diff --git a/.pylintrc b/.pylintrc index daee0a9..94ca7f4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,4 +1,642 @@ [MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list=[gi] + + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked and +# will not be imported (useful for modules/projects where namespaces are +# manipulated during runtime and thus existing member attributes cannot be +# deduced by static analysis). It supports qualified module names, as well as +# Unix pattern matching. +ignored-modules= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Resolve imports to .pyi stubs if available. May reduce no-member messages and +# increase not-an-iterable messages. +prefer-stubs=no + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.13 + +# Discover python modules and packages in the file system subtree. +recursive=yes + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=yes + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. max-line-length=150 + +# Maximum number of lines in a module. max-module-lines=2000 -generated-member=cairo.* \ No newline at end of file + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + use-implicit-booleaness-not-comparison-to-string, + use-implicit-booleaness-not-comparison-to-zero, + wrong-import-position, + wrong-import-order + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable= + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + +# Let 'consider-using-join' be raised when the separator to join on would be +# non-empty (resulting in expected fixes of the type: ``"- " + " - +# ".join(items)``) +suggest-join-with-non-empty-separator=yes + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are: 'text', 'parseable', +# 'colorized', 'json2' (improved json format), 'json' (old json format), msvs +# (visual studio) and 'github' (GitHub actions). You can also give a reporter +# class, e.g. mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The maximum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + diff --git a/CODING b/CODING index 2d3382b..b03c7f9 100644 --- a/CODING +++ b/CODING @@ -1,5 +1,5 @@ -Attempts have been made to keep this project meeting PEP8 standards. While -meeting this standard would be nice pull request & patch acceptance will +Attempts have been made to keep this project readable, using pylint and python black. +While meeting this standard would be nice pull request & patch acceptance will be based on merit first and coding standard second. Due to a quirk in the way pygobject functions and the fact we use soft diff --git a/README.md b/README.md index 49aa571..76729c5 100644 --- a/README.md +++ b/README.md @@ -147,29 +147,18 @@ It is advised to install python-gobject from your system's own package manager. #### Debian/Ubuntu -`apt install python3-gi python3-gi-cairo libappindicator3-dev` +`apt install python4-gi gtk4-layer-shell libgtk4-layer-shell-dev` Libappindicator might conflict with other installed packages, but is optional -with Wayland support - -`apt install gtk-layer-shell libgtk-layer-shell-dev` - #### Arch -`pacman -S python-gobject libappindicator-gtk3` - -with Wayland support - -`pacman -S gtk-layer-shell` +`pacman -S python-gobject gtk4-layer-shell` #### Fedora -`dnf install python3-pip python3-gobject gtk3-devel python3-cairo python-devel python-gobject python-gobject-devel` - -with Wayland support +`dnf install python3-pip python3-gobject gtk4-devel python3-cairo python-devel python-gobject python-gobject-devel gtk4-layer-shell` -`dnf install gtk-layer-shell` ## Usage diff --git a/_version.py b/_version.py new file mode 100644 index 0000000..777f190 --- /dev/null +++ b/_version.py @@ -0,0 +1 @@ +__version__ = "0.8.0" diff --git a/discover_overlay/audio_assist.py b/discover_overlay/audio_assist.py index 80a8dd4..5893c39 100644 --- a/discover_overlay/audio_assist.py +++ b/discover_overlay/audio_assist.py @@ -28,7 +28,10 @@ def __init__(self, discover): self.thread = None self.enabled = False - self.source = None # String containing the name of the PA/PW microphone or other input + + # String containing the name of the PA/PW microphone or other input + self.source = None + self.sink = None # String containing the name of the PA/PW output self.discover = discover @@ -68,9 +71,9 @@ def thread_loop(self): async def listen(self): """Async to connect to pulse and listen for events""" try: - async with pulsectl_asyncio.PulseAsync('Discover-Monitor') as pulse: + async with pulsectl_asyncio.PulseAsync("Discover-Monitor") as pulse: await self.get_device_details(pulse) - async for event in pulse.subscribe_events('all'): + async for event in pulse.subscribe_events("all"): await self.handle_events(pulse, event) except pulsectl.pulsectl.PulseDisconnected: log.info("Pulse has gone away") @@ -118,28 +121,28 @@ async def get_device_details(self, pulse): self.discover.set_mute_async(mute) async def handle_events(self, pulse, ev): - """ `Sink` and `Source` events are fired for changes to output and inputs - `Server` is fired when default sink or source changes.""" + """`Sink` and `Source` events are fired for changes to output and inputs + `Server` is fired when default sink or source changes.""" if not self.enabled: return match ev.facility: - case 'sink': + case "sink": await self.get_device_details(pulse) - case 'source': + case "source": await self.get_device_details(pulse) - case 'server': + case "server": await self.get_device_details(pulse) - case 'source_output': + case "source_output": pass - case 'sink_input': + case "sink_input": pass - case 'client': + case "client": pass case _: diff --git a/discover_overlay/autostart.py b/discover_overlay/autostart.py index b858bb0..e56c7d2 100644 --- a/discover_overlay/autostart.py +++ b/discover_overlay/autostart.py @@ -13,7 +13,7 @@ """A class to assist auto-start""" import os import logging -import shutil + try: from xdg.BaseDirectory import xdg_config_home, xdg_data_home except ModuleNotFoundError: @@ -30,14 +30,17 @@ def __init__(self, app_name): if not app_name.endswith(".desktop"): app_name = f"{app_name}.desktop" self.app_name = app_name - self.auto_locations = [os.path.join( - xdg_config_home, 'autostart/'), '/etc/xdg/autostart/'] - self.desktop_locations = [os.path.join( - xdg_data_home, 'applications/'), '/usr/share/applications/'] + self.auto_locations = [ + os.path.join(xdg_config_home, "autostart/"), + "/etc/xdg/autostart/", + ] + self.desktop_locations = [ + os.path.join(xdg_data_home, "applications/"), + "/usr/share/applications/", + ] self.auto = self.find_auto() self.desktop = self.find_desktop() - log.info("Autostart info : desktop %s auto %s", - self.desktop, self.auto) + log.info("Autostart info : desktop %s auto %s", self.desktop, self.auto) def find_auto(self): """Check all known locations for auto-started apps""" @@ -59,7 +62,7 @@ def set_autostart(self, enable): """Set or Unset auto-start state""" if enable and not self.auto: # Enable - directory = os.path.join(xdg_config_home, 'autostart') + directory = os.path.join(xdg_config_home, "autostart") self.auto = os.path.join(directory, self.app_name) os.makedirs(directory, exist_ok=True) os.symlink(self.desktop, self.auto) @@ -71,41 +74,3 @@ def set_autostart(self, enable): def is_auto(self): """Check if it's already set to auto-start""" return True if self.auto else False - - -class BazziteAutostart: - """A class to assist auto-start""" - - def __init__(self): - self.auto = False - with open("/etc/default/discover-overlay", encoding="utf-8") as f: - content = f.readlines() - for line in content: - if line.startswith("AUTO_LAUNCH_DISCOVER_OVERLAY="): - self.auto = int(line.split("=")[1]) > 0 - log.info("Bazzite Autostart info : %s", - self.auto) - - def set_autostart(self, enable): - """Set or Unset auto-start state""" - if enable and not self.auto: - self.change_file("1") - elif not enable and self.auto: - self.change_file("0") - self.auto = enable - - def change_file(self, value): - """Alter bazzite config via pkexec and sed""" - root = '' - if shutil.which('pkexec'): - root = 'pkexec' - else: - log.error("No ability to request root privs. Cancel") - return - command = f" sed -i 's/AUTO_LAUNCH_DISCOVER_OVERLAY=./AUTO_LAUNCH_DISCOVER_OVERLAY={value}/g' /etc/default/discover-overlay" - command_with_permissions = root + command - os.system(command_with_permissions) - - def is_auto(self): - """Check if it's already set to auto-start""" - return self.auto diff --git a/discover_overlay/connection_state.py b/discover_overlay/connection_state.py new file mode 100644 index 0000000..d6b6d0e --- /dev/null +++ b/discover_overlay/connection_state.py @@ -0,0 +1,13 @@ +"""Enum of states of discover connection to discord""" + +from enum import Enum + + +class ConnectionState(Enum): + """Possible states of service""" + + NO_DISCORD = 0 # We havn't managed to reach Discord on localhost + DISCORD_INVALID = 1 # Port connection works but turns away RPC. + NO_VOICE_CHAT = 2 # We're connected but the user is not in a room + VOICE_CHAT_NOT_CONNECTED = 3 # We've chosen a room but not successfully connected to it yet (or connection has degraded) + CONNECTED = 4 # Connected and working diff --git a/discover_overlay/css_helper.py b/discover_overlay/css_helper.py new file mode 100644 index 0000000..a75871d --- /dev/null +++ b/discover_overlay/css_helper.py @@ -0,0 +1,56 @@ +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Functions to assist font picking""" +import gi +import json + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, Pango + + +# https://toshiocp.github.io/Gtk4-tutorial/sec23.html +# TODO Weights, Italics +def desc_to_css_font(desc): + """Formats a font description into a CSS rule""" + if desc.get_size_is_absolute(): + size = f"{desc.get_size() / Pango.SCALE}px" + else: + size = f"{desc.get_size() / Pango.SCALE}pt" + mods = "" + family = desc.get_family() + font = f'{size} {mods} "{family}"' + return font + + +def font_string_to_css_font_string(string_in): + """Takes a string of uncertain origin and feeds it into a + Gtk.FontButton in the hopes of turning it into a font + description, then turning that into a CSS rule""" + if string_in[0].isnumeric(): # If it starts with a number it is Probably correct + return string_in + # It might be an old-style font string... + fb = Gtk.FontButton() + fb.set_font(string_in) + return desc_to_css_font(fb.get_font_desc()) + + +def col_to_css(col): + """Convert a JSON-encoded string or a tuple into a CSS colour""" + if isinstance(col, str): + col = json.loads(col) + assert len(col) == 4 + red = int(col[0] * 255) + green = int(col[1] * 255) + blue = int(col[2] * 255) + alpha = col[3] + return f"rgba({red},{green},{blue},{alpha:2.2f})" diff --git a/discover_overlay/discord_connector.py b/discover_overlay/discord_connector.py index c13bc81..bbf2994 100644 --- a/discover_overlay/discord_connector.py +++ b/discover_overlay/discord_connector.py @@ -27,6 +27,7 @@ import calendar import websocket import requests +from .connection_state import ConnectionState from gi.repository import GLib @@ -44,21 +45,17 @@ def __init__(self, discover): self.discover = discover self.websocket = None self.access_token = discover.config().get( - "cache", "access_token", fallback=None) + "cache", "access_token", fallback=None + ) self.oauth_token = "207646673902501888" self.guilds = {} self.channels = {} self.user = {} - self.userlist = {} - self.in_room = [] self.current_guild = "0" self.current_voice = "0" self.current_text = "0" self.current_text_guild = "0" - self.list_altered = False - self.text_altered = False - self.text = [] self.authed = False self.last_rate_limit_send = 0 self.muted = False @@ -68,6 +65,11 @@ def __init__(self, discover): self.rate_limited_channels = [] self.reconnect_cb = None + self.reconnect_time = 5 + + self.rate_limit = None + + self.state = ConnectionState.NO_DISCORD def get_access_token_stage1(self): """ @@ -78,13 +80,12 @@ def get_access_token_stage1(self): return cmd = { "cmd": "AUTHORIZE", - "args": - { + "args": { "client_id": self.oauth_token, "scopes": ["rpc", "messages.read", "rpc.notifications.read"], "prompt": "none", }, - "nonce": "deadbeef" + "nonce": "deadbeef", } self.websocket.send(json.dumps(cmd)) @@ -98,8 +99,8 @@ def get_access_token_stage2(self, code1): try: jsonresponse = json.loads(response.text) except requests.exceptions.Timeout: - # TODO This probably needs a retry, not a quit - jsonresponse = {} + self.websocket.close() + return except requests.exceptions.TooManyRedirects: jsonresponse = {} except json.JSONDecodeError: @@ -118,16 +119,18 @@ def set_channel(self, channel, guild, need_req=True): Set currently active voice channel """ if not channel: + self.set_state(ConnectionState.NO_VOICE_CHAT) if self.current_voice: self.unsub_voice_channel(self.current_voice) self.current_voice = "0" self.current_guild = "0" self.discover.voice_overlay.set_blank() - self.in_room = [] return if channel != self.current_voice: + self.set_state(ConnectionState.VOICE_CHAT_NOT_CONNECTED) if self.current_voice != "0": self.unsub_voice_channel(self.current_voice) + self.discover.voice_overlay.set_blank() self.sub_voice_channel(channel) self.current_voice = channel self.current_guild = guild @@ -152,72 +155,51 @@ def set_text_channel(self, channel, guild, need_req=True): if need_req: self.req_channel_details(channel) - def set_in_room(self, userid, present): - """ - Set user currently in given room - """ - if present: - if userid not in self.in_room: - self.in_room.append(userid) - else: - if userid in self.in_room: - self.in_room.remove(userid) - def add_text(self, message): """ Add line of text to text list. Assumes the message is from the correct room """ utc_time = None try: - utc_time = time.strptime( - message["timestamp"], "%Y-%m-%dT%H:%M:%S.%f%z") + utc_time = time.strptime(message["timestamp"], "%Y-%m-%dT%H:%M:%S.%f%z") except ValueError: - utc_time = time.strptime( - message["timestamp"], "%Y-%m-%dT%H:%M:%S%z") + utc_time = time.strptime(message["timestamp"], "%Y-%m-%dT%H:%M:%S%z") epoch_time = calendar.timegm(utc_time) username = message["author"]["username"] - if ("nick" in message and message['nick'] and len(message["nick"]) > 1 - and 'object Object' not in json.dumps(message["nick"])): + if ( + "nick" in message + and message["nick"] + and len(message["nick"]) > 1 + and "object Object" not in json.dumps(message["nick"]) + ): username = message["nick"] colour = "#ffffff" if "author_color" in message: colour = message["author_color"] - self.text.append({'id': message["id"], - 'content': self.get_message_from_message(message), - 'nick': username, - 'nick_col': colour, - 'time': epoch_time, - 'attach': self.get_attachment_from_message(message), - }) - self.text_altered = True + self.discover.text_overlay.new_line( + { + "id": message["id"], + "content": self.get_message_from_message(message), + "nick": username, + "nick_col": colour, + "time": epoch_time, + "attach": self.get_attachment_from_message(message), + } + ) def update_text(self, message_in): """ Update a line of text """ - for idx, message in enumerate(self.text): - if message['id'] == message_in['id']: - new_message = {'id': message['id'], - 'content': self.get_message_from_message(message_in), - 'nick': message['nick'], - 'nick_col': message['nick_col'], - 'time': message['time'], - 'attach': message['attach']} - self.text[idx] = new_message - self.text_altered = True - return + self.discover.text_overlay.update_message(message_in["id"], message_in) def delete_text(self, message_in): """ Delete a line of text """ - for idx, message in enumerate(self.text): - if message['id'] == message_in['id']: - del self.text[idx] - self.text_altered = True - return + self.discover.text_overlay.update_message(message_in["id"]) def get_message_from_message(self, message): """ @@ -246,38 +228,13 @@ def get_attachment_from_message(self, message): return message["attachments"] return None - def update_user(self, user): - """ - Update user information - Pass along our custom user information from version to version - """ - if user["id"] in self.userlist: - olduser = self.userlist[user["id"]] - if "mute" not in user and "mute" in olduser: - user["mute"] = olduser["mute"] - if "deaf" not in user and "deaf" in olduser: - user["deaf"] = olduser["deaf"] - if "speaking" not in user and "speaking" in olduser: - user["speaking"] = olduser["speaking"] - if "nick" not in user and "nick" in olduser: - user["nick"] = olduser["nick"] - if "lastspoken" not in user and "lastspoken" in olduser: - user["lastspoken"] = olduser["lastspoken"] - if olduser["avatar"] != user["avatar"]: - self.discover.voice_overlay.delete_avatar(user["id"]) - if "lastspoken" not in user: # Still nothing? - user["lastspoken"] = 0 # EEEEPOOCH EEEEEPOCH! BELIEVE MEEEE - if "speaking" not in user: - user["speaking"] = False - self.userlist[user["id"]] = user - def on_message(self, message): """ Recieve websocket message super-function """ j = json.loads(message) if j["cmd"] == "AUTHORIZE": - if 'data' in j and 'code' in j['data']: + if "data" in j and "code" in j["data"]: self.get_access_token_stage2(j["data"]["code"]) else: log.error("Authorization rejected") @@ -287,56 +244,79 @@ def on_message(self, message): if j["evt"] == "READY": self.req_auth() elif j["evt"] == "VOICE_STATE_UPDATE": - self.list_altered = True thisuser = j["data"]["user"] nick = j["data"]["nick"] thisuser["nick"] = nick - mute = (j["data"]["voice_state"]["mute"] or - j["data"]["voice_state"]["self_mute"] or - j["data"]["voice_state"]["suppress"]) - deaf = j["data"]["voice_state"]["deaf"] or j["data"]["voice_state"]["self_deaf"] - thisuser["mute"] = mute - thisuser["deaf"] = deaf - if self.current_voice != "0": - self.update_user(thisuser) - self.set_in_room(thisuser["id"], True) + mute = ( + j["data"]["voice_state"]["mute"] + or j["data"]["voice_state"]["self_mute"] + or j["data"]["voice_state"]["suppress"] + ) + deaf = ( + j["data"]["voice_state"]["deaf"] + or j["data"]["voice_state"]["self_deaf"] + ) + if "mute" not in thisuser or thisuser["mute"] != mute: + thisuser["mute"] = mute + self.discover.voice_overlay.set_mute(thisuser["id"], mute) + if "deaf" not in thisuser or thisuser["deaf"] != deaf: + thisuser["deaf"] = deaf + self.discover.voice_overlay.set_deaf(thisuser["id"], deaf) + self.discover.voice_overlay.update_user(thisuser) elif j["evt"] == "VOICE_STATE_CREATE": - self.list_altered = True thisuser = j["data"]["user"] nick = j["data"]["nick"] thisuser["nick"] = nick - self.update_user(thisuser) + mute = ( + j["data"]["voice_state"]["mute"] + or j["data"]["voice_state"]["self_mute"] + or j["data"]["voice_state"]["suppress"] + ) + deaf = ( + j["data"]["voice_state"]["deaf"] + or j["data"]["voice_state"]["self_deaf"] + ) + if "mute" not in thisuser or thisuser["mute"] != mute: + thisuser["mute"] = mute + self.discover.voice_overlay.set_mute(thisuser["id"], mute) + if "deaf" not in thisuser or thisuser["deaf"] != deaf: + thisuser["deaf"] = deaf + self.discover.voice_overlay.set_deaf(thisuser["id"], deaf) # We've joined a room... but where? if j["data"]["user"]["id"] == self.user["id"]: self.find_user() - self.userlist[thisuser["id"]]["lastspoken"] = time.perf_counter() + self.discover.voice_overlay.update_user(thisuser) elif j["evt"] == "VOICE_STATE_DELETE": - self.list_altered = True - self.set_in_room(j["data"]["user"]["id"], False) if j["data"]["user"]["id"] == self.user["id"]: - self.in_room = [] + # We've left the room, empty overlay and ask where we are now self.find_user() - self.discover.voice_overlay.set_channel_title(None) - self.discover.voice_overlay.set_channel_icon(None) - # User might have been forcibly moved room + self.discover.voice_overlay.set_blank() + else: + # Remove this user from overlay + self.discover.voice_overlay.del_user(thisuser) elif j["evt"] == "SPEAKING_START": - self.list_altered = True - self.userlist[j["data"]["user_id"]]["speaking"] = True - self.userlist[j["data"]["user_id"]]["lastspoken"] = time.perf_counter() - self.set_in_room(j["data"]["user_id"], True) + self.discover.voice_overlay.set_talking(j["data"]["user_id"], True) elif j["evt"] == "SPEAKING_STOP": - self.list_altered = True - if j["data"]["user_id"] in self.userlist: - self.userlist[j["data"]["user_id"]]["speaking"] = False - self.set_in_room(j["data"]["user_id"], True) + self.discover.voice_overlay.set_talking(j["data"]["user_id"], False) elif j["evt"] == "VOICE_CHANNEL_SELECT": if j["data"]["channel_id"]: - self.set_channel(j["data"]["channel_id"], - j["data"]["guild_id"]) + self.set_channel(j["data"]["channel_id"], j["data"]["guild_id"]) else: self.set_channel(None, None) elif j["evt"] == "VOICE_CONNECTION_STATUS": - self.discover.voice_overlay.set_connection_status(j["data"]) + state = j["data"]["state"] + if ( + state == "NO_ROUTE" + or state == "VOICE_DISCONNECTED" + or state == "ICE_CHECKING" + or state == "AWAITING_ENDPOINT" + or state == "AUTHENTICATING" + or state == "VOICE_CONNECTING" + or state == "CONNECTING" + ): + self.set_state(ConnectionState.VOICE_CHAT_NOT_CONNECTED) + elif state == "CONNECTED" or state == "VOICE_CONNECTED": + self.set_state(ConnectionState.CONNECTED) elif j["evt"] == "MESSAGE_CREATE": if self.current_text == j["data"]["channel_id"]: self.add_text(j["data"]["message"]) @@ -348,39 +328,37 @@ def on_message(self, message): self.delete_text(j["data"]["message"]) elif j["evt"] == "CHANNEL_CREATE": # We haven't been told what guild this is in - self.req_channel_details(j["data"]["id"], 'new') + self.req_channel_details(j["data"]["id"], "new") elif j["evt"] == "NOTIFICATION_CREATE": self.discover.notification_overlay.add_notification_message(j) elif j["evt"] == "VOICE_SETTINGS_UPDATE": - source = j['data']['input']['device_id'] - sink = j['data']['output']['device_id'] - if sink == 'default': - for available_sink in j['data']['output']['available_devices']: - if available_sink['id'] == 'default': - sink = available_sink['name'][9:] - if source == 'default': - for available_source in j['data']['input']['available_devices']: - if available_source['id'] == 'default': - source = available_source['name'][9:] + source = j["data"]["input"]["device_id"] + sink = j["data"]["output"]["device_id"] + if sink == "default": + for available_sink in j["data"]["output"]["available_devices"]: + if available_sink["id"] == "default": + sink = available_sink["name"][9:] + if source == "default": + for available_source in j["data"]["input"]["available_devices"]: + if available_source["id"] == "default": + source = available_source["name"][9:] self.discover.audio_assist.set_devices(sink, source) else: log.warning(j) return elif j["cmd"] == "AUTHENTICATE": + self.set_state(ConnectionState.NO_VOICE_CHAT) + if j["evt"] == "ERROR": self.access_token = None self.get_access_token_stage1() return else: - self.discover.config_set( - "cache", "access_token", self.access_token) + self.discover.config_set("cache", "access_token", self.access_token) self.req_guilds() self.user = j["data"]["user"] - log.info( - "ID is %s", self.user["id"]) - log.info( - "Logged in as %s", self.user["username"]) + log.info("Successfully connected to a Discord client") self.authed = True self.on_connected() return @@ -398,13 +376,13 @@ def on_message(self, message): return elif j["cmd"] == "GET_CHANNELS": - if j['evt'] == 'ERROR': - log.error('%s', j['data']['message']) + if j["evt"] == "ERROR": + log.error("%s", j["data"]["message"]) return - self.guilds[j['nonce']]["channels"] = j["data"]["channels"] + self.guilds[j["nonce"]]["channels"] = j["data"]["channels"] for channel in j["data"]["channels"]: - channel['guild_id'] = j['nonce'] - channel['guild_name'] = self.guilds[j['nonce']]["name"] + channel["guild_id"] = j["nonce"] + channel["guild_name"] = self.guilds[j["nonce"]]["name"] self.channels[channel["id"]] = channel if channel["type"] == 2: self.req_channel_details(channel["id"]) @@ -412,47 +390,47 @@ def on_message(self, message): return elif j["cmd"] == "SUBSCRIBE": # Only log errors - if j['evt']: + if j["evt"]: log.warning(j) return elif j["cmd"] == "UNSUBSCRIBE": return elif j["cmd"] == "GET_SELECTED_VOICE_CHANNEL": - if 'data' in j and j['data'] and 'id' in j['data']: - self.set_channel(j['data']['id'], j['data']['guild_id']) - self.discover.voice_overlay.set_channel_title( - j["data"]["name"]) - if (self.current_guild in self.guilds and - 'icon_url' in self.guilds[self.current_guild]): + if "data" in j and j["data"] and "id" in j["data"]: + self.set_channel(j["data"]["id"], j["data"]["guild_id"]) + self.discover.voice_overlay.set_channel_title(j["data"]["name"]) + if ( + self.current_guild in self.guilds + and "icon_url" in self.guilds[self.current_guild] + ): self.discover.voice_overlay.set_channel_icon( - self.guilds[self.current_guild]['icon_url']) + self.guilds[self.current_guild]["icon_url"] + ) else: self.discover.voice_overlay.set_channel_icon(None) - self.list_altered = True - self.in_room = [] - for u in j['data']['voice_states']: + for u in j["data"]["voice_states"]: thisuser = u["user"] nick = u["nick"] thisuser["nick"] = nick - mute = (u["voice_state"]["mute"] or - u["voice_state"]["self_mute"] or - u["voice_state"]["suppress"]) + mute = ( + u["voice_state"]["mute"] + or u["voice_state"]["self_mute"] + or u["voice_state"]["suppress"] + ) deaf = u["voice_state"]["deaf"] or u["voice_state"]["self_deaf"] thisuser["mute"] = mute thisuser["deaf"] = deaf - self.update_user(thisuser) - self.set_in_room(thisuser["id"], True) + self.discover.voice_overlay.update_user(thisuser) return elif j["cmd"] == "GET_CHANNEL": if j["evt"] == "ERROR": - log.info( - "Could not get room") + log.info("Could not get room") return if j["nonce"] == "new": self.req_channels(j["data"]["guild_id"]) if j["data"]["type"] == 0: # Text channel if self.current_text == j["data"]["id"]: - self.text = [] + self.discover.text_overlay.set_blank() for message in j["data"]["messages"]: self.add_text(message) @@ -460,18 +438,18 @@ def on_message(self, message): elif j["cmd"] == "SELECT_VOICE_CHANNEL": return elif j["cmd"] == "SET_VOICE_SETTINGS": - self.muted = j['data']['mute'] - self.deafened = j['data']['deaf'] + # Keep this for toggling mute from RPC + self.muted = j["data"]["mute"] + self.deafened = j["data"]["deaf"] return elif j["cmd"] == "GET_VOICE_SETTINGS": return log.warning(j) def dump_channel_data(self): - """ Write all channel data out to file""" - with open(self.discover.channel_file, 'w', encoding="utf-8") as f: - f.write(json.dumps( - {'channels': self.channels, 'guild': self.guilds})) + """Write all channel data out to file""" + with open(self.discover.channel_file, "w", encoding="utf-8") as f: + f.write(json.dumps({"channels": self.channels, "guild": self.guilds})) def on_connected(self): """ @@ -497,7 +475,7 @@ def on_close(self): GLib.source_remove(self.socket_watch) self.socket_watch = None self.websocket = None - self.update_overlays_from_data() + self.blank_overlays() self.current_voice = "0" self.schedule_reconnect() @@ -507,10 +485,8 @@ def req_auth(self): """ cmd = { "cmd": "AUTHENTICATE", - "args": { - "access_token": self.access_token - }, - "nonce": "deadbeef" + "args": {"access_token": self.access_token}, + "nonce": "deadbeef", } self.websocket.send(json.dumps(cmd)) @@ -518,22 +494,16 @@ def req_guild(self, guild_id, nonce): """ Request info on one guild """ - cmd = { - "cmd": "GET_GUILD", - "args": {"guild_id": guild_id}, - "nonce": nonce - } + cmd = {"cmd": "GET_GUILD", "args": {"guild_id": guild_id}, "nonce": nonce} self.websocket.send(json.dumps(cmd)) def req_guilds(self): """ Request all guilds information for logged in user """ - cmd = { - "cmd": "GET_GUILDS", - "args": {}, - "nonce": "deadbeef" - } + if not self.websocket: + return + cmd = {"cmd": "GET_GUILDS", "args": {}, "nonce": "deadbeef"} self.websocket.send(json.dumps(cmd)) def req_channels(self, guild): @@ -555,13 +525,7 @@ def req_channel_details(self, channel, nonce=None): return if not nonce: nonce = channel - cmd = { - "cmd": "GET_CHANNEL", - "args": { - "channel_id": channel - }, - "nonce": nonce - } + cmd = {"cmd": "GET_CHANNEL", "args": {"channel_id": channel}, "nonce": nonce} self.websocket.send(json.dumps(cmd)) def find_user(self): @@ -569,37 +533,21 @@ def find_user(self): Find the user """ - cmd = { - "cmd": "GET_SELECTED_VOICE_CHANNEL", - "args": { - - }, - "nonce": "test" - } + cmd = {"cmd": "GET_SELECTED_VOICE_CHANNEL", "args": {}, "nonce": "test"} self.websocket.send(json.dumps(cmd)) def sub_raw(self, event, args, nonce): """ Subscribe to event helper function """ - cmd = { - "cmd": "SUBSCRIBE", - "args": args, - "evt": event, - "nonce": nonce - } + cmd = {"cmd": "SUBSCRIBE", "args": args, "evt": event, "nonce": nonce} self.websocket.send(json.dumps(cmd)) def unsub_raw(self, event, args, nonce): """ Subscribe to event helper function """ - cmd = { - "cmd": "UNSUBSCRIBE", - "args": args, - "evt": event, - "nonce": nonce - } + cmd = {"cmd": "UNSUBSCRIBE", "args": args, "evt": event, "nonce": nonce} self.websocket.send(json.dumps(cmd)) def sub_server(self): @@ -669,32 +617,24 @@ def get_voice_settings(self): """ Request a recent version of voice settings """ - cmd = { - "cmd": "GET_VOICE_SETTINGS", - "args": {}, - "nonce": "deadbeef" - } + cmd = {"cmd": "GET_VOICE_SETTINGS", "args": {}, "nonce": "deadbeef"} if self.websocket: self.websocket.send(json.dumps(cmd)) def set_mute(self, muted): - """ Set client muted status """ + """Set client muted status""" cmd = { "cmd": "SET_VOICE_SETTINGS", "args": {"mute": muted}, - "nonce": "deadbeef" + "nonce": "deadbeef", } if self.websocket: self.websocket.send(json.dumps(cmd)) return False def set_deaf(self, deaf): - """ Set client deafened status """ - cmd = { - "cmd": "SET_VOICE_SETTINGS", - "args": {"deaf": deaf}, - "nonce": "deadbeef" - } + """Set client deafened status""" + cmd = {"cmd": "SET_VOICE_SETTINGS", "args": {"deaf": deaf}, "nonce": "deadbeef"} if self.websocket: self.websocket.send(json.dumps(cmd)) return False @@ -705,11 +645,8 @@ def change_voice_room(self, room_id): """ cmd = { "cmd": "SELECT_VOICE_CHANNEL", - "args": { - "channel_id": room_id, - "force": True - }, - "nonce": "deadbeef" + "args": {"channel_id": room_id, "force": True}, + "nonce": "deadbeef", } if self.websocket: self.websocket.send(json.dumps(cmd)) @@ -720,50 +657,35 @@ def change_text_room(self, room_id): """ cmd = { "cmd": "SELECT_TEXT_CHANNEL", - "args": { - "channel_id": room_id - }, - "nonce": "deadbeef" + "args": {"channel_id": room_id}, + "nonce": "deadbeef", } if self.websocket: self.websocket.send(json.dumps(cmd)) - def update_overlays_from_data(self): - """Send new data out to overlay windows""" - if self.websocket is None: - self.discover.voice_overlay.set_blank() - if self.discover.text_overlay: - self.discover.text_overlay.set_blank() - if self.discover.notification_overlay: - self.discover.notification_overlay.set_blank() - return - newlist = [] - for userid in self.in_room: - newlist.append(self.userlist[userid]) - self.discover.voice_overlay.set_user_list(newlist, self.list_altered) - self.list_altered = False - # Update text list - if self.discover.text_overlay.popup_style: - self.text_altered = True - if self.text_altered: - self.discover.text_overlay.set_text_list( - self.text, self.text_altered) - self.text_altered = False - - if self.authed and len(self.rate_limited_channels) > 0: - now = time.time() - if self.last_rate_limit_send < now - 60: - guild = self.rate_limited_channels.pop() - - cmd = { - "cmd": "GET_CHANNELS", - "args": { - "guild_id": guild - }, - "nonce": guild - } - self.websocket.send(json.dumps(cmd)) - self.last_rate_limit_send = now + def channel_rate_limit(self): + """Called regularly to pull in any required channels""" + if self.websocket and self.authed and len(self.rate_limited_channels) > 0: + guild = self.rate_limited_channels.pop() + log.info("Getting guild : %s", guild) + cmd = { + "cmd": "GET_CHANNELS", + "args": {"guild_id": guild}, + "nonce": guild, + } + self.websocket.send(json.dumps(cmd)) + + continue_rate_limit = len(self.rate_limited_channels) > 0 + if not continue_rate_limit: + self.rate_limit = None + return continue_rate_limit + + def blank_overlays(self): + """Clear information from overlays""" + self.discover.voice_overlay.set_blank() + if self.discover.text_overlay: + self.discover.text_overlay.set_blank() + return def start_listening_text(self, channel): """ @@ -787,13 +709,24 @@ def request_text_rooms_for_guild(self, guild_id): """ if guild_id == 0: return - self.rate_limited_channels.append(guild_id) + if guild_id not in self.rate_limited_channels: + self.rate_limited_channels.append(guild_id) + if not self.rate_limit: + # Run once now and schedule for 15 seconds. + # Any others added suddently will have to wait, or timeout will clear eventually + self.channel_rate_limit() + self.rate_limit = GLib.timeout_add_seconds(15, self.channel_rate_limit) def schedule_reconnect(self): """Set a timer to attempt reconnection""" if self.reconnect_cb is None: - log.info("Scheduled a reconnect") - self.reconnect_cb = GLib.timeout_add_seconds(60, self.connect) + log.info("Scheduled a reconnect in %s seconds", self.reconnect_time) + self.reconnect_cb = GLib.timeout_add_seconds( + self.reconnect_time, self.connect + ) + self.reconnect_time += 5 + if self.reconnect_time > 60: + self.reconnect_time = 60 else: log.error("Reconnect already scheduled") @@ -803,6 +736,7 @@ def connect(self): Should not throw simply for being unable to connect, only for more serious issues """ + self.authed = False log.info("Connecting...") if self.websocket: log.warning("Already connected?") @@ -814,7 +748,7 @@ def connect(self): self.websocket = websocket.create_connection( f"ws://127.0.0.1:6463/?v=1&client_id={self.oauth_token}", origin="http://localhost:3000", - timeout=0.1 + timeout=0.2, ) if self.socket_watch: GLib.source_remove(self.socket_watch) @@ -822,8 +756,9 @@ def connect(self): self.websocket.sock, GLib.PRIORITY_DEFAULT_IDLE, GLib.IOCondition.HUP | GLib.IOCondition.IN | GLib.IOCondition.ERR, - self.socket_glib + self.socket_glib, ) + self.reconnect_time = 5 except ConnectionError as _error: self.schedule_reconnect() @@ -839,13 +774,34 @@ def socket_glib(self, _fd, condition): if not self.websocket: # Connection was closed in the meantime break - recv, _w, _e = select.select( - (self.websocket.sock,), (), (), 0) - except (websocket.WebSocketConnectionClosedException, json.decoder.JSONDecodeError): + recv, _w, _e = select.select((self.websocket.sock,), (), (), 0) + except websocket.WebSocketConnectionClosedException as e: + log.error("Connector Websocket closed : %s", e) + self.on_close() + break + except json.decoder.JSONDecodeError as e: + log.error("Invalid JSON from Discord : %s", e) + log.error("This is probably a modded client...") + self.set_state(ConnectionState.DISCORD_INVALID) + # It's VERY unlikely this will be fixed in sensible time frame + # So set a high retry time to limit wasted CPU + self.reconnect_time = 60 self.on_close() break - self.update_overlays_from_data() else: - self.update_overlays_from_data() + self.blank_overlays() + self.authed = False + self.set_state(ConnectionState.NO_DISCORD) return False return True + + def set_state(self, state): + """Passes state of play to voice overlay for user feedback""" + if ( # This state remains until a successful connection + state == ConnectionState.NO_DISCORD + and self.state == ConnectionState.DISCORD_INVALID + ): + return + if self.state != state: + self.state = state + self.discover.voice_overlay.set_connection_status(state) diff --git a/discover_overlay/discover_overlay.py b/discover_overlay/discover_overlay.py index 4e75283..4ca588b 100755 --- a/discover_overlay/discover_overlay.py +++ b/discover_overlay/discover_overlay.py @@ -11,28 +11,33 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Main application class""" + import gettext import os import sys import re import traceback import logging -import json import signal +import importlib_resources from configparser import ConfigParser +from ctypes import CDLL +from _version import __version__ + +CDLL("libgtk4-layer-shell.so") + import gi -import pkg_resources -from .settings_window import MainSettingsWindow +from .overlay import OverlayWindow +from .settings_window import Settings from .voice_overlay import VoiceOverlayWindow from .text_overlay import TextOverlayWindow from .notification_overlay import NotificationOverlayWindow from .discord_connector import DiscordConnector from .audio_assist import DiscoverAudioAssist -gi.require_version("Gtk", "3.0") -# pylint: disable=wrong-import-position,wrong-import-order -from gi.repository import Gtk, GLib, Gio # nopep8 +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, GLib, Gio try: from xdg.BaseDirectory import xdg_config_home @@ -40,15 +45,29 @@ from xdg import XDG_CONFIG_HOME as xdg_config_home log = logging.getLogger(__name__) -t = gettext.translation( - 'default', pkg_resources.resource_filename('discover_overlay', 'locales'), fallback=True) -_ = t.gettext +with importlib_resources.as_file( + importlib_resources.files("discover_overlay") / "locales" +) as path: + t = gettext.translation( + "default", + path, + fallback=True, + ) + _ = t.gettext class Discover: """Main application class""" def __init__(self, rpc_file, config_file, channel_file, debug_file, args): + unsupported_desktops = ["gnome", "weston", "gamescope"] + if os.getenv("XDG_SESSION_DESKTOP", "none").lower() in unsupported_desktops: + log.warning( + "GTK Layer Shell is not supported on this Wayland compositor. Removing WAYLAND_DISPLAY to fallback to X11" + ) + os.unsetenv("WAYLAND_DISPLAY") + # pylint: disable=E1120 + Gtk.init() self.mix_settings = False self.ind = None self.tray = None @@ -61,18 +80,21 @@ def __init__(self, rpc_file, config_file, channel_file, debug_file, args): self.channel_file = channel_file self.config_file = config_file self.rpc_file = rpc_file + self.skip_config_read = False self.do_args(args, True) if "GAMESCOPE_WAYLAND_DISPLAY" in os.environ: log.info( - "GameScope session detected. Enabling steam and gamescope integration") + "GameScope session detected. Enabling steam and gamescope integration" + ) self.steamos = True self.show_settings_delay = True self.mix_settings = True + + # pylint: disable=E1120 settings = Gtk.Settings.get_default() if settings: - settings.set_property( - "gtk-application-prefer-dark-theme", Gtk.true) + settings.set_property("gtk-application-prefer-dark-theme", True) self.create_gui() @@ -91,7 +113,9 @@ def __init__(self, rpc_file, config_file, channel_file, debug_file, args): self.config_changed() - Gtk.main() + # pylint: disable=E1120 + while len(Gtk.Window.get_toplevels()) > 0: + GLib.MainContext.iteration(GLib.MainContext.default(), True) def do_args(self, data, normal_close): """ @@ -139,16 +163,19 @@ def do_args(self, data, normal_close): self.connection.request_text_rooms_for_guild(match.group(1)) def exit(self): + """Kills self, works from threads""" os.kill(os.getpid(), signal.SIGTERM) def config_set(self, context, key, value): - """Set a config value and save to disk""" + """Set a config value and save to disk. Avoid re-reading automatically""" config = self.config() + self.skip_config_read = True if not context in config.sections(): config.add_section(context) config.set(context, key, value) - with open(self.config_file, 'w', encoding="utf-8") as file: + with open(self.config_file, "w", encoding="utf-8") as file: config.write(file) + self.skip_config_read = False def config(self): """Read config from disk""" @@ -169,224 +196,46 @@ def config_changed(self, _a=None, _b=None, _c=None, _d=None): """ Called when the config file has been altered """ + if self.skip_config_read: + log.warning("Config skipped") + return # Read new config config = self.config() - # Set Voice overlay options - self.voice_overlay.set_align_x(config.getboolean( - "main", "rightalign", fallback=False)) - self.voice_overlay.set_align_y( - config.getint("main", "topalign", fallback=1)) - self.voice_overlay.set_bg(json.loads(config.get( - "main", "bg_col", fallback="[0.0,0.0,0.0,0.5]"))) - self.voice_overlay.set_fg(json.loads(config.get( - "main", "fg_col", fallback="[1.0,1.0,1.0,1.0]"))) - self.voice_overlay.set_fg_hi(json.loads(config.get( - "main", "fg_hi_col", fallback="[1.0,1.0,1.0,1.0]"))) - self.voice_overlay.set_tk(json.loads(config.get( - "main", "tk_col", fallback="[0.0,0.7,0.0,1.0]"))) - self.voice_overlay.set_mt(json.loads(config.get( - "main", "mt_col", fallback="[0.6,0.0,0.0,1.0]"))) - self.voice_overlay.set_mute_bg(json.loads(config.get( - "main", "mt_bg_col", fallback="[0.0,0.0,0.0,0.5]"))) - self.voice_overlay.set_hi(json.loads(config.get( - "main", "hi_col", fallback="[0.0,0.0,0.0,0.5]"))) - self.voice_overlay.set_bo(json.loads(config.get( - "main", "bo_col", fallback="[0.0,0.0,0.0,0.0]"))) - self.voice_overlay.set_avatar_bg_col(json.loads(config.get( - "main", "avatar_bg_col", fallback="[0.0,0.0,0.0,0.0]"))) - self.voice_overlay.set_avatar_size( - config.getint("main", "avatar_size", fallback=48)) - self.voice_overlay.set_nick_length( - config.getint("main", "nick_length", fallback=32)) - self.voice_overlay.set_icon_spacing( - config.getint("main", "icon_spacing", fallback=8)) - self.voice_overlay.set_text_padding( - config.getint("main", "text_padding", fallback=6)) - self.voice_overlay.set_text_baseline_adj(config.getint( - "main", "text_baseline_adj", fallback=0)) - font = config.get("main", "font", fallback=None) - title_font = config.get("main", "title_font", fallback=None) - self.voice_overlay.set_square_avatar(config.getboolean( - "main", "square_avatar", fallback=True)) - self.voice_overlay.set_only_speaking(config.getboolean( - "main", "only_speaking", fallback=False)) - self.voice_overlay.set_only_speaking_grace_period(config.getint( - "main", "only_speaking_grace", fallback=0)) - self.voice_overlay.set_highlight_self(config.getboolean( - "main", "highlight_self", fallback=False)) - self.voice_overlay.set_icon_only(config.getboolean( - "main", "icon_only", fallback=False)) - self.voice_overlay.set_vert_edge_padding(config.getint( - "main", "vert_edge_padding", fallback=0)) - self.voice_overlay.set_horz_edge_padding(config.getint( - "main", "horz_edge_padding", fallback=0)) - floating = config.getboolean("main", "floating", fallback=False) - floating_x = config.getfloat("main", "floating_x", fallback=0.0) - floating_y = config.getfloat("main", "floating_y", fallback=0.0) - floating_w = config.getfloat("main", "floating_w", fallback=0.1) - floating_h = config.getfloat("main", "floating_h", fallback=0.1) - self.voice_overlay.set_order( - config.getint("main", "order", fallback=0)) - self.voice_overlay.set_hide_on_mouseover( - config.getboolean("main", "autohide", fallback=False)) - self.voice_overlay.set_mouseover_timer( - config.getint("main", "autohide_timer", fallback=1)) - - self.voice_overlay.set_horizontal(config.getboolean( - "main", "horizontal", fallback=False)) - self.voice_overlay.set_overflow_style( - config.getint("main", "overflow", fallback=0)) - self.voice_overlay.set_show_connection(config.getboolean( - "main", "show_connection", fallback=False)) - self.voice_overlay.set_show_title(config.getboolean( - "main", "show_title", fallback=False)) - self.voice_overlay.set_show_disconnected(config.getboolean( - "main", "show_disconnected", fallback=False)) - self.voice_overlay.set_drawn_border_width( - config.getint("main", "border_width", fallback=2)) - self.voice_overlay.set_icon_transparency(config.getfloat( - "main", "icon_transparency", fallback=1.0)) - self.voice_overlay.set_show_avatar( - config.getboolean("main", "show_avatar", fallback=True)) - self.voice_overlay.set_fancy_border(config.getboolean("main", - "fancy_border", fallback=True)) - self.voice_overlay.set_show_dummy(config.getboolean("main", - "show_dummy", fallback=False)) - self.voice_overlay.set_dummy_count(config.getint("main", - "dummy_count", fallback=10)) - - self.voice_overlay.set_monitor( - config.get("main", "monitor", fallback="Any") - ) - - self.voice_overlay.set_enabled(True) - - self.voice_overlay.set_floating( - floating, floating_x, floating_y, floating_w, floating_h) - - if font: - self.voice_overlay.set_font(font) - if title_font: - self.voice_overlay.set_title_font(title_font) + hidden = config.getboolean("general", "hideoverlay", fallback=False) - self.voice_overlay.set_fade_out_inactive( - config.getboolean("main", "fade_out_inactive", fallback=False), - config.getint("main", "inactive_time", fallback=10), - config.getint("main", "inactive_fade_time", fallback=30), - config.getfloat("main", "fade_out_limit", fallback=0.3) - ) + if not config.has_section("main"): + config["main"] = {} + voice_section = config["main"] + if self.voice_overlay_window: + self.voice_overlay_window.set_config(voice_section) + self.voice_overlay_window.set_hidden(hidden) + self.voice_overlay.set_config(voice_section) # Set Text overlay options - self.text_overlay.set_enabled(config.getboolean( - "text", "enabled", fallback=False)) - self.text_overlay.set_align_x(config.getboolean( - "text", "rightalign", fallback=True)) - self.text_overlay.set_align_y( - config.getint("text", "topalign", fallback=2)) - floating = config.getboolean("text", "floating", fallback=True) - floating_x = config.getfloat("text", "floating_x", fallback=0.0) - floating_y = config.getfloat("text", "floating_y", fallback=0.0) - floating_w = config.getfloat("text", "floating_w", fallback=0.1) - floating_h = config.getfloat("text", "floating_h", fallback=0.1) - - channel = config.get("text", "channel", fallback="0") - guild = config.get("text", "guild", fallback="0") - self.connection.set_text_channel(channel, guild) - - self.font = config.get("text", "font", fallback=None) - self.text_overlay.set_bg(json.loads(config.get( - "text", "bg_col", fallback="[0.0,0.0,0.0,0.5]"))) - self.text_overlay.set_fg(json.loads(config.get( - "text", "fg_col", fallback="[1.0,1.0,1.0,1.0]"))) - self.text_overlay.set_popup_style(config.getboolean( - "text", "popup_style", fallback=False)) - self.text_overlay.set_text_time( - config.getint("text", "text_time", fallback=30)) - self.text_overlay.set_show_attach(config.getboolean( - "text", "show_attach", fallback=True)) - self.text_overlay.set_line_limit( - config.getint("text", "line_limit", fallback=20)) - self.text_overlay.set_hide_on_mouseover( - config.getboolean("text", "autohide", fallback=False)) - self.text_overlay.set_mouseover_timer( - config.getint("text", "autohide_timer", fallback=1)) - - self.text_overlay.set_monitor( - config.get("text", "monitor", fallback="Any") - ) - self.text_overlay.set_floating( - floating, floating_x, floating_y, floating_w, floating_h) - - if self.font: - self.text_overlay.set_font(self.font) + if not config.has_section("text"): + config["text"] = {} + text_section = config["text"] + if self.text_overlay_window: + self.text_overlay_window.set_config(text_section) + self.text_overlay_window.set_hidden(hidden) + self.text_overlay.set_config(text_section) # Set Notification overlay options - self.notification_overlay.set_enabled(config.getboolean( - "notification", "enabled", fallback=False)) - self.notification_overlay.set_align_x(config.getboolean( - "notification", "rightalign", fallback=True)) - self.notification_overlay.set_align_y( - config.getint("notification", "topalign", fallback=2)) - floating = config.getboolean( - "notification", "floating", fallback=False) - floating_x = config.getfloat( - "notification", "floating_x", fallback=0.0) - floating_y = config.getfloat( - "notification", "floating_y", fallback=0.0) - floating_w = config.getfloat( - "notification", "floating_w", fallback=0.1) - floating_h = config.getfloat( - "notification", "floating_h", fallback=0.1) - font = config.get("notification", "font", fallback=None) - self.notification_overlay.set_bg(json.loads(config.get( - "notification", "bg_col", fallback="[0.0,0.0,0.0,0.5]"))) - self.notification_overlay.set_fg(json.loads(config.get( - "notification", "fg_col", fallback="[1.0,1.0,1.0,1.0]"))) - self.notification_overlay.set_text_time(config.getint( - "notification", "text_time", fallback=10)) - self.notification_overlay.set_show_icon(config.getboolean( - "notification", "show_icon", fallback=True)) - self.notification_overlay.set_reverse_order(config.getboolean( - "notification", "rev", fallback=False)) - self.notification_overlay.set_limit_width(config.getint( - "notification", "limit_width", fallback=400)) - self.notification_overlay.set_icon_left(config.getboolean( - "notification", "icon_left", fallback=True)) - self.notification_overlay.set_icon_pad(config.getint( - "notification", "icon_padding", fallback=8)) - self.notification_overlay.set_icon_size(config.getint( - "notification", "icon_size", fallback=32)) - self.notification_overlay.set_padding(config.getint( - "notification", "padding", fallback=8)) - self.notification_overlay.set_border_radius(config.getint( - "notification", "border_radius", fallback=8)) - self.notification_overlay.set_testing(config.getboolean( - "notification", "show_dummy", fallback=False)) - self.font = config.get("notification", "font", fallback=None) - - if self.font: - self.notification_overlay.set_font(self.font) - - self.notification_overlay.set_monitor( - config.get("notification", "monitor", fallback="Any") + if not config.has_section("notification"): + config["notification"] = {} + notification_section = config["notification"] + if self.notification_overlay_window: + self.notification_overlay_window.set_config(notification_section) + self.notification_overlay_window.set_hidden(hidden) + self.notification_overlay.set_config(notification_section) + + if self.one_window: + self.one_window.set_hidden(hidden) + + self.audio_assist.set_enabled( + config.getboolean("general", "audio_assist", fallback=False) ) - self.notification_overlay.set_floating( - floating, floating_x, floating_y, floating_w, floating_h) - if self.font: - self.notification_overlay.set_font(self.font) - - # Set Core settings - self.set_force_xshape( - config.getboolean("general", "xshape", fallback=False)) - - hidden = config.getboolean("general", "hideoverlay", fallback=False) - self.voice_overlay.set_hidden(hidden) - self.text_overlay.set_hidden(hidden) - self.notification_overlay.set_hidden(hidden) - - self.audio_assist.set_enabled(config.getboolean( - "general", "audio_assist", fallback=False)) def parse_guild_ids(self, guild_ids_str): """Parse the guild_ids from a str and return them in a list""" @@ -401,55 +250,48 @@ def create_gui(self): """ Create Systray & associated menu, overlays & settings windows """ - self.voice_overlay = VoiceOverlayWindow(self) + self.one_window = self.voice_overlay_window = self.text_overlay_window = ( + self.notification_overlay_window + ) = None if self.steamos: - self.text_overlay = TextOverlayWindow(self, self.voice_overlay) - self.notification_overlay = NotificationOverlayWindow( - self, self.text_overlay) + self.one_window = OverlayWindow(self) + self.voice_overlay = VoiceOverlayWindow(self) + self.text_overlay = TextOverlayWindow(self) + self.notification_overlay = NotificationOverlayWindow(self) + self.one_window.merged_overlay( + [self.voice_overlay, self.text_overlay, self.notification_overlay] + ) else: + self.voice_overlay_window = OverlayWindow(self) + self.voice_overlay = VoiceOverlayWindow(self) + self.voice_overlay_window.overlay(self.voice_overlay) + + self.text_overlay_window = OverlayWindow(self) self.text_overlay = TextOverlayWindow(self) + self.text_overlay_window.overlay(self.text_overlay) + + self.notification_overlay_window = OverlayWindow(self) self.notification_overlay = NotificationOverlayWindow(self) + self.notification_overlay_window.overlay(self.notification_overlay) if self.mix_settings: - MainSettingsWindow( - self.config_file, self.rpc_file, self.channel_file, []) - - def toggle_show(self, _obj=None): - """Toggle all overlays off or on""" - if self.voice_overlay: - hide = not self.voice_overlay.hidden - self.voice_overlay.set_hidden(hide) - if self.text_overlay: - self.text_overlay.set_hidden(hide) - if self.notification_overlay: - self.notification_overlay.set_hidden(hide) + app = Settings( + "io.github.trigg.discover_overlay", + Gio.ApplicationFlags.FLAGS_NONE, + self.config_file, + self.rpc_file, + self.channel_file, + sys.argv[1:], + ) + app.connect("activate", app.start) + app.run() def close(self, _a=None, _b=None, _c=None): """ End of the program """ - Gtk.main_quit() - - def set_force_xshape(self, force): - """ - Set if XShape should be forced - """ - self.voice_overlay.set_force_xshape(force) - if self.text_overlay: - self.text_overlay.set_force_xshape(force) - if self.notification_overlay: - self.notification_overlay.set_force_xshape(force) - - def set_show_task(self, visible): - """Set if the overlay should allow itself to appear on taskbar. - Not working at last check""" - if self.voice_overlay: - self.voice_overlay.set_task(visible) - if self.text_overlay: - self.text_overlay.set_task(visible) - if self.notification_overlay: - self.notification_overlay.set_task(visible) + sys.exit() def set_mute_async(self, mute): """Set mute status from another thread""" @@ -547,8 +389,18 @@ def entrypoint(): debug_file = os.path.join(config_dir, "output.txt") if "-c" in sys.argv or "--configure" in sys.argv: - _settings = MainSettingsWindow(config_file, rpc_file, channel_file, sys.argv[1:]) - Gtk.main() + # Show config window + # pylint: disable=E1101 + app = Settings( + "io.github.trigg.discover_overlay", + Gio.ApplicationFlags.FLAGS_NONE, + config_file, + rpc_file, + channel_file, + sys.argv[1:], + ) + app.connect("activate", app.start) + app.run() return if is_a_controller(sys.argv): @@ -565,15 +417,17 @@ def entrypoint(): logging.basicConfig(filename=debug_file, format=log_format) else: logging.basicConfig(format=log_format) - log.info("Starting Discover Overlay: %s", - pkg_resources.get_distribution('discover_overlay').version) + log.info( + "Starting Discover Overlay: %s", + __version__, + ) # Hedge against the bet gamescope ships with some WAYLAND_DISPLAY # Compatibility and we're not ready yet - if 'GAMESCOPE_WAYLAND_DISPLAY' in os.environ: + if "GAMESCOPE_WAYLAND_DISPLAY" in os.environ: + os.environ["GDK_BACKEND"] = "x11" os.unsetenv("WAYLAND_DISPLAY") - # Catch any errors and log them try: with open(rpc_file, "w", encoding="utf-8") as tfile: diff --git a/discover_overlay/draggable_window.py b/discover_overlay/draggable_window.py deleted file mode 100644 index 3dfd964..0000000 --- a/discover_overlay/draggable_window.py +++ /dev/null @@ -1,178 +0,0 @@ -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -"""An X11 window which can be moved and resized""" -import logging -import gi -import cairo -gi.require_version("Gtk", "3.0") -# pylint: disable=wrong-import-position -from gi.repository import Gtk, Gdk # nopep8 - -log = logging.getLogger(__name__) - - -class DraggableWindow(Gtk.Window): - """An X11 window which can be moved and resized""" - - def __init__(self, pos_x=0.0, pos_y=0.0, width=0.1, height=0.1, - message="Message", settings=None, monitor=None): - Gtk.Window.__init__(self, type=Gtk.WindowType.POPUP) - self.monitor = monitor - (_screen_x, _screen_y, screen_width, - screen_height) = self.get_display_coords() - self.pos_x = pos_x * screen_width - self.pos_y = pos_y * screen_height - self.width = max(40, width * screen_width) - self.height = max(40, height * screen_height) - self.settings = settings - self.message = message - self.set_size_request(50, 50) - - self.connect('draw', self.dodraw) - self.connect('motion-notify-event', self.drag) - self.connect('button-press-event', self.button_press) - self.connect('button-release-event', self.button_release) - - self.compositing = False - # Set RGBA - screen = self.get_screen() - visual = screen.get_rgba_visual() - if visual: - # Set the visual even if we can't use it right now - self.set_visual(visual) - if screen.is_composited(): - self.compositing = True - - self.set_app_paintable(True) - - self.drag_type = None - self.drag_x = 0 - self.drag_y = 0 - self.force_location() - self.show_all() - - def force_location(self): - """ - Move the window to previously given co-ords. - Also double check sanity on layer & decorations - """ - self.set_decorated(False) - self.set_keep_above(True) - - (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords() - - self.width = min(self.width, screen_width) - self.height = min(self.height, screen_height) - self.pos_x = max(0, self.pos_x) - self.pos_x = min(screen_width - self.width, self.pos_x) - self.pos_y = max(0, self.pos_y) - self.pos_y = min(screen_height - self.height, self.pos_y) - - self.move(self.pos_x + screen_x, self.pos_y + screen_y) - self.resize(self.width, self.height) - - def drag(self, _w, event): - """Called by GTK while mouse is moving over window. Used to resize and move""" - if event.state & Gdk.ModifierType.BUTTON1_MASK: - if self.drag_type == 1: - # Center is move - (screen_x, screen_y, _screen_width, - _screen_height) = self.get_display_coords() - self.pos_x = (event.x_root - screen_x) - self.drag_x - self.pos_y = (event.y_root - screen_y) - self.drag_y - self.force_location() - elif self.drag_type == 2: - # Right edge - self.width += event.x - self.drag_x - self.drag_x = event.x - self.force_location() - elif self.drag_type == 3: - # Bottom edge - self.height += event.y - self.drag_y - self.drag_y = event.y - self.force_location() - elif self.drag_type == 4: - # Bottom Right - self.width += event.x - self.drag_x - self.height += event.y - self.drag_y - self.drag_x = event.x - self.drag_y = event.y - self.force_location() - - def button_press(self, _widget, event): - """Called when a mouse button is pressed on this window""" - (width, height) = self.get_size() - if not self.drag_type: - self.drag_type = 1 - # Where in the window did we press? - if event.y > height - 32: - self.drag_type += 2 - if event.x > width - 32: - self.drag_type += 1 - self.drag_x = event.x - self.drag_y = event.y - - def button_release(self, _w, _event): - """Called when a mouse button is released""" - self.drag_type = None - - def dodraw(self, _widget, context): - """Draw our window.""" - context.set_source_rgba(1.0, 1.0, 0.0, 0.7) - # Don't layer drawing over each other, always replace - context.set_operator(cairo.OPERATOR_SOURCE) - context.paint() - context.set_operator(cairo.OPERATOR_OVER) - # Get size of window - (window_width, window_height) = self.get_size() - - # Draw text - context.set_source_rgba(0.0, 0.0, 0.0, 1.0) - _xb, _yb, text_width, text_height, _dx, _dy = context.text_extents( - self.message) - context.move_to(window_width / 2 - text_width / 2, - window_height / 2 - text_height / 2) - context.show_text(self.message) - - # Draw resizing edges - context.set_source_rgba(0.0, 0.0, 1.0, 0.5) - context.rectangle(window_width - 32, 0, 32, window_height) - context.fill() - - context.rectangle(0, window_height - 32, window_width, 32) - context.fill() - - def get_display_coords(self): - """Get coordinates for this display""" - display = Gdk.Display.get_default() - if "get_monitor" in dir(display): - monitor = display.get_monitor(self.monitor) - if monitor: - geometry = monitor.get_geometry() - return (geometry.x, geometry.y, geometry.width, geometry.height) - return (0, 0, 1920, 1080) # We're in trouble - - def get_coords(self): - """Return window position and size""" - (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords() - scale = self.get_scale_factor() - (pos_x, pos_y) = self.get_position() - pos_x = float(max(0, pos_x - screen_x)) - pos_y = float(max(0, pos_y - screen_y)) - (width, height) = self.get_size() - width = float(width) - height = float(height) - pos_x = pos_x / scale - pos_y = pos_y / scale - return (pos_x / screen_width, pos_y / screen_height, - width / screen_width, height / screen_height) diff --git a/discover_overlay/draggable_window_wayland.py b/discover_overlay/draggable_window_wayland.py deleted file mode 100644 index 530d2df..0000000 --- a/discover_overlay/draggable_window_wayland.py +++ /dev/null @@ -1,211 +0,0 @@ -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -"""A Wayland full-screen window which can be moved and resized""" -import logging -import cairo -import gi -gi.require_version("Gtk", "3.0") -# pylint: disable=wrong-import-position -from gi.repository import Gtk, Gdk # nopep8 -try: - gi.require_version('GtkLayerShell', '0.1') - from gi.repository import GtkLayerShell -except (ImportError, ValueError): - GtkLayerShell = None - -log = logging.getLogger(__name__) - - -class DraggableWindowWayland(Gtk.Window): - """A Wayland full-screen window which can be moved and resized""" - - def __init__(self, pos_x=0.0, pos_y=0.0, width=0.1, height=0.1, - message="Message", settings=None, steamos=False, monitor=None): - Gtk.Window.__init__(self, type=Gtk.WindowType.TOPLEVEL) - if steamos: - monitor = 0 - self.monitor = monitor - (_screen_x, _screen_y, screen_width, - screen_height) = self.get_display_coords() - self.pos_x = pos_x * screen_width - self.pos_y = pos_y * screen_height - self.width = max(40, width * screen_width) - self.height = max(40, height * screen_height) - self.settings = settings - self.message = message - self.set_size_request(50, 50) - - self.connect('draw', self.dodraw) - self.connect('motion-notify-event', self.drag) - self.connect('button-press-event', self.button_press) - self.connect('button-release-event', self.button_release) - - log.info("Starting: %d,%d %d x %d", - self.pos_x, self.pos_y, self.width, self.height) - - self.set_app_paintable(True) - - self.drag_type = None - self.drag_x = 0 - self.drag_y = 0 - if GtkLayerShell and not steamos: - GtkLayerShell.init_for_window(self) - display = Gdk.Display.get_default() - if "get_monitor" in dir(display): - monitor = display.get_monitor(self.monitor) - if monitor: - GtkLayerShell.set_monitor(self, monitor) - GtkLayerShell.set_layer(self, GtkLayerShell.Layer.TOP) - GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.LEFT, True) - GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.RIGHT, True) - GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.BOTTOM, True) - GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.TOP, True) - if steamos: - self.steamos = steamos - self.set_steamos_window_size() - - self.show_all() - self.force_location() - - def set_steamos_window_size(self): - """Prepare window for a gamescope steamos session""" - # Huge bunch of assumptions. - # Gamescope only has one monitor - # Gamescope has no scale factor - # Probably never possible to reach here, as Gamescope/SteamOS - # is X11 for overlays - display = Gdk.Display.get_default() - if "get_monitor" in dir(display): - monitor = display.get_monitor(0) - if monitor: - geometry = monitor.get_geometry() - log.info("%d %d", geometry.width, geometry.height) - self.set_size_request(geometry.width, geometry.height) - - def force_location(self): - """Move the window to previously given co-ords. In wayland just clip to current screen""" - (_screen_x, _screen_y, screen_width, - screen_height) = self.get_display_coords() - self.width = min(self.width, screen_width) - self.height = min(self.height, screen_height) - self.pos_x = max(0, self.pos_x) - self.pos_x = min(screen_width - self.width, self.pos_x) - self.pos_y = max(0, self.pos_y) - self.pos_y = min(screen_height - self.height, self.pos_y) - - self.queue_draw() - - def drag(self, _w, event): - """Called by GTK while mouse is moving over window. Used to resize and move""" - if event.state & Gdk.ModifierType.BUTTON1_MASK: - if self.drag_type == 1: - # Center is move - self.pos_x += event.x - self.drag_x - self.pos_y += event.y - self.drag_y - self.drag_x = event.x - self.drag_y = event.y - - self.force_location() - elif self.drag_type == 2: - # Right edge - self.width += event.x - self.drag_x - self.drag_x = event.x - self.force_location() - elif self.drag_type == 3: - # Bottom edge - self.height += event.y - self.drag_y - self.drag_y = event.y - self.force_location() - elif self.drag_type == 4: - # Bottom Right - self.width += event.x - self.drag_x - self.height += event.y - self.drag_y - self.drag_x = event.x - self.drag_y = event.y - self.force_location() - - def button_press(self, _w, event): - """Called when a mouse button is pressed on this window""" - press_x = event.x - self.pos_x - press_y = event.y - self.pos_y - - if not self.drag_type: - self.drag_type = 1 - # Where in the window did we press? - if press_x < 20 and press_y < 20: - self.settings.change_placement(self) - if press_y > self.height - 32: - self.drag_type += 2 - if press_x > self.width - 32: - self.drag_type += 1 - self.drag_x = event.x - self.drag_y = event.y - - def button_release(self, _w, _event): - """Called when a mouse button is released""" - self.drag_type = None - - def dodraw(self, _widget, context): - """ - Draw our window. For wayland we're secretly a - fullscreen app and need to draw only a single - rectangle of the overlay - """ - context.translate(self.pos_x, self.pos_y) - context.save() - context.rectangle(0, 0, self.width, self.height) - context.clip() - - context.set_source_rgba(1.0, 1.0, 0.0, 0.7) - # Don't layer drawing over each other, always replace - context.set_operator(cairo.OPERATOR_SOURCE) - context.paint() - # Get size of window - - # Draw text - context.set_source_rgba(0.0, 0.0, 0.0, 1.0) - _xb, _yb, width, height, _dx, _dy = context.text_extents(self.message) - context.move_to(self.width / 2 - width / 2, - self.height / 2 - height / 2) - context.show_text(self.message) - - # Draw resizing edges - context.set_source_rgba(0.0, 0.0, 1.0, 0.5) - context.rectangle(self.width - 32, 0, 32, self.height) - context.fill() - - context.rectangle(0, self.height - 32, self.width, 32) - context.fill() - - # Draw Done! - context.set_source_rgba(0.0, 1.0, 0.0, 0.5) - context.rectangle(0, 0, 20, 20) - context.fill() - context.restore() - - def get_display_coords(self): - """Get coordinates from display""" - display = Gdk.Display.get_default() - if "get_monitor" in dir(display): - monitor = display.get_monitor(self.monitor) - if monitor: - geometry = monitor.get_geometry() - return (geometry.x, geometry.y, geometry.width, geometry.height) - return (0, 0, 1920, 1080) # We're in trouble - - def get_coords(self): - """Return the position and size of the window""" - (_screen_x, _screen_y, screen_width, - screen_height) = self.get_display_coords() - return (float(self.pos_x) / screen_width, float(self.pos_y) / screen_height, - float(self.width) / screen_width, float(self.height) / screen_height) diff --git a/discover_overlay/glade/settings.glade b/discover_overlay/glade/settings.glade deleted file mode 100644 index c6d671c..0000000 --- a/discover_overlay/glade/settings.glade +++ /dev/null @@ -1,2847 +0,0 @@ - - - - - - 0.10 - 1 - 0.5 - 0.01 - 0.10 - - - 8 - 128 - 48 - 1 - 8 - - - 1 - 100 - 1 - 10 - - - 64 - 1 - 1 - - - 128 - 1 - 8 - - - 100 - 8 - 1 - 10 - - - 8 - 128 - 48 - 1 - 8 - - - 100 - 4000 - 400 - 1 - 10 - - - 128 - 1 - 8 - - - 1 - 4000 - 10 - 1 - 10 - - - - - - - - - Alphabetically - - - ID - - - Last Spoken - - - - - - - - - - - Shrink - - - Wrap - - - None - - - - - 1 - 100 - 10 - 1 - 10 - - - 64 - 1 - 1 - - - 30 - 5000 - 30 - 1 - 10 - - - 1 - 100 - 1 - 1 - 10 - - - - - - - - - Left - - - Right - - - - - 360 - 1 - 10 - - - 10 - 100 - 50 - 1 - 10 - - - 4000 - 1 - 8 - - - 1 - 0.3 - 0.01 - 0.10 - - - 1 - 100 - 1 - 1 - 10 - - - 1 - 100 - 1 - 10 - - - 10 - 32 - 32 - 1 - 10 - - - 1 - 100 - 1 - 1 - 10 - - - -100 - 100 - 1 - 8 - - - 4000 - 1 - 8 - - - window - False - Discover Overlay Configuration - discover-overlay - - - notebook - True - True - - - - overview_grid - True - False - - - overview_image - True - False - 10 - 0 - discover-overlay - 6 - - - 1 - 0 - - - - - overview_main_text - True - False - 50 - INTRO TEXT - True - - - 0 - 1 - 3 - - - - - Close overlay - overview_close_button - True - True - True - - - - 0 - 3 - 3 - - - - - - - - - - - - - - - - - - - - - - notebook_overview_label - True - False - About - - - False - - - - - - voice_grid - True - False - baseline - baseline - 5 - 5 - 5 - 5 - 3 - 3 - - - voice_display_horizontally_label - True - False - 28 - 28 - Display Horizontally - 0 - - - 0 - 5 - - - - - voice_display_horizontally - True - True - False - True - - - - 1 - 5 - - - - - voice_talking_label - True - False - Talking - - - 2 - 1 - - - - - voice_idle_label - True - False - Idle - - - 2 - 2 - - - - - voice_mute_label - True - False - Mute - - - 2 - 3 - - - - - voice_avatar_label - True - False - Avatar - - - 2 - 4 - - - - - voice_foreground_label - 100 - True - False - 4 - 4 - 4 - 4 - Foreground - - - 3 - 0 - - - - - voice_background_label - 100 - True - False - 4 - 4 - 4 - 4 - Background - - - 4 - 0 - - - - - voice_border_label - 100 - True - False - 4 - 4 - 4 - 4 - Border - - - 5 - 0 - - - - - voice_talking_foreground - True - True - True - True - - - - 3 - 1 - - - - - voice_talking_background - True - True - True - True - - - - 4 - 1 - - - - - voice_talking_border - True - True - True - True - - - - 5 - 1 - - - - - voice_idle_foreground - True - True - True - True - - - - 3 - 2 - - - - - voice_idle_background - True - True - True - True - - - - 4 - 2 - - - - - voice_idle_border - True - True - True - True - - - - 5 - 2 - - - - - voice_mute_foreground - True - True - True - True - - - - 3 - 3 - - - - - voice_mute_background - True - True - True - True - - - - 4 - 3 - - - - - voice_avatar_background - True - True - True - True - - - - 4 - 4 - - - - - voice_hide_mouseover_label - True - False - Hide Overlay on Mouseover - 0 - - - 6 - 1 - - - - - voice_hide_mouseover - True - True - False - True - - - - 7 - 1 - - - - - voice_show_mouseover_label - True - False - Show again after (seconds) - 0 - - - 6 - 2 - - - - - voice_show_mouseover - True - True - 1 - voice_show_mouseover_adj - 1 - - - - 7 - 2 - - - - - voice_overlay_location_label - True - False - Overlay Location - - - 0 - 0 - 2 - - - - - voice_anchor_float - True - False - 0 - - Floating - Anchor to Edge - - - - - 0 - 1 - 2 - - - - - voice_monitor - True - False - - - - 0 - 2 - 2 - - - - - voice_align_1 - True - False - 0 - - Left - Right - - - - - 0 - 3 - 2 - - - - - voice_align_2 - True - False - 0 - - Top - Middle - Bottom - - - - - 0 - 4 - 2 - - - - - Place Window - voice_place_window_button - True - True - True - - - - 0 - 3 - 2 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - False - - - - - notebook_voice_label - True - False - Voice - - - 1 - False - - - - - - voice_advanced_grid - True - False - 1 - 5 - - - voice_display_icon_only_label - True - False - Show Names - 0 - - - 2 - 0 - - - - - voice_display_icon_only - True - True - False - True - - - - 3 - 0 - - - - - voice_font_label - True - False - Font - 0 - - - 2 - 1 - - - - - voice_font - True - True - True - Sans 12 - en-us - - - - - 3 - 1 - - - - - voice_text_padding_label - True - False - Text Padding - 0 - - - 2 - 2 - - - - - voice_text_padding - True - True - 0 - text_padding_adj - - - - 3 - 2 - - - - - voice_text_vertical_offset_label - True - False - Text Vertical Offset - 0 - - - 2 - 3 - - - - - voice_text_vertical_offset - True - True - 0 - voice_text_offset - - - - 3 - 3 - - - - - voice_show_title_label - True - False - Show Title - 0 - - - 0 - 0 - - - - - voice_show_title - True - True - False - True - - - - 1 - 0 - - - - - voice_show_connection_status_label - True - False - Show Connection Status - 0 - - - 0 - 2 - - - - - voice_show_connection_status - True - True - False - True - - - - 1 - 2 - - - - - voice_title_font - True - True - True - Sans 12 - en-us - - - - - 1 - 1 - - - - - voice_title_font_label - True - False - Title Font - 0 - - - 0 - 1 - - - - - voice_vertical_padding - True - True - 0 - voice_vertical_padding_adj - - - - 1 - 4 - - - - - voice_horizontal_padding_label - True - False - Horizontal Edge Padding - 0 - - - 0 - 5 - - - - - voice_horizontal_padding - True - True - 0 - voice_horizontal_padding_adj - - - - 1 - 5 - - - - - voice_show_test_content_label - True - False - Show Test Content - 0 - - - 0 - 7 - - - - - voice_show_test_content - True - True - False - True - - - - 1 - 7 - - - - - voice_dummy_count_label - True - False - Test Data Count - 0 - - - 0 - 8 - - - - - voice_dummy_count - True - True - 50 - voice_dummy_count_adj - 50 - - - - 1 - 8 - - - - - voice_show_disconnected_label - True - False - Show While Disconnected - 0 - - - 0 - 9 - - - - - voice_show_disconnected - True - True - False - True - - - - 1 - 9 - - - - - voce_vertical_padding_label - True - False - Verticle Edge Padding - 0 - - - 0 - 4 - - - - - voice_nick_length_label - True - False - Limit text length - 0 - - - 2 - 4 - - - - - voice_nick_length - True - True - 32 - voice_nick_lenght_adj - 32 - - - - 3 - 4 - - - - - voice_avatar_label - True - False - Show Avatar - 0 - - - 2 - 5 - - - - - voice_show_avatar - True - True - False - True - - - - 3 - 5 - - - - - voice_square_avatar_label - True - False - Square Avatar - 0 - - - 2 - 6 - - - - - voice_square_avatar - True - True - False - True - - - - 3 - 6 - - - - - voice_fancy_avatar_shapes_label - True - False - Fancy Avatar Shapes - 0 - - - 2 - 7 - - - - - voice_fancy_avatar_shapes - True - True - False - True - - - - 3 - 7 - - - - - voice_avatar_size_label - True - False - Avatar Size - 0 - - - 2 - 8 - - - - - voice_avatar_size - True - True - 48 - avatar_size_adj - 48 - - - - 3 - 8 - - - - - voice_avatar_opacity_label - True - False - Avatar Opacity - 0 - - - 2 - 9 - - - - - voice_avatar_opacity - True - True - ava_opacity_adj - 1 - - - - 3 - 9 - - - - - voice_display_speakers_only_label - True - False - Display Speakers Only - 0 - - - 4 - 0 - - - - - voice_display_speakers_only - True - True - False - True - - - - 5 - 0 - - - - - voice_display_speakers_grace_period_label - True - False - Speakers Grace Period - 0 - - - 4 - 1 - - - - - voice_display_speakers_grace_period - True - True - 0 - voice_display_speakers_grace_period_adj - - - - 5 - 1 - - - - - voice_overflow_style_label - True - False - Overflow Style - 0 - - - 4 - 2 - - - - - voice_overflow_style - True - False - - None - Wrap - Shrink - - - - - 5 - 2 - - - - - voice_order_avatars_by_label - True - False - Order Users By - 0 - - - 4 - 3 - - - - - voice_order_avatars_by - True - False - - Alphabetically - ID - Last Spoken - - - - - 5 - 3 - - - - - voice_highlight_self_label - True - False - Highlight Self - 0 - - - 4 - 4 - - - - - voice_highlight_self - True - True - False - True - - - - 5 - 4 - - - - - voice_border_width_label - True - False - Border width - 0 - - - 4 - 5 - - - - - voice_border_width - True - True - 1 - border_width_adj - 1 - - - - 5 - 5 - - - - - voice_icon_spacing_label - True - False - Padding between users - 0 - - - 4 - 6 - - - - - voice_icon_spacing - True - True - 0 - icon_spacing_adj - - - - 5 - 6 - - - - - Reset Voice Settings - voice_reset_all - True - True - True - - - - 5 - 12 - - - - - voice_inactive_fade_label - True - False - Fade out when Inactive - 0 - - - 4 - 7 - - - - - voice_inactive_opacity_label - True - False - Inactive Opacity - 0 - - - 4 - 8 - - - - - voice_inactive_time_label - True - False - Time before Inactive - 0 - - - 4 - 9 - - - - - voice_inactive_fade_time_label - True - False - Time Fading out - 0 - - - 4 - 10 - - - - - voice_inactive_fade - True - True - False - True - - - - 5 - 7 - - - - - voice_inactive_opacity - True - True - voice_inactive_fade_opacity - 1 - - - - 5 - 8 - - - - - voice_inactive_time - True - True - 0 - voice_inactive_time_range - - - - 5 - 9 - - - - - voice_inactive_fade_time - True - True - 0 - voice_inactive_fade_time_range - - - - 5 - 10 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 2 - False - - - - - notebook_voice_adv_label - True - False - Voice - Advanced - - - 2 - False - - - - - - text_grid - True - False - 5 - 5 - 5 - 5 - 1 - 1 - - - text_enable_label - True - False - Enable - 0 - - - 0 - 0 - - - - - text_enable - True - True - False - True - - - - 1 - 0 - - - - - text_popup_style_label - True - False - Popup Style - 0 - - - 0 - 1 - - - - - text_popup_style - True - True - False - True - - - - 1 - 1 - - - - - text_line_limit_label - True - False - Line Limit - 0 - - - 0 - 12 - - - - - text_line_limit - True - True - 10 - text_line_limit_adj - 10 - - - - 1 - 12 - - - - - text_show_attachments_label - True - False - Show Attachments - 0 - - - 0 - 11 - - - - - text_show_attachments - True - True - False - True - - - - 1 - 11 - - - - - text_overlay_location_label - True - False - Overlay Location - 0 - - - 0 - 10 - - - - - Place Window - text_place_window_button - True - True - True - - - - 1 - 10 - - - - - text_monitor - True - False - - - - 1 - 9 - - - - - text_background_colour_label - True - False - Background Colour - 0 - - - 0 - 8 - - - - - text_background_colour - True - True - True - True - - - - 1 - 8 - - - - - text_colour_label - True - False - Text Colour - 0 - - - 0 - 7 - - - - - text_colour - True - True - True - True - - - - 1 - 7 - - - - - text_font - True - True - True - Sans 12 - en-us - - - - - 1 - 6 - - - - - text_font_label - True - False - Font - 0 - - - 0 - 6 - - - - - text_channel_label - True - False - Channel - 0 - - - 0 - 5 - - - - - text_channel - True - False - - - 1 - 5 - - - - - Refresh List - text_refresh_server_button - True - True - True - - - - 1 - 4 - - - - - text_server_label - True - False - Server - 0 - - - 0 - 3 - - - - - text_server - True - False - - - 1 - 3 - - - - - text_popup_time_label - True - False - Popup time limit - 0 - - - 0 - 2 - - - - - text_popup_time - True - True - text_popup_time_adj - - - - 1 - 2 - - - - - text_hide_mouseover_label - True - False - Hide Overlay on Mouseover - 0 - - - 0 - 13 - - - - - text_show_mouseover_label - True - False - Show again after (seconds) - 0 - - - 0 - 14 - - - - - Reset Text Settings - text_reset_all - True - True - True - - - - 1 - 15 - - - - - text_hide_mouseover - True - True - False - True - - - - 1 - 13 - - - - - text_show_mouseover - True - True - 1 - text_show_mouseover_adj - 1 - - - - 1 - 14 - - - - - - - - - - - - - - 3 - False - - - - - notebook_text_label - True - False - Text - - - 3 - False - - - - - - notification_grid - True - False - 5 - 5 - 5 - 5 - 1 - 1 - - - notification_enable_label - True - False - Enable - 0 - - - 0 - 0 - - - - - notification_reverse_order_label - True - False - Reverse Order - 0 - - - 0 - 1 - - - - - notification_popup_timer_label - True - False - Popup Timer - 0 - - - 0 - 2 - - - - - notification_limit_popup_width_label - True - False - Limit Popup Width - 0 - - - 0 - 3 - - - - - notification_font_label - True - False - Font - 0 - - - 0 - 4 - - - - - notification_text_colour_label - True - False - Text Colour - 0 - - - 0 - 5 - - - - - notification_background_colour_label - True - False - Background Colour - 0 - - - 0 - 6 - - - - - notification_overlay_location_label - True - False - Overlay Location - 0 - - - 0 - 7 - 3 - - - - - notification_show_icon_label - True - False - Show Icon - 0 - - - 0 - 10 - - - - - notification_icon_position_label - True - False - Icon Position - 0 - - - 0 - 11 - - - - - notification_enable - True - True - False - True - - - - 1 - 0 - - - - - notification_reverse_order - True - True - False - True - - - - 1 - 1 - - - - - notification_popup_timer - True - True - notification_popup_timer_adj - - - - 1 - 2 - - - - - notification_limit_popup_width - True - True - notification_limit_width_adj - - - - 1 - 3 - - - - - notification_font - True - True - True - Sans 12 - en-us - - - - - 1 - 4 - - - - - notification_text_colour - True - True - True - True - - - - 1 - 5 - - - - - notification_background_colour - True - True - True - True - - - - 1 - 6 - - - - - notification_show_icon - True - True - False - True - - - - 1 - 10 - - - - - notification_show_test_content - True - True - False - True - - - - 1 - 16 - - - - - notification_show_test_content_label - True - False - Show Test Content - 0 - - - 0 - 16 - - - - - notification_border_radius_label - True - False - Border Radius - 0 - - - 0 - 15 - - - - - notification_padding_between_label - True - False - Padding Between Notifications - 0 - - - 0 - 14 - - - - - notification_icon_size_label - True - False - Icon Size - 0 - - - 0 - 13 - - - - - notification_icon_padding_label - True - False - Icon Padding - 0 - - - 0 - 12 - - - - - notification_icon_padding - True - True - notification_icon_padding_adj - - - - 1 - 12 - - - - - notification_icon_size - True - True - notification_icon_size_adj - - - - 1 - 13 - - - - - notification_padding_between - True - True - notification_padding_adj - - - - 1 - 14 - - - - - notification_border_radius - True - True - notification_border_radius_adj - - - - 1 - 15 - - - - - notification_monitor - True - False - 0 - - Unknown - - - - - 1 - 7 - - - - - notification_align_1 - True - False - 0 - - Left - Right - - - - - 1 - 8 - - - - - notification_align_2 - True - False - 0 - - Top - Middle - Bottom - - - - - 1 - 9 - - - - - notification_icon_position - True - False - - Left - Right - - - - - 1 - 11 - - - - - Reset Notification Settings - notification_reset_all - True - True - True - - - - 1 - 17 - - - - - - - - 4 - False - - - - - notebook_notification_label - True - False - Notification - - - 4 - False - - - - - - core_grid - True - False - 5 - 5 - 5 - 5 - 2 - 8 - - - core_run_on_startup_label - True - False - Run Overlay on Startup - 0 - - - 0 - 0 - - - - - core_run_on_startup - True - True - False - True - - - - 1 - 0 - - - - - core_run_conf_on_startup_label - True - False - Run Configuration on Startup - 0 - - - 0 - 1 - - - - - core_run_conf_on_startup - True - True - False - True - - - - 1 - 1 - - - - - core_force_xshape_label - True - False - Force XShape - 0 - - - 0 - 2 - - - - - core_force_xshape - True - True - False - True - - - - 1 - 2 - - - - - core_show_tray_icon_label - True - False - Show Tray Icon - 0 - - - 0 - 3 - - - - - core_show_tray_icon - True - True - False - True - - - - 1 - 3 - - - - - core_settings_min_label - True - False - Start Settings Minimized - 0 - - - 0 - 4 - - - - - core_settings_min - True - True - False - True - - - - 1 - 4 - - - - - core_hide_overlay - True - True - False - True - - - 1 - 5 - - - - - core_hide_overlay_label - True - False - Hide Overlays - 0 - - - 0 - 5 - - - - - Reset Core Settings - core_reset_all - True - True - True - - - - 1 - 7 - - - - - core_audio_assist_label - True - False - Integrate with Pipewire/Pulseaudio - 0 - - - 0 - 6 - - - - - core_audio_assist - True - True - False - True - - - - 1 - 6 - - - - - - - - 5 - False - - - - - notebook_core_label - True - False - Core - - - 5 - False - - - - - - diff --git a/discover_overlay/glade/settings.xml b/discover_overlay/glade/settings.xml new file mode 100644 index 0000000..94a2761 --- /dev/null +++ b/discover_overlay/glade/settings.xml @@ -0,0 +1,2220 @@ + + + + + 0.10 + 1 + 0.5 + 0.01 + 0.10 + + + 8 + 128 + 48 + 1 + 8 + + + 1 + 100 + 1 + 10 + + + 64 + 1 + 1 + + + 128 + 1 + 8 + + + 100 + 8 + 1 + 10 + + + 8 + 128 + 48 + 1 + 8 + + + 100 + 4000 + 400 + 1 + 10 + + + 128 + 1 + 8 + + + 1 + 4000 + 10 + 1 + 10 + + + + + + + + Alphabetically + + + ID + + + Last Spoken + + + + + + + + + + Shrink + + + Wrap + + + None + + + + + 1 + 100 + 10 + 1 + 10 + + + 64 + 1 + 1 + + + 30 + 5000 + 30 + 1 + 10 + + + 1 + 100 + 1 + 1 + 10 + + + + + + + + Left + + + Middle + + + Right + + + + + 360 + 1 + 10 + + + 10 + 100 + 50 + 1 + 10 + + + 4000 + 1 + 8 + + + 1 + 0.3 + 0.01 + 0.10 + + + 1 + 100 + 1 + 1 + 10 + + + 1 + 100 + 1 + 10 + + + 10 + 32 + 32 + 1 + 10 + + + 1 + 100 + 1 + 1 + 10 + + + -100 + 100 + 1 + 8 + + + 4000 + 1 + 8 + + + window + Discover Overlay Configuration + discover-overlay + + + notebook + 1 + + + + + overview_grid + + + overview_image + 10 + 0 + discover-overlay + + 1 + 0 + + + + + + overview_main_text + 50 + INTRO TEXT + 1 + + 0 + 1 + 3 + + + + + + Close overlay + overview_close_button + 1 + 1 + + + 0 + 3 + 3 + + + + + + + + + + + + + + + + + + + + + + + notebook_overview_label + About + + + + + + + 1 + False + + + voice_grid + baseline-fill + baseline-fill + 5 + 5 + 5 + 5 + 3 + 3 + + + voice_display_horizontally_label + 28 + 28 + Display Horizontally + 0 + + 0 + 5 + + + + + + voice_display_horizontally + 1 + + + 1 + 5 + + + + + + voice_talking_label + Talking + + 2 + 1 + + + + + + voice_idle_label + Idle + + 2 + 2 + + + + + + voice_mute_label + Mute + + 2 + 3 + + + + + + voice_avatar_label + Avatar + + 2 + 4 + + + + + + voice_foreground_label + 100 + 4 + 4 + 4 + 4 + Foreground + + 3 + 0 + + + + + + voice_background_label + 100 + 4 + 4 + 4 + 4 + Background + + 4 + 0 + + + + + + voice_border_label + 100 + 4 + 4 + 4 + 4 + Border + + 5 + 0 + + + + + + voice_talking_foreground + 1 + 1 + + + 3 + 1 + + + + + + voice_talking_background + 1 + 1 + + + 4 + 1 + + + + + + voice_talking_border + 1 + 1 + + + 5 + 1 + + + + + + voice_idle_foreground + 1 + 1 + + + 3 + 2 + + + + + + voice_idle_background + 1 + 1 + + + 4 + 2 + + + + + + voice_idle_border + 1 + 1 + + + 5 + 2 + + + + + + voice_mute_foreground + 1 + 1 + + + 3 + 3 + + + + + + voice_mute_background + 1 + 1 + + + 4 + 3 + + + + + + voice_avatar_background + 1 + 1 + + + 4 + 4 + + + + + + voice_hide_mouseover_label + Hide Overlay on Mouseover + 0 + + 6 + 1 + + + + + + voice_hide_mouseover + 1 + + + 7 + 1 + + + + + + voice_show_mouseover_label + Show again after (seconds) + 0 + + 6 + 2 + + + + + + voice_show_mouseover + 1 + 1 + voice_show_mouseover_adj + 1 + + + 7 + 2 + + + + + + voice_overlay_location_label + Overlay Location + + 0 + 0 + 2 + + + + + + voice_monitor + + + 0 + 1 + 2 + + + + + + voice_align_1 + 0 + + Left + Middle + Right + + + + 0 + 2 + 2 + + + + + + voice_align_2 + 0 + + Top + Middle + Bottom + + + + 0 + 3 + 2 + + + + + + + + notebook_voice_label + Voice + + + + + + + 2 + False + + + voice_advanced_grid + 1 + 5 + + + voice_text_side + + Right + Left + Bottom + Top + + + + 5 + 11 + + + + + + voice_text_side_label + Side of Icon + 0 + + 4 + 11 + + + + + + voice_display_icon_only_label + Show Names + 0 + + 2 + 0 + + + + + + voice_display_icon_only + 1 + + + 3 + 0 + + + + + + voice_font_label + Font + 0 + + 2 + 1 + + + + + + voice_font + 1 + 1 + Sans 12 + en-us + + + + 3 + 1 + + + + + + voice_text_padding_label + Text Padding + 0 + + 2 + 2 + + + + + + voice_text_padding + 1 + 0 + text_padding_adj + + + 3 + 2 + + + + + + + voice_show_title_label + Show Title + 0 + + 0 + 0 + + + + + + voice_show_title + 1 + + + 1 + 0 + + + + + + voice_show_connection_status_label + Show Connection Status Always + 0 + + 0 + 2 + + + + + + voice_show_connection_status + 1 + + + 1 + 2 + + + + + + voice_title_font + 1 + 1 + Sans 12 + en-us + + + + 1 + 1 + + + + + + voice_title_font_label + Title Font + 0 + + 0 + 1 + + + + + + voice_vertical_padding + 1 + 0 + voice_vertical_padding_adj + + + 1 + 4 + + + + + + voice_horizontal_padding_label + Horizontal Edge Padding + 0 + + 0 + 5 + + + + + + voice_horizontal_padding + 1 + 0 + voice_horizontal_padding_adj + + + 1 + 5 + + + + + + voice_show_test_content_label + Show Test Content + 0 + + 0 + 7 + + + + + + voice_show_test_content + 1 + + + 1 + 7 + + + + + + voice_dummy_count_label + Test Data Count + 0 + + 0 + 8 + + + + + + voice_dummy_count + 1 + 50 + voice_dummy_count_adj + 50 + + + 1 + 8 + + + + + + voice_show_disconnected_label + When Disconnected + 0 + + 0 + 3 + + + + + + voice_show_disconnected + 1 + + + 1 + 3 + + + + + + voce_vertical_padding_label + Verticle Edge Padding + 0 + + 0 + 4 + + + + + + voice_nick_length_label + Limit text length + 0 + + 2 + 4 + + + + + + voice_nick_length + 1 + 32 + voice_nick_lenght_adj + 32 + + + 3 + 4 + + + + + + voice_avatar_label + Show Avatar + 0 + + 2 + 5 + + + + + + voice_show_avatar + 1 + + + 3 + 5 + + + + + + voice_avatar_circle_label + Circle Avatar + 0 + + 2 + 6 + + + + + + voice_avatar_circle + 1 + + + 3 + 6 + + + + + + voice_avatar_size_label + Avatar Size + 0 + + 2 + 8 + + + + + + voice_avatar_size + 1 + 48 + avatar_size_adj + 48 + + + 3 + 8 + + + + + + voice_avatar_opacity_label + Avatar Opacity + 0 + + 2 + 9 + + + + + + 0 + voice_avatar_opacity + 1 + ava_opacity_adj + 3 + + + 3 + 9 + + + + + + voice_display_speakers_only_label + Display Speakers Only + 0 + + 4 + 0 + + + + + + voice_display_speakers_only + 1 + + + 5 + 0 + + + + + + voice_display_speakers_grace_period_label + Speakers Grace Period + 0 + + 4 + 1 + + + + + + voice_display_speakers_grace_period + 1 + 0 + voice_display_speakers_grace_period_adj + + + 5 + 1 + + + + + + voice_order_avatars_by_label + Order Users By + 0 + + 4 + 3 + + + + + + voice_order_avatars_by + + Alphabetically + ID + Last Spoken + + + + 5 + 3 + + + + + + voice_highlight_self_label + Highlight Self + 0 + + 4 + 4 + + + + + + voice_highlight_self + 1 + + + 5 + 4 + + + + + + voice_border_width_label + Border width + 0 + + 4 + 5 + + + + + + voice_border_width + 1 + 1 + border_width_adj + 1 + + + 5 + 5 + + + + + + voice_icon_spacing_label + Padding between users + 0 + + 4 + 6 + + + + + + voice_icon_spacing + 1 + 0 + icon_spacing_adj + + + 5 + 6 + + + + + + Reset Voice Settings + voice_reset_all + 1 + 1 + + + 5 + 12 + + + + + + voice_inactive_fade_label + Fade out when Inactive + 0 + + 4 + 7 + + + + + + voice_inactive_opacity_label + Inactive Opacity + 0 + + 4 + 8 + + + + + + voice_inactive_time_label + Time before Inactive + 0 + + 4 + 9 + + + + + + voice_inactive_fade_time_label + Time Fading out + 0 + + 4 + 10 + + + + + + voice_inactive_fade + 1 + + + 5 + 7 + + + + + + 1 + voice_inactive_opacity + 1 + voice_inactive_fade_opacity + 1 + + + 5 + 8 + + + + + + voice_inactive_time + 1 + 0 + voice_inactive_time_range + + + 5 + 9 + + + + + + voice_inactive_fade_time + 1 + 0 + voice_inactive_fade_time_range + + + 5 + 10 + + + + + + + + notebook_voice_adv_label + Voice - Advanced + + + + + + + 3 + False + + + text_grid + 5 + 5 + 5 + 5 + 1 + 1 + + + text_enable_label + Enable + 0 + + 0 + 0 + + + + + + text_enable + 1 + + + 1 + 0 + + + + + + text_popup_style_label + Popup Style + 0 + + 0 + 1 + + + + + + text_popup_style + 1 + + + 1 + 1 + + + + + + text_line_limit_label + Line Limit + 0 + + 0 + 12 + + + + + + text_line_limit + 1 + 10 + text_line_limit_adj + 10 + + + 1 + 12 + + + + + + text_show_attachments_label + Show Attachments + 0 + + 0 + 11 + + + + + + text_show_attachments + 1 + + + 1 + 11 + + + + + + text_overlay_location_label + Overlay Location + 0 + + 0 + 10 + + + + + + text_background_colour_label + Background Colour + 0 + + 0 + 8 + + + + + + text_background_colour + 1 + 1 + + + 1 + 8 + + + + + + text_colour_label + Text Colour + 0 + + 0 + 7 + + + + + + text_colour + 1 + 1 + + + 1 + 7 + + + + + + text_font + 1 + 1 + Sans 12 + en-us + + + + 1 + 6 + + + + + + text_font_label + Font + 0 + + 0 + 6 + + + + + + text_channel_label + Channel + 0 + + 0 + 5 + + + + + + text_channel + + 1 + 5 + + + + + + Refresh List + text_refresh_server_button + 1 + 1 + + + 1 + 4 + + + + + + text_server_label + Server + 0 + + 0 + 3 + + + + + + text_server + + 1 + 3 + + + + + + text_popup_time_label + Popup time limit + 0 + + 0 + 2 + + + + + + text_popup_time + 1 + text_popup_time_adj + + + 1 + 2 + + + + + + text_hide_mouseover_label + Hide Overlay on Mouseover + 0 + + 0 + 13 + + + + + + text_show_mouseover_label + Show again after (seconds) + 0 + + 0 + 14 + + + + + + Reset Text Settings + text_reset_all + 1 + 1 + + + 1 + 20 + + + + + + text_hide_mouseover + 1 + + + 1 + 13 + + + + + + text_show_mouseover + 1 + 1 + text_show_mouseover_adj + 1 + + + 1 + 14 + + + + + + text_monitor + + + 1 + 15 + + + + + + text_align_1 + 0 + + Left + Middle + Right + + + + 1 + 16 + + + + + + text_align_2 + 0 + + Top + Middle + Bottom + + + + 1 + 17 + + + + + + + + notebook_text_label + Text + + + + + + + 4 + False + + + notification_grid + 5 + 5 + 5 + 5 + 1 + 1 + + + notification_text_justify_label + Text Justification + 0 + + 0 + 16 + + + + + + notification_text_justify + + Left + Middle + Right + + + + 1 + 16 + + + + + + notification_enable_label + Enable + 0 + + 0 + 0 + + + + + + notification_popup_timer_label + Popup Timer + 0 + + 0 + 2 + + + + + + notification_limit_popup_width_label + Limit Popup Width + 0 + + 0 + 3 + + + + + + notification_font_label + Font + 0 + + 0 + 4 + + + + + + notification_text_colour_label + Text Colour + 0 + + 0 + 5 + + + + + + notification_background_colour_label + Background Colour + 0 + + 0 + 6 + + + + + + notification_show_icon_label + Show Icon + 0 + + 0 + 10 + + + + + + notification_icon_position_label + Icon Position + 0 + + 0 + 11 + + + + + + notification_enable + 1 + + + 1 + 0 + + + + + + notification_popup_timer + 1 + notification_popup_timer_adj + + + 1 + 2 + + + + + + notification_limit_popup_width + 1 + notification_limit_width_adj + + + 1 + 3 + + + + + + notification_font + 1 + 1 + Sans 12 + en-us + + + + 1 + 4 + + + + + + notification_text_colour + 1 + 1 + + + 1 + 5 + + + + + + notification_background_colour + 1 + 1 + + + 1 + 6 + + + + + + notification_show_icon + 1 + + + 1 + 10 + + + + + + Show Test Content + notification_show_test_content + 1 + + + 1 + 17 + + + + + + notification_border_radius_label + Border Radius + 0 + + 0 + 15 + + + + + + notification_padding_between_label + Padding Between Notifications + 0 + + 0 + 14 + + + + + + notification_icon_size_label + Icon Size + 0 + + 0 + 13 + + + + + + notification_icon_padding_label + Icon Padding + 0 + + 0 + 12 + + + + + + notification_icon_padding + 1 + notification_icon_padding_adj + + + 1 + 12 + + + + + + notification_icon_size + 1 + notification_icon_size_adj + + + 1 + 13 + + + + + + notification_padding_between + 1 + notification_padding_adj + + + 1 + 14 + + + + + + notification_border_radius + 1 + notification_border_radius_adj + + + 1 + 15 + + + + + + notification_monitor + 0 + + Unknown + + + + 1 + 7 + + + + + + notification_align_1 + 0 + + Left + Middle + Right + + + + 1 + 8 + + + + + + notification_align_2 + 0 + + Top + Middle + Bottom + + + + 1 + 9 + + + + + + notification_icon_position + + Left + Right + + + + 1 + 11 + + + + + + Reset Notification Settings + notification_reset_all + 1 + 1 + + + 1 + 18 + + + + + + + + + + + notebook_notification_label + Notification + + + + + + + 5 + False + + + core_grid + 5 + 5 + 5 + 5 + 2 + 8 + + + core_run_on_startup_label + Run Overlay on Startup + 0 + + 0 + 0 + + + + + + core_run_on_startup + 1 + + + 1 + 0 + + + + + + core_run_conf_on_startup_label + Run Configuration on Startup + 0 + + 0 + 1 + + + + + + core_run_conf_on_startup + 1 + + + 1 + 1 + + + + + + core_hide_overlay + 1 + + 1 + 5 + + + + + + core_hide_overlay_label + Hide Overlays + 0 + + 0 + 5 + + + + + + Reset Core Settings + core_reset_all + 1 + 1 + + + 1 + 7 + + + + + + core_audio_assist_label + Integrate with Pipewire/Pulseaudio + 0 + + 0 + 6 + + + + + + core_audio_assist + 1 + + + 1 + 6 + + + + + + + + + + + notebook_core_label + Core + + + + + + + + \ No newline at end of file diff --git a/discover_overlay/image_getter.py b/discover_overlay/image_getter.py index 9fc3466..c0efa97 100644 --- a/discover_overlay/image_getter.py +++ b/discover_overlay/image_getter.py @@ -14,151 +14,180 @@ import threading import logging import os -import copy import gi import requests -import cairo -import PIL import PIL.Image as Image -gi.require_version('GdkPixbuf', '2.0') -gi.require_version("Gtk", "3.0") -# pylint: disable=wrong-import-position -from gi.repository import Gtk # nopep8 +import io + +gi.require_version("GdkPixbuf", "2.0") +gi.require_version("Gtk", "4.0") + +from gi.repository import Gtk, GdkPixbuf, GLib log = logging.getLogger(__name__) -class SurfaceGetter(): - """Download and decode image using PIL and store as a cairo surface""" +class SurfaceGetter: + """Download and decode image to Pixbuf""" - def __init__(self, func, url, identifier, size): + def __init__(self, func, url, identifier, display, recolor): self.func = func self.identifier = identifier self.url = url - self.size = size + self.display = display + self.recolor = recolor + + def pil_recolor(self, image): + """Takes a PIL Image object, rejigs the colours, and outputs a GLib.Bytes containing a PNG formatted version of the image""" + arr = bytearray(image.tobytes()) + r_p = self.recolor[0] * self.recolor[3] + g_p = self.recolor[1] * self.recolor[3] + b_p = self.recolor[2] * self.recolor[3] + a_p = self.recolor[3] + if image.has_transparency_data: + for idx in range(0, len(arr), 4): + arr[idx] = int(arr[idx] * r_p) + arr[idx + 1] = int(arr[idx + 1] * g_p) + arr[idx + 2] = int(arr[idx + 2] * b_p) + arr[idx + 3] = int(arr[idx + 3] * a_p) + else: + for idx in range(0, len(arr), 3): + arr[idx] = int(arr[idx] * r_p) + arr[idx + 1] = int(arr[idx + 1] * g_p) + arr[idx + 2] = int(arr[idx + 2] * b_p) + pimage = Image.frombytes( + "RGBA" if image.has_transparency_data else "RGB", image.size, arr + ) + img_byte_arr = io.BytesIO() + pimage.save(img_byte_arr, format="PNG") + return GLib.Bytes(img_byte_arr.getvalue()) def get_url(self): """Downloads and decodes""" + pixbuf = None + resp = None try: resp = requests.get( - self.url, stream=True, timeout=10, headers={ - 'Referer': 'https://streamkit.discord.com/overlay/voice', - 'User-Agent': 'Mozilla/5.0' - } + self.url, + stream=True, + timeout=10, + headers={ + "Referer": "https://streamkit.discord.com/overlay/voice", + "User-Agent": "Mozilla/5.0", + }, ) - raw = resp.raw - image = Image.open(raw) - (surface, mask) = from_pil(image) - - self.func(self.identifier, surface, mask) except requests.HTTPError: log.error("Unable to open %s", self.url) + return except requests.TooManyRedirects: log.error("Unable to open %s - Too many redirects", self.url) + return except requests.Timeout: log.error("Unable to open %s - Timeout", self.url) - except requests.ConnectionError: - log.error("Unable to open %s - Connection error", self.url) - except ValueError: - log.error("Unable to read %s", self.url) - except TypeError: - log.error("Unable to read %s", self.url) - except PIL.UnidentifiedImageError: - log.error("Unknown image type: %s", self.url) + return + except requests.ConnectionError as e: + log.error("Unable to open %s - Connection error %s", self.url, e) + return + + content = self.pil_recolor(Image.open(resp.raw)) + + loader = GdkPixbuf.PixbufLoader() + try: + loader.write_bytes(content) + loader.close() + except ValueError as e: + log.error("Unable to open %s - Value error %s", self.url, e) + return + except TypeError as e: + log.error("Unable to open %s - Type error %s", self.url, e) + return + except GLib.GError as e: + log.error("Unable to open %s - GError %s", self.url, e) + return + pixbuf = loader.get_pixbuf() + self.func(self.identifier, pixbuf) def get_file(self): """Attempt to load the file""" errors = [] - # Grab icon from icon theme - icon_theme = Gtk.IconTheme.get_default() - icon = icon_theme.choose_icon( - [self.url, None], -1, Gtk.IconLookupFlags.NO_SVG) + icon = None + content = None + if "/" in self.url or "." in self.url or "~" in self.url: + # It's a filename for sure + if self.url.startswith("/") or self.url.startswith("~"): + # Single path + locations = [""] + else: + # Take pot shots + locations = [ + "", + os.path.join( + os.path.expanduser("~/.local/"), + "share/icons/hicolor/256x256/apps/", + ), + os.path.join("/usr/", "share/icons/hicolor/256x256/apps/"), + os.path.join("/app", "share/icons/hicolor/256x256/apps/"), + ] + + # Not found in theme, try some common locations + for prefix in locations: + mixpath = os.path.join( + prefix, + self.url, + ) + if not os.path.isfile(mixpath): + errors.append(f"File not found: {mixpath}") + continue + content = self.pil_recolor(Image.open(mixpath)) + else: # URL is a GTK icon name + icon_theme = Gtk.IconTheme.get_for_display(self.display) + icon = icon_theme.lookup_icon( + self.url, + None, + -1, + 1, + Gtk.TextDirection.NONE, + Gtk.IconLookupFlags.FORCE_REGULAR, + ) - if icon: - try: - image = Image.open(icon.get_filename()) - (surface, mask) = from_pil(image) - if surface: - self.func(self.identifier, surface, mask) + if icon: + try: + image = GdkPixbuf.Pixbuf.new_from_file(icon.get_file().get_path()) + self.func(self.identifier, image) return - except ValueError: - errors.append("Value Error - Unable to read %s", self.url) - except TypeError: - errors.append("Type Error - Unable to read %s", self.url) - except PIL.UnidentifiedImageError: - errors.append("Unknown image type: %s", self.url) - except FileNotFoundError: - errors.append("File not found: %s", self.url) - # Not found in theme, try some common locations - locations = [os.path.expanduser('~/.local/'), '/usr/', '/app'] - for prefix in locations: - mixpath = os.path.join(os.path.join( - prefix, 'share/icons/hicolor/256x256/apps/'), self.url + ".png") - image = None - try: - image = Image.open(mixpath) - except ValueError: - errors.append(f"Value Error - Unable to read {mixpath}") - except TypeError: - errors.append(f"Type Error - Unable to read {mixpath}") - except PIL.UnidentifiedImageError: - errors.append(f"Unknown image type: {mixpath}") - except FileNotFoundError: - errors.append(f"File not found: {mixpath}") - if image: - (surface, mask) = from_pil(image) - if surface: - self.func(self.identifier, surface, mask) - return - for error in errors: - log.error(error) - - -def from_pil(image, alpha=1.0, image_format='BGRa'): - """ - :param im: Pillow Image - :param alpha: 0..1 alpha to add to non-alpha images - :param format: Pixel format for output surface - """ - arr = bytearray() - mask = bytearray() - if 'A' not in image.getbands(): - image.putalpha(int(alpha * 255.0)) - arr = bytearray(image.tobytes('raw', image_format)) - mask = arr - else: - arr = bytearray(image.tobytes('raw', image_format)) - mask = copy.deepcopy((arr)) - idx = 0 - while idx < len(arr): - if arr[idx] > 0: - mask[idx] = 255 - else: - mask[idx] = 0 - # Cairo expects the raw data to be pre-multiplied alpha - # This means when we change the alpha level we need to change the RGB channels equally - arr[idx] = int(arr[idx] * alpha) - idx += 1 - surface = cairo.ImageSurface.create_for_data( - arr, cairo.FORMAT_ARGB32, image.width, image.height) - mask = cairo.ImageSurface.create_for_data( - mask, cairo.FORMAT_ARGB32, image.width, image.height) - return (surface, mask) + except ValueError as e: + errors.append(f"Value Error - Unable to read {self.url} {e}") + except FileNotFoundError as e: + errors.append(f"File not found: {self.url} {e}") + errors.append("Not an icon : self.url") + return -def to_pil(surface): - """Return a PIL Image from the Cairo surface""" - if surface.get_format() == cairo.Format.ARGB32: - return Image.frombuffer('RGBA', (surface.get_width(), surface.get_height()), - surface.get_data(), 'raw', "BGRA", surface.get_stride()) - return Image.frombuffer("RGB", (surface.get_width(), surface.get_height()), - surface.get_data(), 'raw', "BGRX", surface.get_stride()) + loader = GdkPixbuf.PixbufLoader() + try: + loader.write_bytes(content) + loader.close() + except ValueError as e: + log.error("Unable to open %s - Value error %s", self.url, e) + return + except TypeError as e: + log.error("Unable to open %s - Type error %s", self.url, e) + return + except GLib.GError as e: + log.error("Unable to open %s - GError %s", self.url, e) + return + pixbuf = loader.get_pixbuf() + self.func(self.identifier, pixbuf) + for error in errors: + log.error(error) -def get_surface(func, identifier, ava, size): - """Download to cairo surface""" - image_getter = SurfaceGetter(func, identifier, ava, size) - if identifier.startswith('http'): +def get_surface(func, identifier, ava, display, recolor=None): + """Download to Pixbuf""" + if not recolor: + recolor = [1.0, 1.0, 1.0, 1.0] + image_getter = SurfaceGetter(func, identifier, ava, display, recolor) + if identifier.startswith("http"): thread = threading.Thread(target=image_getter.get_url) thread.start() else: @@ -192,80 +221,3 @@ def get_aspected_size(img, width, height, anchor=0, hanchor=0): if hanchor == 1: offset_x = offset_x + ((old_width - width) / 2) return (offset_x, offset_y, width, height) - - -def draw_img_to_rect(img, ctx, - pos_x, pos_y, - width, height, - path=False, aspect=False, - anchor=0, hanchor=0, alpha=1.0): - """Draw cairo surface onto context - - Path - only add the path do not fill : True/False - Aspect - keep aspect ratio : True/False - Anchor - with aspect : 0=left 1=middle 2=right - HAnchor - with apect : 0=bottom 1=middle 2=top - """ - - ctx.save() - offset_x = 0 - offset_y = 0 - if aspect: - (offset_x, offset_y, width, height) = get_aspected_size( - img, width, height, anchor=anchor, hanchor=hanchor) - - ctx.translate(pos_x + offset_x, pos_y + offset_y) - ctx.scale(width, height) - ctx.scale(1 / img.get_width(), 1 / img.get_height()) - - if alpha != 1.0: - # Honestly, couldn't find a 'use-image-with-modifier' option - # Tried RasterSourcePattern but it appears... broken? in the python implementation - # Or just lacking documentation. - - # Pass raw data to PIL and then back with an alpha modifier - ctx.set_source_surface( - from_pil( - to_pil(img), - alpha - )[0], - 0, 0) - else: - ctx.set_source_surface(img, 0, 0) - - ctx.rectangle(0, 0, img.get_width(), img.get_height()) - if not path: - ctx.fill() - ctx.restore() - return (width, height) - - -def draw_img_to_mask(img, ctx, - pos_x, pos_y, - width, height, - path=False, aspect=False, - anchor=0, hanchor=0): - """Draw cairo surface as mask into context - - Path - only add the path do not fill : True/False - Aspect - keep aspect ratio : True/False - Anchor - with aspect : 0=left 1=middle 2=right - HAnchor - with apect : 0=bottom 1=middle 2=top - """ - - ctx.save() - offset_x = 0 - offset_y = 0 - if aspect: - (offset_x, offset_y, width, height) = get_aspected_size( - img, width, height, anchor=anchor, hanchor=hanchor) - - ctx.translate(pos_x + offset_x, pos_y + offset_y) - ctx.scale(width, height) - ctx.scale(1 / img.get_width(), 1 / img.get_height()) - - ctx.rectangle(0, 0, img.get_width(), img.get_height()) - if not path: - ctx.mask_surface(img, 0, 0) - ctx.restore() - return (width, height) diff --git a/discover_overlay/img/discover-overlay-deaf.png b/discover_overlay/img/discover-overlay-deaf.png new file mode 100644 index 0000000..811193e Binary files /dev/null and b/discover_overlay/img/discover-overlay-deaf.png differ diff --git a/discover_overlay/img/discover-overlay-deaf.svg b/discover_overlay/img/discover-overlay-deaf.svg new file mode 100644 index 0000000..33a5a52 --- /dev/null +++ b/discover_overlay/img/discover-overlay-deaf.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/discover-overlay-default.png b/discover_overlay/img/discover-overlay-default.png similarity index 100% rename from discover-overlay-default.png rename to discover_overlay/img/discover-overlay-default.png diff --git a/discover-overlay-default.svg b/discover_overlay/img/discover-overlay-default.svg similarity index 100% rename from discover-overlay-default.svg rename to discover_overlay/img/discover-overlay-default.svg diff --git a/discover_overlay/img/discover-overlay-mute.png b/discover_overlay/img/discover-overlay-mute.png new file mode 100644 index 0000000..187e94e Binary files /dev/null and b/discover_overlay/img/discover-overlay-mute.png differ diff --git a/discover_overlay/img/discover-overlay-mute.svg b/discover_overlay/img/discover-overlay-mute.svg new file mode 100644 index 0000000..444482d --- /dev/null +++ b/discover_overlay/img/discover-overlay-mute.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + diff --git a/discover_overlay/layout.py b/discover_overlay/layout.py new file mode 100644 index 0000000..90b6214 --- /dev/null +++ b/discover_overlay/layout.py @@ -0,0 +1,312 @@ +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Collection of LayoutManagers used throughout""" +import logging +from enum import Enum +import gi + +gi.require_version("Gtk", "4.0") + + +from gi.repository import Gtk, Gdk + +log = logging.getLogger(__name__) + + +class Direction(Enum): + """Direction of flow""" + + LTR = 0 + RTL = 1 + TTB = 2 + BTT = 3 + + +class VertAlign(Enum): + """Possible positions for overlay""" + + TOP = 0 + MIDDLE = 1 + BOTTOM = 2 + + +class HorzAlign(Enum): + """Possible positions for overlay""" + + LEFT = 0 + MIDDLE = 1 + RIGHT = 2 + + +def get_h_align(in_str): + """Get a HorzAlign or None, from a string""" + assert isinstance(in_str, str) + if in_str.lower() == "left": + return HorzAlign.LEFT + elif in_str.lower() == "right": + return HorzAlign.RIGHT + elif in_str.lower() == "middle": + return HorzAlign.MIDDLE + elif in_str.lower() == "none": + return HorzAlign.LEFT + log.error("Unknown H Align : %s", in_str) + return None + + +def get_v_align(in_str): + """Get a VertAlign or None, from a string""" + assert isinstance(in_str, str) + if in_str.lower() == "top": + return VertAlign.TOP + elif in_str.lower() == "bottom": + return VertAlign.BOTTOM + elif in_str.lower() == "middle": + return VertAlign.MIDDLE + elif in_str.lower() == "none": + return VertAlign.TOP + log.error("Unknown V Align : %s", in_str) + return None + + +class AmalgamationLayout(Gtk.LayoutManager): + """A Layout manager to place all child widgets where they request, allowing overlapping""" + + # pylint: disable=W0221 + def do_allocate(self, widget, width, height, _baseline): + child = widget.get_first_child() + while child: + (h_a, v_a) = child.get_align() + vert = child.measure(Gtk.Orientation.VERTICAL, width) + horz = child.measure(Gtk.Orientation.HORIZONTAL, height) + pref_w = horz[0] + pref_h = vert[0] + if pref_w > width: + pref_w = width + + alloc = Gdk.Rectangle() + if h_a == HorzAlign.LEFT: + alloc.x = 0 + elif h_a == HorzAlign.MIDDLE: + alloc.x = width / 2 - int(pref_w / 2) + else: + alloc.x = width - pref_w + if v_a == VertAlign.TOP: + alloc.y = 0 + elif v_a == HorzAlign.MIDDLE: + alloc.y = height / 2 - int(pref_h / 2) + else: + alloc.y = height - pref_h + alloc.width = pref_w + alloc.height = pref_h + child.size_allocate(alloc, -1) + child = child.get_next_sibling() + + # pylint: disable=W0221,W0613 + def do_measure(self, widget, orientation, for_size): + (_screen_x, _screen_y, screen_width, screen_height) = ( + widget.get_parent().get_display_coords() + ) + + if orientation == Gtk.Orientation.VERTICAL: + return (screen_height, screen_height, -1, -1) + else: + return (screen_width, screen_width, -1, -1) + + +class NotificationLayout(Gtk.LayoutManager): + """A Layout manager to lay out a single notification widget""" + + # pylint: disable=W0221 + def do_allocate(self, widget, width, height, _baseline): + asize = widget.overlay.icon_size + padding = widget.overlay.padding + i_padding = widget.overlay.icon_pad + if not widget.overlay.show_icon: + asize = 0 + i_padding = 0 + if not widget.image: + asize = 0 + i_padding = 0 + img_alloc = Gdk.Rectangle() + lbl_alloc = Gdk.Rectangle() + ttl_alloc = Gdk.Rectangle() + img_alloc.x = lbl_alloc.x = ttl_alloc.x = padding + img_alloc.y = lbl_alloc.y = ttl_alloc.y = padding + width = width - (padding * 2) + height = height - (padding * 2) + + text_width = width - (asize + i_padding) + + [t_min, _t_nat, _t_bl, _t_nat_bl] = widget.title.measure( + Gtk.Orientation.VERTICAL, text_width + ) + [l_min, _l_nat, _l_bl, _l_nat_bl] = widget.message.measure( + Gtk.Orientation.VERTICAL, text_width + ) + split = t_min + if height < t_min + l_min: + log.error("height %s : %s %s", height, t_min, l_min) + split = (height) * ((t_min) / (t_min + l_min)) + + img_alloc.width = asize + img_alloc.height = asize + + lbl_alloc.height = height - split + ttl_alloc.height = split + lbl_alloc.y += split + + lbl_alloc.width = ttl_alloc.width = text_width + + if widget.overlay.icon_left: + ttl_alloc.x += asize + i_padding + lbl_alloc.x += asize + i_padding + else: + img_alloc.x += text_width + i_padding + + if widget.image: + widget.image.size_allocate(img_alloc, -1) + widget.title.size_allocate(ttl_alloc, -1) + widget.message.size_allocate(lbl_alloc, -1) + + # pylint: disable=W0221,W0613 + def do_measure(self, widget, orientation, for_size): + asize = widget.overlay.icon_size + padding = widget.overlay.padding + i_padding = widget.overlay.icon_pad + if not widget.overlay.show_icon: + asize = 0 + i_padding = 0 + im_m = [0, 0, 0, 0] + if not widget.image: + asize = 0 + i_padding = 0 + else: + im_m = [asize, asize, 0, 0] + for_size = for_size - (padding * 2) - i_padding + if orientation == Gtk.Orientation.VERTICAL: + lb_m = widget.message.measure(orientation, for_size - asize) + tt_m = widget.title.measure(orientation, for_size - asize) + return ( + max(im_m[0], lb_m[0] + tt_m[0]) + (padding * 2), + max(im_m[1], lb_m[1] + tt_m[1]) + (padding * 2), + -1, + -1, + ) + else: + lb_m = widget.message.measure(orientation, for_size) + tt_m = widget.title.measure(orientation, for_size) + return ( + im_m[0] + max(lb_m[0], tt_m[0]) + (padding * 2) + i_padding, + im_m[1] + max(lb_m[1], tt_m[1]) + (padding * 2) + i_padding, + -1, + -1, + ) + + +class MessageBoxLayout(Gtk.LayoutManager): + """A Layout manager to lay out a message box, which places messages newest at the bottom and crops at a preset height""" + + # pylint: disable=W0221 + def do_allocate(self, widget, width, height, _baseline): + y = height + child = widget.get_last_child() + while child: + alloc = Gdk.Rectangle() + measure = child.measure(Gtk.Orientation.VERTICAL, width) + alloc.x = 0 + alloc.y = y - measure[0] + y -= measure[0] + alloc.width = width + alloc.height = measure[0] + child.size_allocate(alloc, -1) + child = child.get_prev_sibling() + + # pylint: disable=W0221,W0613 + def do_measure(self, widget, orientation, _for_size): + if orientation == Gtk.Orientation.VERTICAL: + return (widget.height_limit, widget.height_limit, -1, -1) + else: + return (widget.width_limit, widget.width_limit, -1, -1) + + +class UserBoxLayout(Gtk.LayoutManager): + """A Layout manager to lay out a userbox in voice overlay""" + + # pylint: disable=W0221 + def do_allocate(self, widget, width, height, _baseline): + direction = Direction(widget.overlay.text_side) + asize = widget.overlay.avatar_size + img_alloc = Gdk.Rectangle() + lbl_alloc = Gdk.Rectangle() + + img_alloc.width = img_alloc.height = asize + if direction == Direction.LTR: + img_alloc.y = height / 2 - int(asize / 2) + img_alloc.x = lbl_alloc.y = 0 + lbl_alloc.x = asize + lbl_alloc.height = height + lbl_alloc.width = width - asize + elif direction == Direction.RTL: + img_alloc.y = height / 2 - int(asize / 2) + lbl_alloc.x = lbl_alloc.y = 0 + lbl_alloc.height = height + lbl_alloc.width = img_alloc.x = width - asize + elif direction == Direction.TTB: + img_alloc.x = width / 2 - int(asize / 2) + img_alloc.y = lbl_alloc.x = 0 + lbl_alloc.y = asize + lbl_alloc.width = width + lbl_alloc.height = height - asize + else: + img_alloc.x = width / 2 - int(asize / 2) + img_alloc.y = lbl_alloc.height = height - asize + lbl_alloc.x = lbl_alloc.y = 0 + lbl_alloc.width = width + + tx = widget.overlay.text_x_align + if tx == "left": + widget.label.set_halign(Gtk.Align.START) + elif tx == "middle": + widget.label.set_halign(Gtk.Align.CENTER) + else: + widget.label.set_halign(Gtk.Align.END) + ty = widget.overlay.text_y_align + if ty == "top": + widget.label.set_valign(Gtk.Align.START) + elif ty == "middle": + widget.label.set_valign(Gtk.Align.CENTER) + else: + widget.label.set_valign(Gtk.Align.END) + + widget.image.size_allocate(img_alloc, -1) + widget.label.size_allocate(lbl_alloc, -1) + widget.mute.size_allocate(img_alloc, -1) + widget.deaf.size_allocate(img_alloc, -1) + + # pylint: disable=W0221,W0613 + def do_measure(self, widget, orientation, for_size): + direction = Direction(widget.overlay.text_side) + + im_m = widget.image.measure(orientation, for_size) + lb_m = widget.label.measure(orientation, for_size) + + if ( + orientation == Gtk.Orientation.VERTICAL + and (direction == Direction.TTB or direction == Direction.BTT) + ) or ( + orientation == Gtk.Orientation.HORIZONTAL + and (direction == Direction.LTR or direction == Direction.RTL) + ): + return (im_m[0] + lb_m[0], im_m[1] + lb_m[1], -1, -1) + else: + return (max(im_m[0], lb_m[0]), max(im_m[1], lb_m[1]), -1, -1) diff --git a/discover_overlay/message.py b/discover_overlay/message.py new file mode 100644 index 0000000..f9268fa --- /dev/null +++ b/discover_overlay/message.py @@ -0,0 +1,98 @@ +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Notification window for text""" +import logging +import gi +import time + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, GLib + +log = logging.getLogger(__name__) + + +class Message(Gtk.Box): + """Overlay window for notifications""" + + def __init__(self, overlay, message): + Gtk.Box.__init__(self) + self.skip = False + self.overlay = overlay + self.message = message + + self.image = None + self.label = Gtk.Label() + self.label.add_css_class("message") + self.label.set_wrap(True) + self.label.set_markup(f"{GLib.markup_escape_text(message["nick"])}:{self.make_line(message["content"])}") + self.append(self.label) + if overlay.popup_style: + hide_time = message["time"] + overlay.text_time + now = time.time() + timeout = hide_time - now + if timeout > 0: + GLib.timeout_add_seconds(timeout, self.exit) + else: + self.skip = True + + def update(self): + """Something has changed. Check for attachments, edits to text...""" + # TODO Update + + def exit(self): + """Remove self from overlay""" + self.overlay.remove(self) + self.overlay.update() + + def make_line(self, message): + """Decode a recursive JSON object into pango markup.""" + ret = "" + if isinstance(message, list): + for inner_message in message: + ret = f"{ret}{self.make_line(inner_message)}" + elif isinstance(message, str): + ret = GLib.markup_escape_text(message) + elif message["type"] == "strong": + ret = f"{self.make_line(message['content'])}" + elif message["type"] == "text": + ret = GLib.markup_escape_text(message["content"]) + elif message["type"] == "link": + ret = f"{self.make_line(message['content'])}" + elif message["type"] == "emoji": + if "surrogate" in message: + # ['src'] is SVG URL + # ret = msg + ret = message["surrogate"] + else: + ret = "`" + elif ( + message["type"] == "inlineCode" + or message["type"] == "codeBlock" + or message["type"] == "blockQuote" + ): + ret = f"{self.make_line(message['content'])}" + elif message["type"] == "u": + ret = f"{self.make_line(message['content'])}" + elif message["type"] == "em": + ret = f"{self.make_line(message['content'])}" + elif message["type"] == "s": + ret = f"{self.make_line(message['content'])}" + elif message["type"] == "channel": + ret = self.make_line(message["content"]) + elif message["type"] == "mention": + ret = self.make_line(message["content"]) + elif message["type"] == "br": + ret = "\n" + else: + pass + return ret diff --git a/discover_overlay/notification.py b/discover_overlay/notification.py new file mode 100644 index 0000000..3908ccf --- /dev/null +++ b/discover_overlay/notification.py @@ -0,0 +1,106 @@ +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Notification window for text""" +import logging +import gi +from .image_getter import get_surface +from .overlay import HorzAlign +from .layout import NotificationLayout + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, GLib, Pango + +log = logging.getLogger(__name__) + + +class Notification(Gtk.Box): + """Overlay window for notifications""" + + def __init__(self, overlay, image, title, message, timeout): + Gtk.Box.__init__(self) + self.set_layout_manager(NotificationLayout()) + + self.overlay = overlay + self.title = Gtk.Label() + self.message = Gtk.Label() + self.image = None + + self.append(self.title) + self.append(self.message) + + self.title.set_markup(GLib.markup_escape_text(title)) + self.message.set_markup(GLib.markup_escape_text(message)) + + self.title.set_wrap(True) + self.message.set_wrap(True) + + self.title.set_wrap_mode(Pango.WrapMode.WORD) + self.message.set_wrap_mode(Pango.WrapMode.WORD) + + self.title.set_justify(Gtk.Justification.RIGHT) + self.message.set_justify(Gtk.Justification.RIGHT) + + self.add_css_class("notification") + self.title.add_css_class("title") + self.message.add_css_class("message") + + self.show() + self.title.show() + self.message.show() + if image: + if not isinstance(image, str): + image = f"{image[0]}{image[1]}" + log.info(image) + get_surface(self.recv_avatar, image, "channel", self.get_display()) + + if timeout: + GLib.timeout_add_seconds(timeout, self.exit) + overlay.append(self) + self.get_root().set_visibility() + + def recv_avatar(self, _ident, pix): + """A new image touches the notification""" + if pix and not self.image: + self.image = Gtk.Image() + self.append(self.image) + self.image.add_css_class("image") + self.image.show() + self.image.set_from_pixbuf(pix) + # self.image.set_valign(Gtk.Align.START) + elif not pix and self.image: + self.remove(self.image) + self.image = None + elif pix and self.image: + self.image.set_from_pixbuf(pix) + self.queue_resize() + + def exit(self): + """Remove self from visible notifications""" + self.overlay.remove(self) + self.overlay.get_root().set_visibility() + + def update(self): + """Change child properties based on config of overlay""" + align = Gtk.Align.START + justify = Gtk.Justification.LEFT + if self.overlay.text_align == HorzAlign.MIDDLE: + align = Gtk.Align.CENTER + justify = Gtk.Justification.CENTER + elif self.overlay.text_align == HorzAlign.RIGHT: + align = Gtk.Align.END + justify = Gtk.Justification.RIGHT + + self.title.set_justify(justify) + self.message.set_justify(justify) + self.title.set_halign(align) + self.message.set_halign(align) diff --git a/discover_overlay/notification_overlay.py b/discover_overlay/notification_overlay.py index bb4132f..c92ae76 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -12,541 +12,236 @@ # along with this program. If not, see . """Notification window for text""" import logging -import time -import math - +import json import cairo import gi -from .image_getter import get_surface, draw_img_to_rect -from .overlay import OverlayWindow -gi.require_version('PangoCairo', '1.0') -# pylint: disable=wrong-import-position,wrong-import-order -from gi.repository import Pango, PangoCairo # nopep8 +from .overlay import get_h_align, get_v_align, HorzAlign, VertAlign +from .notification import Notification +from .css_helper import col_to_css, font_string_to_css_font_string + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk log = logging.getLogger(__name__) -class NotificationOverlayWindow(OverlayWindow): +class NotificationOverlayWindow(Gtk.Box): """Overlay window for notifications""" - def __init__(self, discover, piggyback=None): - OverlayWindow.__init__(self, discover, piggyback) - self.text_spacing = 4 - self.content = [] + def __init__(self, discover): + Gtk.Box.__init__(self) + self.discover = discover + + self.set_orientation(Gtk.Orientation.VERTICAL) self.test_content = [ { - "icon": ( + "icon_url": ( "https://cdn.discordapp.com/" "icons/951077080769114172/991abffc0d2a5c040444be4d1a4085f4.webp?size=96" ), - "title": "Title1" - }, - { - "title": "Title2", - "body": "Body", - "icon": None + "title": "Title1", }, + {"title": "Title2", "body": "Body", "icon": None}, { - "icon": ("https://cdn.discordapp.com/" - "icons/951077080769114172/991abffc0d2a5c040444be4d1a4085f4.webp?size=96" - ), + "icon_url": ( + "https://cdn.discordapp.com/" + "icons/951077080769114172/991abffc0d2a5c040444be4d1a4085f4.webp?size=96" + ), "title": "Title 3", - "body": ("Lorem ipsum dolor sit amet, consectetur adipiscing elit," - " sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " - "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris " - "nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in " - "reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla " - "pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa " - "qui officia deserunt mollit anim id est laborum." - ) + "body": ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit," + " sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris " + "nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in " + "reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla " + "pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa " + "qui officia deserunt mollit anim id est laborum." + ), }, { - "icon": None, + "icon_url": None, "title": "Title 3", - "body": ("Lorem ipsum dolor sit amet, consectetur adipiscing elit, " - "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " - "Ut enim ad minim veniam, quis nostrud exercitation ullamco " - "laboris nisi ut aliquip ex ea commodo consequat. Duis aute " - "irure dolor in reprehenderit in voluptate velit esse cillum " - "dolore eu fugiat nulla pariatur. Excepteur sint occaecat " - "cupidatat non proident, sunt in culpa qui officia deserunt " - "mollit anim id est laborum." - ) + "body": ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, " + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " + "Ut enim ad minim veniam, quis nostrud exercitation ullamco " + "laboris nisi ut aliquip ex ea commodo consequat. Duis aute " + "irure dolor in reprehenderit in voluptate velit esse cillum " + "dolore eu fugiat nulla pariatur. Excepteur sint occaecat " + "cupidatat non proident, sunt in culpa qui officia deserunt " + "mollit anim id est laborum." + ), }, { - "icon": ("https://cdn.discordapp.com/" - "avatars/147077941317206016/6a6935192076489fa6dc1eb5dafbf6e7.webp?size=128" - ), + "icon_url": ( + "https://cdn.discordapp.com/" + "avatars/147077941317206016/6a6935192076489fa6dc1eb5dafbf6e7.webp?size=128" + ), "title": "PM", - "body": "Birdy test" - } + "body": "Birdy test", + }, ] - self.text_font = None - self.text_size = 13 self.text_time = None self.show_icon = None - self.pango_rect = Pango.Rectangle() - self.pango_rect.width = self.text_size * Pango.SCALE - self.pango_rect.height = self.text_size * Pango.SCALE - - self.connected = True - self.bg_col = [0.0, 0.6, 0.0, 0.1] - self.fg_col = [1.0, 1.0, 1.0, 1.0] - self.icons = [] - self.reverse_order = False self.padding = 10 self.border_radius = 5 self.limit_width = 100 - self.testing = False self.icon_size = 64 self.icon_pad = 16 self.icon_left = True - - self.image_list = {} - self.warned_filetypes = [] - self.set_title("Discover Notifications") - self.redraw() - - def set_blank(self): - """Set to no data and redraw""" - self.content = [] - self.set_needs_redraw() - - def tick(self): - """Remove old messages from dataset""" - now = time.time() - newlist = [] - oldsize = len(self.content) - # Iterate over and remove messages older than 30s - for message in self.content: - if message['time'] + self.text_time > now: - newlist.append(message) - self.content = newlist - # If there is still content to remove - if len(newlist) > 0 or oldsize != len(newlist): - self.set_needs_redraw() + self.text_align = "left" + self.align_x = HorzAlign.RIGHT + self.align_y = VertAlign.TOP + self.show() def add_notification_message(self, data): """Add new message to dataset""" - noti = None - data = data['data'] - message_id = data['message']['id'] - for message in self.content: - if message['id'] == message_id: - return - if 'body' in data and 'title' in data: - if 'icon_url' in data: - noti = {"icon": data['icon_url'], - "title": data['title'], - "body": data['body'], "time": time.time(), - "id": message_id} - else: - noti = {"title": data['title'], - "body": data['body'], "time": time.time(), - "id": message_id} - - if noti: - self.content.append(noti) - self.set_needs_redraw() - self.get_all_images() + if "data" in data: + data = data["data"] + if "body" in data or "title" in data: + n_not = Notification( + self, + data["icon_url"] if "icon_url" in data else None, + data["title"] if "title" in data else "", + data["body"] if "body" in data else "", + self.text_time, + ) + # n_not.set_reveal_child(True) + else: + log.error("Malformed message %s", data) def set_padding(self, padding): """Config option: Padding between notifications, in window-space pixels""" - if self.padding != padding: - self.padding = padding - self.set_needs_redraw() + self.set_spacing(padding) + + def set_font(self, font): + """ + Set the font used by the overlay + """ + self.set_css("font", "* { font: %s; }" % (font_string_to_css_font_string(font))) + + def set_icon_padding(self, padding): + """Config option: Set space between icon and title/body""" + self.icon_pad = padding + self.update_all() def set_border_radius(self, radius): """Config option: Radius of the border, in window-space pixels""" - if self.border_radius != radius: - self.border_radius = radius - self.set_needs_redraw() + self.padding = radius + self.set_css( + "border-radius", ".notification { border-radius: %spx; }" % (radius) + ) def set_icon_size(self, size): """Config option: Size of icons, in window-space pixels""" if self.icon_size != size: self.icon_size = size - self.image_list = {} - self.get_all_images() - - def set_icon_pad(self, pad): - """Config option: Padding between icon and message, in window-space pixels""" - if self.icon_pad != pad: - self.icon_pad = pad - self.set_needs_redraw() def set_icon_left(self, left): """Config option: Icon on left or right of text""" if self.icon_left != left: self.icon_left = left - self.set_needs_redraw() def set_text_time(self, timer): """Config option: Duration that a message will be visible for, in seconds""" self.text_time = timer - self.timer_after_draw = timer def set_limit_width(self, limit): - """Config option: Word wrap limit, in window-space pixels - """ - if self.limit_width != limit: - self.limit_width = limit - self.set_needs_redraw() - - def get_all_images(self): - """Return a list of all downloaded images""" - the_list = self.content - if self.testing: - the_list = self.test_content - for line in the_list: - icon = line["icon"] - - if icon and icon not in self.image_list: - get_surface(self.recv_icon, icon, icon, - self.icon_size) - - def recv_icon(self, identifier, pix, _mask): - """Callback from image_getter for icons""" - self.image_list[identifier] = pix - self.set_needs_redraw() + """Config option: Word wrap limit, in window-space pixels""" + child = self.get_first_child() + while child: + child.set_size_request(1, 1) + child = child.get_next_sibling() def set_fg(self, fg_col): """Config option: Set default text colour""" - if self.fg_col != fg_col: - self.fg_col = fg_col - self.set_needs_redraw() + self.set_css( + "text-col", + ".notification .message, .notification .title { color: %s; }" + % (col_to_css(fg_col)), + ) def set_bg(self, bg_col): """Config option: Set background colour""" - if self.bg_col != bg_col: - self.bg_col = bg_col - self.set_needs_redraw() + self.set_css( + "background", + ".notification { background-color: %s; }" % (col_to_css(bg_col)), + ) def set_show_icon(self, icon): """Config option: Set if icons should be shown inline""" - if self.show_icon != icon: - self.show_icon = icon - self.set_needs_redraw() - self.get_all_images() + self.show_icon = icon + child = self.get_first_child() + while child: + child.queue_allocate() + child = child.get_next_sibling() - def set_reverse_order(self, rev): - """Config option: Reverse order of messages""" - if self.reverse_order != rev: - self.reverse_order = rev - self.set_needs_redraw() - - def set_font(self, font): - """Config option: Font used to render text""" - if self.text_font != font: - self.text_font = font - - self.pango_rect = Pango.Rectangle() - font = Pango.FontDescription(self.text_font) - self.pango_rect.width = font.get_size() * Pango.SCALE - self.pango_rect.height = font.get_size() * Pango.SCALE - self.set_needs_redraw() - - def recv_attach(self, identifier, pix): - """Callback from image_getter for attachments""" - self.icons[identifier] = pix - self.set_needs_redraw() - - def calc_all_height(self): - """Return the height in window-space pixels required - to draw this overlay with current dataset""" - h = 0 - my_list = self.content - if self.testing: - my_list = self.test_content - for line in my_list: - h += self.calc_height(line) - if h > 0: - h -= self.padding # Remove one unneeded padding - return h - - def calc_height(self, line): - """Return height in window-space pixels required to draw individual notification""" - icon_width = 0 - icon_pad = 0 - icon = line['icon'] - if self.show_icon and icon and icon in self.image_list and self.image_list[icon]: - icon_width = self.icon_size - icon_pad = self.icon_pad - message = "" - if 'body' in line and len(line['body']) > 0: - m_no_body = "%s\n%s" - message = m_no_body % (self.sanitize_string(line["title"]), - self.sanitize_string(line['body'])) - else: - m_with_body = "%s" - message = m_with_body % (self.sanitize_string(line["title"])) - layout = self.create_pango_layout(message) - layout.set_auto_dir(True) - layout.set_markup(message, -1) - (_floating_x, _floating_y, floating_width, - _floating_height) = self.get_floating_coords() - width = self.limit_width if floating_width > self.limit_width else floating_width - layout.set_width((Pango.SCALE * (width - - (self.border_radius * 4 + icon_width + icon_pad)))) - layout.set_spacing(Pango.SCALE * 3) - if self.text_font: - font = Pango.FontDescription(self.text_font) - layout.set_font_description(font) - _text_width, text_height = layout.get_pixel_size() - if text_height < icon_width: - text_height = icon_width - return text_height + (self.border_radius*4) + self.padding - - def has_content(self): + def should_show(self): """Return true if this overlay has meaningful content to show""" - if not self.enabled: - return False - if self.hidden: - return False - if self.testing: - return self.test_content - return self.content - - def overlay_draw(self, w, context, data=None): - """Draw the overlay""" - if self.piggyback: - self.piggyback.overlay_draw(w, context, data) - if not self.enabled: - return - self.context = context - (_width, height) = self.get_size() - if not self.piggyback_parent: - context.set_antialias(cairo.ANTIALIAS_GOOD) - - # Make background transparent - context.set_source_rgba(0.0, 0.0, 0.0, 0.0) - context.set_operator(cairo.OPERATOR_SOURCE) - context.paint() - - self.tick() - context.save() - if self.is_wayland or self.piggyback_parent or self.discover.steamos: - # Special case! - # The window is full-screen regardless of what the user has selected. - # We need to set a clip and a transform to imitate original behaviour - # Used in wlroots & gamescope - (floating_x, floating_y, floating_width, - floating_height) = self.get_floating_coords() - if self.floating: - context.new_path() - context.translate(floating_x, floating_y) - context.rectangle(0, 0, floating_width, floating_height) - context.clip() - - current_y = height - if self.align_vert == 0: - current_y = 0 - if self.align_vert == 1: # Center. Oh god why - current_y = (height/2.0) - (self.calc_all_height() / 2.0) - if self.testing: - the_list = self.test_content - else: - the_list = self.content - if self.reverse_order: - the_list = reversed(the_list) - for line in the_list: - col = "#fff" - if 'body' in line and len(line['body']) > 0: - m_no_body = "%s\n%s" - message = m_no_body % (self.sanitize_string(col), - self.sanitize_string(line["title"]), - self.sanitize_string(line['body'])) - else: - m_with_body = "%s" - message = m_with_body % (self.sanitize_string(col), - self.sanitize_string(line["title"])) - - icon = None - # If we've got an embedded image - if "icon_surface" in line and line["icon_surface"]: - icon = line["icon_surface"] - # If we're given an icon name, it's in the list of icons, and it's not none - elif line["icon"] and line["icon"] in self.image_list and self.image_list[line["icon"]]: - icon = self.image_list[line["icon"]] - - current_y = self.draw_text(current_y, message, icon) - if current_y <= 0: - # We've done enough - break - context.restore() - self.context = None - - def draw_text(self, pos_y, text, icon): - """Draw a text message, returning the Y position of the next message""" - icon_width = self.icon_size - icon_pad = self.icon_pad - if not self.show_icon: - icon = None - if not icon: - icon_pad = 0 - icon_width = 0 - - layout = self.create_pango_layout(text) - layout.set_auto_dir(True) - layout.set_markup(text, -1) - attr = layout.get_attributes() - - (_floating_x, _floating_y, floating_width, - _floating_height) = self.get_floating_coords() - width = self.limit_width if floating_width > self.limit_width else floating_width - layout.set_width((Pango.SCALE * (width - - (self.border_radius * 4 + icon_width + icon_pad)))) - layout.set_spacing(Pango.SCALE * 3) - if self.text_font: - font = Pango.FontDescription(self.text_font) - layout.set_font_description(font) - text_width, text_height = layout.get_pixel_size() - self.col(self.bg_col) - top = 0 - if self.align_vert == 2: # Bottom align - top = pos_y - (text_height + self.border_radius * 4) - else: # Top align - top = pos_y - if text_height < icon_width: - text_height = icon_width - shape_height = text_height + self.border_radius * 4 - shape_width = text_width + self.border_radius*4 + icon_width + icon_pad - - left = 0 - if self.align_right: - left = floating_width - shape_width - - self.context.save() - # Draw Background - self.context.translate(left, top) - # self.context.rectangle(self.border_radius, 0, - # shape_width - (self.border_radius*2), shape_height) - # self.context.fill() - # self.context.rectangle(0, self.border_radius, - # shape_width, shape_height - (self.border_radius * 2)) - - # self.context.arc(0.7, 0.3, 0.035, 1.25 * math.pi, 2.25 * math.pi) - # self.context.arc(0.3, 0.7, 0.035, .25 * math.pi, 1.25 * math.pi) - if self.border_radius == 0: - - self.context.move_to(0.0, 0.0) - self.context.line_to(shape_width, 0.0) - self.context.line_to(shape_width, shape_height) - self.context.line_to(0.0, shape_height) - self.context.close_path() - self.context.fill() - else: - # Edge top - self.context.move_to(self.border_radius, 0.0) - self.context.line_to(shape_width - self.border_radius, 0.0) - - # Arc topright - self.context.arc(shape_width - self.border_radius, self.border_radius, - self.border_radius, 1.5 * math.pi, 2 * math.pi) - - # Edge right - self.context.line_to( - shape_width, shape_height - self.border_radius) - - # Arc bottomright - self.context.arc(shape_width - self.border_radius, shape_height - self.border_radius, - self.border_radius, 0.0, 0.5 * math.pi) - - # Edge bottom - self.context.line_to(self.border_radius, shape_height) - - # Arch bottomleft - self.context.arc(self.border_radius, shape_height - - self.border_radius, self.border_radius, 0.5 * math.pi, math.pi) - - # Edge left - self.context.line_to(0.0, self.border_radius) - - # Arc topleft - self.context.arc(self.border_radius, self.border_radius, - self.border_radius, math.pi, 1.5 * math.pi) - - # End - self.context.close_path() - self.context.fill() - - self.context.set_operator(cairo.OPERATOR_OVER) - # Draw Image - if icon: - self.context.save() - if self.icon_left: - self.context.translate( - self.border_radius*2, self.border_radius*2) - draw_img_to_rect(icon, self.context, 0, 0, - icon_width, icon_width) - else: - self.context.translate( - self.border_radius*2 + text_width + icon_pad, self.border_radius*2) - draw_img_to_rect(icon, self.context, 0, 0, - icon_width, icon_width) - self.context.restore() - - self.col(self.fg_col) - - if self.icon_left: - self.context.translate( - self.border_radius*2 + icon_width + icon_pad, self.border_radius*2) - PangoCairo.context_set_shape_renderer( - self.get_pango_context(), self.render_custom, None) - - text = layout.get_text() - - layout.set_attributes(attr) - - PangoCairo.show_layout(self.context, layout) - else: - self.context.translate(self.border_radius*2, self.border_radius*2) - PangoCairo.context_set_shape_renderer( - self.get_pango_context(), self.render_custom, None) - - text = layout.get_text() - - layout.set_attributes(attr) - - PangoCairo.show_layout(self.context, layout) - - self.context.restore() - next_y = 0 - if self.align_vert == 2: - next_y = pos_y - (shape_height + self.padding) - else: - next_y = pos_y + shape_height + self.padding - return next_y - - def render_custom(self, ctx, shape, path, _data): - """Draw an inline image as a custom emoticon""" - if shape.data >= len(self.image_list): - log.warning("%s >= %s", shape.data, len(self.image_list)) - return - # key is the url to the image - key = self.image_list[shape.data] - if key not in self.icons: - get_surface(self.recv_attach, - key, - key, None) - return - pix = self.icons[key] - (pos_x, pos_y) = ctx.get_current_point() - draw_img_to_rect(pix, ctx, pos_x, pos_y - self.text_size, self.text_size, - self.text_size, path=path) - return True - - def sanitize_string(self, string): - """Sanitize a text message so that it doesn't intefere with Pango's XML format""" - string = string.replace("&", "&") - string = string.replace("<", "<") - string = string .replace(">", ">") - string = string.replace("'", "'") - string = string.replace("\"", """) - return string - - def set_testing(self, testing): - """Toggle placeholder images for testing""" - self.testing = testing - self.set_needs_redraw() - self.get_all_images() + if self.get_first_child() is not None: + return True + return False + + def show_testing(self): + """Pop up test notifications""" + for test in self.test_content: + self.add_notification_message(test) + + def set_text_align(self, text_align): + """Config option: Set text justification""" + self.text_align = text_align + self.update_all() + + def update_all(self): + """Call update on all children""" + child = self.get_first_child() + while child: + child.update() + child = child.get_next_sibling() + + def set_config(self, config): + """Read in config section and set self and children accordingly""" + font = config.get("font", fallback=None) + self.align_x = get_h_align(config.get("align_x", "right")) + self.align_y = get_v_align(config.get("align_y", "top")) + self.set_bg(json.loads(config.get("bg_col", fallback="[0.0,0.0,0.0,0.5]"))) + self.set_fg(json.loads(config.get("fg_col", fallback="[1.0,1.0,1.0,1.0]"))) + self.set_text_time(config.getint("text_time", fallback=10)) + self.set_show_icon(config.getboolean("show_icon", fallback=True)) + self.set_limit_width(config.getint("limit_width", fallback=400)) + self.set_icon_left(config.getboolean("icon_left", fallback=True)) + self.set_icon_size(config.getint("icon_size", fallback=32)) + self.set_padding(config.getint("padding", fallback=8)) + self.set_icon_padding(config.getint("icon_padding", fallback=8)) + self.set_border_radius(config.getint("border_radius", fallback=8)) + self.set_text_align(get_h_align(config.get("text_align", fallback="left"))) + + show_dummy = config.getboolean("show_dummy", fallback=False) + if show_dummy: + self.show_testing() + self.discover.config_set("notification", "show_dummy", "False") + + if font: + self.set_font(font) + + def set_css(self, css_id, rule): + """Add or replace custom css rules""" + self.get_root().set_css(css_id, rule) + + def get_align(self): + """Get the alignment of this overlay. Used by amalgamation mode""" + return (self.align_x, self.align_y) + + def get_boxes(self): + """Return a list of cairo.RectangleInt which are the bounding boxes of widgets in this view""" + boxes = [] + child = self.get_first_child() + while child: + box = child.get_allocation() + # pylint: disable=E1101 + region = cairo.RectangleInt( + x=box.x, y=box.y, width=box.width, height=box.height + ) + boxes.append(region) + child = child.get_next_sibling() + return boxes diff --git a/discover_overlay/overlay.py b/discover_overlay/overlay.py index 58e2ac5..35b5576 100644 --- a/discover_overlay/overlay.py +++ b/discover_overlay/overlay.py @@ -14,21 +14,23 @@ Overlay parent class. Helpful if we need more overlay types without copy-and-pasting too much code """ -import sys -import os +from ctypes import CDLL + +CDLL("libgtk4-layer-shell.so") import logging import gi import cairo from Xlib.display import Display from Xlib import X, Xatom -gi.require_version("Gtk", "3.0") -# pylint: disable=wrong-import-position,wrong-import-order -from gi.repository import Gtk, Gdk, GLib # nopep8 -try: - gi.require_version('GtkLayerShell', '0.1') - from gi.repository import GtkLayerShell -except (ImportError, ValueError): - pass +from ewmh import EWMH +from .css_helper import font_string_to_css_font_string +from .layout import AmalgamationLayout, HorzAlign, VertAlign, get_h_align, get_v_align + +gi.require_version("Gtk", "4.0") +gi.require_version("GdkWayland", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") + +from gi.repository import Gtk, GLib, GdkX11, GdkWayland, Gtk4LayerShell log = logging.getLogger(__name__) @@ -39,113 +41,153 @@ class OverlayWindow(Gtk.Window): types without copy-and-pasting too much code """ - def detect_type(self): - """ - Helper function to determine if Wayland is being used and return the Window type needed - """ - window = Gtk.Window() - screen = window.get_screen() - screen_type = f"{screen}" - self.is_wayland = False - if "Wayland" in screen_type: - self.is_wayland = True - return Gtk.WindowType.TOPLEVEL - return Gtk.WindowType.POPUP - - def __init__(self, discover, piggyback=None): - Gtk.Window.__init__(self, type=self.detect_type()) + def __init__(self, discover): + Gtk.Window.__init__(self) + self.css_prov = {} + + self.set_css( + "transparent_background", "window { background-color: rgba(0,0,0,0.0); }" + ) self.is_xatom_set = False + self.widget = None + self.amalgamation = None + self.discover = discover - screen = self.get_screen() - self.text_font = None - self.text_size = None self.pos_x = None self.pos_y = None self.width = None self.height = None self.hidden = False self.enabled = False - self.set_size_request(50, 50) + self.width_limit = -1 + self.height_limit = -1 self.hide_on_mouseover = True - self.connect('draw', self.overlay_draw_pre) - # Set RGBA - screen = self.get_screen() - visual = screen.get_rgba_visual() if not self.get_display().supports_input_shapes(): - log.info( - "Input shapes not available. Quitting") + log.info("Input shapes not available. Quitting") self.discover.exit() - if visual: - # Set the visual even if we can't use it right now - self.set_visual(visual) - self.set_app_paintable(True) - self.set_untouchable() - self.set_skip_pager_hint(True) - self.set_skip_taskbar_hint(True) - self.set_keep_above(True) - self.set_decorated(True) - self.set_accept_focus(False) - self.set_wayland_state() - self.piggyback = None - self.piggyback_parent = None - if not piggyback: - self.show_all() - if discover.steamos: - self.set_gamescope_xatom(1) + self.horzalign = HorzAlign.LEFT + self.vertalign = VertAlign.TOP self.monitor = "Any" - self.align_right = True - self.align_vert = 1 - self.floating = False - self.force_xshape = False self.context = None self.redraw_id = None - self.draw_blank = False self.timeout_mouse_over = 1 self.timer_after_draw = None - if piggyback: - self.set_piggyback(piggyback) - - self.get_screen().connect("composited-changed", self.check_composite) - self.get_screen().connect("monitors-changed", self.screen_changed) - self.get_screen().connect("size-changed", self.screen_changed) - if self.get_window(): - self.get_window().set_events(self.get_window().get_events() - | Gdk.EventMask.ENTER_NOTIFY_MASK) - self.connect("enter-notify-event", self.mouseover) - self.connect("leave-notify-event", self.mouseout) + + self.get_display().connect("setting-changed", self.screen_changed) + + self.get_display().get_monitors().connect("items-changed", self.screen_changed) + + self.motion_gesture = Gtk.EventControllerMotion() + self.motion_gesture.connect("enter", self.mouseover) + self.motion_gesture.connect("leave", self.mouseout) + self.add_controller(self.motion_gesture) + self.mouse_over_timer = None # It shouldn't be possible, but let's not leave # this process hanging if it happens - self.connect('destroy', self.window_exited) + self.connect("destroy", self.window_exited) + self.connect("map", self.mapped) + + def mapped(self, _a=None): + """Called when window is shown""" + # When we resize, set untouchable + self.get_surface().connect("layout", self.set_untouchable) + # Right now, set untouchable + self.set_untouchable() + self.force_location() + self.get_root().set_visibility() + + + def remove_css(self, cssid): + """Removes a CSS Rule by id""" + if id in self.css_prov: + self.get_style_context().remove_provider(self.css_prov[id]) + del self.css_prov[cssid] + + def set_css(self, cssid, rules): + """Create or update a CSS rule by id""" + if id not in self.css_prov: + + # pylint: disable=E1120 + css = Gtk.CssProvider.new() + # log.info("Adding rule : %s", rules) + css.load_from_data(bytes(rules, "utf-8")) + self.get_style_context().add_provider_for_display( + self.get_display(), css, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) + self.css_prov[cssid] = css + else: + log.info("Updating rule : %s", rules) + self.css_prov[cssid].load_from_data(bytes(rules, "utf-8")) def window_exited(self, _window=None): """Window closed. Exit app""" self.discover.exit() - def set_gamescope_xatom(self, enabled): - """Set Gamescope XAtom to identify self as an overlay candidate""" - if self.piggyback_parent: + def set_x11_window_location(self, x, y, w, h): + """Set Window location using X11""" + if not self.enabled: return + if isinstance(self.get_surface(), GdkX11.X11Surface): + display = Display() + topw = display.create_resource_object( + "window", self.get_surface().get_xid() + ) + if x is None: + topw.configure(x=0, y=0, w=100, h=100) + display.flush() + display.sync() + return + + align_x = align_y = 0 + if self.amalgamation: + window_w = int(w) + window_h = int(h) + elif self.widget: + (window_x, window_y) = self.widget.get_align() + data = topw.get_geometry() # Use X11 sizes to account for render scale + window_w = data.width + window_h = data.height + if window_x == HorzAlign.MIDDLE: + align_x = (w / 2) - int(window_w / 2) + elif window_x == HorzAlign.RIGHT: + align_x = w - window_w + if window_y == VertAlign.MIDDLE: + align_y = (h / 2) - int(window_h / 2) + elif window_y == VertAlign.BOTTOM: + align_y = h - window_h + + topw.configure( + x=int(x + align_x), y=int(y + align_y), w=int(window_w), h=int(window_h) + ) + screen = display.screen() + ewmh = EWMH(display, screen.root) + ewmh.setWmState(topw, 1, "_NET_WM_STATE_ABOVE") + display.flush() + display.sync() + else: + log.warning("Unable to set X11 location") + + def set_gamescope_state(self, enabled): + """Set Gamescope XAtom to identify self as an overlay candidate""" if enabled == self.is_xatom_set: return self.is_xatom_set = enabled display = Display() atom = display.intern_atom("GAMESCOPE_EXTERNAL_OVERLAY") - # Since unused: _NET_WM_WINDOW_OPACITY - - if self.get_toplevel().get_window(): + # pylint: disable=E1101 + if isinstance(self.get_surface(), GdkX11.X11Surface): topw = display.create_resource_object( - "window", self.get_toplevel().get_window().get_xid()) + "window", self.get_surface().get_xid() + ) - topw.change_property(atom, - Xatom.CARDINAL, 32, - [enabled], X.PropModeReplace) + topw.change_property(atom, Xatom.CARDINAL, 32, [enabled], X.PropModeReplace) log.info("Setting GAMESCOPE_EXTERNAL_OVERLAY to %s", enabled) display.sync() else: @@ -153,222 +195,124 @@ def set_gamescope_xatom(self, enabled): def set_wayland_state(self): """ - If wayland is in use then attempt to set up a GtkLayerShell + If wayland is in use then attempt to set up a Gtk4LayerShell """ - if self.is_wayland: - if not GtkLayerShell.is_supported(): - log.warn("GTK Layer Shell is not supported on this Wayland compositor. Falling back to X11...") - os.environ["GDK_BACKEND"] = "x11" - os.execv(sys.argv[0], sys.argv) - if not GtkLayerShell.is_layer_window(self): - GtkLayerShell.init_for_window(self) - GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY) - GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.LEFT, True) - GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.RIGHT, True) - GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.BOTTOM, True) - GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.TOP, True) - - def set_piggyback(self, other_overlay): - """Sets as piggybacking off the given (other) overlay""" - other_overlay.piggyback = self - self.piggyback_parent = other_overlay + # pylint: disable=E1120 + if not Gtk4LayerShell.is_supported(): + log.error("Desktop session has no LayerShell support. Exiting") + self.discover.exit() + return + if not Gtk4LayerShell.is_layer_window(self): + Gtk4LayerShell.init_for_window(self) + Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.OVERLAY) + if not isinstance(self.horzalign, HorzAlign): + log.error("Invalid y align : %s", self.horzalign) + if not isinstance(self.vertalign, VertAlign): + log.error("Invalid x align : %s", self.vertalign) + Gtk4LayerShell.set_anchor( + self, Gtk4LayerShell.Edge.LEFT, self.horzalign == HorzAlign.LEFT + ) + Gtk4LayerShell.set_anchor( + self, Gtk4LayerShell.Edge.RIGHT, self.horzalign == HorzAlign.RIGHT + ) + Gtk4LayerShell.set_anchor( + self, Gtk4LayerShell.Edge.BOTTOM, self.vertalign == VertAlign.BOTTOM + ) + Gtk4LayerShell.set_anchor( + self, Gtk4LayerShell.Edge.TOP, self.vertalign == VertAlign.TOP + ) def has_content(self): """Return true if overlay has meaningful content""" return False - def overlay_draw_pre(self, _w, context, data=None): - """Prepare for drawing the overlay. Calls overlay_draw after preparations""" - content = self.has_content() - if self.piggyback and self.piggyback.has_content(): - content = True - if self.discover.steamos: - if not content: - self.set_gamescope_xatom(0) - else: - if not self.hidden and self.enabled: - self.set_gamescope_xatom(1) - # If we're hiding on mouseover, allow mouse-in - if self.hide_on_mouseover: - # We've mouse-overed - if self.draw_blank: - return - else: - (width, height) = self.get_size() - surface = cairo.ImageSurface( - cairo.FORMAT_ARGB32, width, height) - surface_ctx = cairo.Context(surface) - self.overlay_draw(None, surface_ctx) - reg = Gdk.cairo_region_create_from_surface(surface) - self.input_shape_combine_region(reg) - - self.overlay_draw(_w, context, data) - - def overlay_draw(self, _w, context, data=None): - """ - Draw overlay - """ - def set_font(self, font): """ Set the font used by the overlay """ - if self.text_font != font: - self.text_font = font - self.set_needs_redraw() + self.set_css("font", "* { font: %s; }" % (font_string_to_css_font_string(font))) - def set_floating(self, floating, pos_x, pos_y, width, height): - """ - Set if the window is floating and what dimensions to use - """ - if width > 1.0 and height > 1.0: - # Old data. - (_screen_x, _screen_y, screen_width, - screen_height) = self.get_display_coords() - pos_x = float(pos_x) / screen_width - pos_y = float(pos_y) / screen_height - width = float(width) / screen_width - height = float(height) / screen_height - - if (self.floating != floating or self.pos_x != pos_x or - self.pos_y != pos_y or self.width != width or self.height != height): - - self.floating = floating - self.pos_x = pos_x - self.pos_y = pos_y - self.width = width - self.height = height - self.force_location() - - def set_untouchable(self): + def set_untouchable( + self, _a=None, _b=None, _c=None + ): # Throw away args to allow size_allocate """ Create a custom input shape and tell it that all of the window is a cut-out This allows us to have a window above everything but that never gets clicked on + + If we want to collect mouse-in events to hide the window when mouse goes over it, + we need to add shapes, not an empty region """ - (width, height) = self.get_size() - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - surface_ctx = cairo.Context(surface) - surface_ctx.set_source_rgba(0.0, 0.0, 0.0, 0.0) - surface_ctx.set_operator(cairo.OPERATOR_SOURCE) - surface_ctx.paint() - reg = Gdk.cairo_region_create_from_surface(surface) - self.input_shape_combine_region(reg) + surface = self.get_surface() + display = self.get_display() + if surface: + # pylint: disable=E1101 + bb_region = cairo.Region() + if not display.is_composited() or self.hide_on_mouseover: + boxes = self.get_boxes() + bb_region = cairo.Region(boxes) + + surface.set_input_region(bb_region) + + def get_boxes(self): + """Get a collection of bounding boxes from widget(s)""" + if self.widget: + return self.widget.get_boxes() + elif self.amalgamation: + boxes = [] + for widget in self.amalgamation: + boxes += widget.get_boxes() + return boxes + raise RuntimeError("Get boxes on empty overlay") def set_hide_on_mouseover(self, hide): """Set if the overlay should hide when mouse moves over it""" if self.hide_on_mouseover != hide: self.hide_on_mouseover = hide - if self.hide_on_mouseover: - self.set_needs_redraw() - else: - self.set_untouchable() + self.set_untouchable() def set_mouseover_timer(self, time): """Set the time until the overlay reappears after mouse over""" self.timeout_mouse_over = time - def unset_shape(self): - """ - Remove XShape (not input shape) - """ - if self.get_window(): - self.get_window().shape_combine_region(None, 0, 0) - def force_location(self): """ On X11 enforce the location and sane defaults On Wayland just store for later On Gamescope enforce size of display but only if it's the primary overlay """ - if self.discover.steamos and not self.piggyback_parent: - (floating_x, floating_y, floating_width, - floating_height) = self.get_floating_coords() - self.resize(floating_width, floating_height) - self.set_needs_redraw() + (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords() + self.set_decorated(False) + self.set_can_focus(False) + # chosen_width = self.width_limit if self.width_limit > 0 else screen_width/4 + # chosen_height = self.height_limit if self.height_limit > 0 else screen_height/4 + # self.set_size_request(chosen_width, chosen_height) + surface = self.get_surface() + if not surface: return - if not self.is_wayland: - self.set_decorated(False) - self.set_keep_above(True) - - (floating_x, floating_y, floating_width, - floating_height) = self.get_floating_coords() - self.resize(floating_width, floating_height) - self.move(floating_x, floating_y) - - self.set_needs_redraw() + elif isinstance(surface, GdkWayland.WaylandSurface): + self.set_wayland_state() + elif isinstance(surface, GdkX11.X11Surface): + surface.set_skip_pager_hint(True) + surface.set_skip_taskbar_hint(True) + self.set_x11_window_location( + screen_x, screen_y, screen_width, screen_height + ) + else: + log.error("Unknown windowing system. %s, Exiting", surface) + self.discover.exit() + self.set_untouchable() def get_display_coords(self): """Get screen space co-ordinates of the monitor""" - if self.piggyback_parent: - return self.piggyback_parent.get_display_coords() monitor = self.get_monitor_from_plug() if not monitor: - monitor = self.get_display().get_monitor(0) + monitor = self.get_display().get_monitors()[0] if monitor: geometry = monitor.get_geometry() return (geometry.x, geometry.y, geometry.width, geometry.height) log.error("No monitor found! This is going to go badly") return (0, 0, 1920, 1080) - def get_floating_coords(self): - """Get screen space co-ordinates of the window""" - (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords() - if self.floating: - if (self.pos_x is None or self.pos_y is None or - self.width is None or self.height is None): - log.error("No usable floating position") - - if not self.is_wayland: - return (screen_x + self.pos_x * screen_width, screen_y + self.pos_y * screen_height, - self.width * screen_width, self.height * screen_height) - return (self.pos_x * screen_width, self.pos_y * screen_height, - self.width * screen_width, self.height * screen_height) - else: - return (screen_x, screen_y, screen_width, screen_height) - - def set_needs_redraw(self, be_pushy=False): - """Schedule this overlay for a redraw. If part of a - piggyback chain, pass it up to be redrawn by topmost parent""" - if (not self.hidden and self.enabled) or be_pushy: - if self.piggyback_parent: - self.piggyback_parent.set_needs_redraw(be_pushy=True) - - if self.redraw_id is None: - self.redraw_id = GLib.idle_add(self.redraw) - else: - log.debug("Already awaiting paint") - - # If this overlay has data that expires after draw, plan for that here - if self.timer_after_draw is not None: - GLib.timeout_add_seconds(self.timer_after_draw, self.redraw) - - def redraw(self): - """ - Request a redraw. - If we're using XShape (optionally or forcibly) then render the image into the shape - so that we only cut out clear sections - """ - self.redraw_id = None - gdkwin = self.get_window() - if self.piggyback_parent: - self.piggyback_parent.redraw() - return - if gdkwin: - compositing = self.get_screen().is_composited() - if not compositing or self.force_xshape: - (width, height) = self.get_size() - surface = cairo.ImageSurface( - cairo.FORMAT_ARGB32, width, height) - surface_ctx = cairo.Context(surface) - self.overlay_draw(None, surface_ctx) - reg = Gdk.cairo_region_create_from_surface(surface) - gdkwin.shape_combine_region(reg, 0, 0) - else: - gdkwin.shape_combine_region(None, 0, 0) - self.queue_draw() - self.redraw_id = None - return False - def set_hidden(self, hidden): """Set if the overlay should be hidden""" self.hidden = hidden @@ -381,115 +325,160 @@ def set_monitor(self, idx=None): plug_name = f"{idx}" if self.monitor != plug_name: self.monitor = plug_name - if self.is_wayland: + if isinstance(self.get_surface(), GdkWayland.WaylandSurface): monitor = self.get_monitor_from_plug() if monitor: - GtkLayerShell.set_monitor(self, monitor) + Gtk4LayerShell.set_monitor(self, monitor) else: self.hide() self.set_wayland_state() - self.show() + if self.has_content(): + self.show() self.set_untouchable() self.force_location() - self.set_needs_redraw() def get_monitor_from_plug(self): """Return a GDK Monitor filtered by plug name - (HDMI-1, eDP-1, VGA etc)""" + (HDMI-1, eDP-1, VGA etc)""" if not self.monitor or self.monitor == "Any": return None - display = Gdk.Display.get_default() - if not "get_n_monitors" in dir(display) or not "get_monitor" in dir(display): - return None - screen = self.get_screen() - count_monitors = display.get_n_monitors() - if count_monitors >= 1: - for i in range(0, count_monitors): - this_mon = display.get_monitor(i) - connector = screen.get_monitor_plug_name(i) - if connector == self.monitor: - return this_mon - return None - def set_align_x(self, align_right): - """ - Set the alignment (True for right, False for left) - """ - if self.align_right != align_right: - self.align_right = align_right - self.force_location() - self.set_needs_redraw() + # pylint: disable=E1120 + display = self.get_display() + monitors = display.get_monitors() + for monitor in monitors: + if self.monitor == monitor.get_connector(): + return monitor + log.warning("Unable to find monitor for : %s : Using Any", self.monitor) + return None - def set_align_y(self, align_vert): + def set_align_x(self, align: HorzAlign): """ - Set the veritcal alignment + Set the alignment """ - if self.align_vert != align_vert: - self.align_vert = align_vert - self.force_location() - self.set_needs_redraw() + if not isinstance(align, HorzAlign): + log.error("Unable to set Align X %s", align) + return - def col(self, col, alpha=1.0): - """ - Convenience function to set the cairo context next colour - """ - self.context.set_source_rgba(col[0], col[1], col[2], col[3] * alpha) + self.horzalign = align + self.force_location() - def set_force_xshape(self, force): + def set_align_y(self, align: VertAlign): """ - Set if XShape should be forced + Set the veritcal alignment """ - self.force_xshape = force + if not isinstance(align, VertAlign): + log.error("Unable to set Align Y %s", align) + return - if self.is_wayland or self.discover.steamos: - # Wayland and XShape are a bad idea unless you're a fan on artifacts - self.force_xshape = False + self.vertalign = align + self.force_location() def set_enabled(self, enabled): """ Set if this overlay should be visible """ self.enabled = enabled - if self.piggyback_parent or self.piggyback: - self.set_needs_redraw() - - if not self.piggyback_parent: - self.set_gamescope_xatom(1 if enabled else 0) - return + if self.discover.steamos: + self.set_gamescope_state(1 if enabled else 0) if enabled and not self.hidden: - self.show_all() - self.set_untouchable() + self.present() + self.show() else: self.hide() - def set_task(self, visible): - """Set visible on taskbar. Not working at last check""" - self.set_skip_pager_hint(not visible) - self.set_skip_taskbar_hint(not visible) - - def check_composite(self, _a=None, _b=None): - """Callback for compositing started/stopped in X11""" - self.redraw() - - def screen_changed(self, _screen=None): + def screen_changed(self, _screen=None, _a=None, _b=None, _c=None): """Callback to set monitor to display on""" self.set_monitor(self.monitor) - def mouseover(self, _a=None, _b=None): + def mouseover(self, _a=None, _b=None, _c=None): """Callback when mouseover occurs, hides overlay""" - self.draw_blank = True - self.set_needs_redraw() self.hide() GLib.timeout_add_seconds(self.timeout_mouse_over, self.mouseout_timed) return True - def mouseout(self, _a=None, _b=None): + def mouseout(self, _a=None, _b=None, _c=None): """Callback when mouseout occurs, sets a timer to show overlay""" return True def mouseout_timed(self, _a=None, _b=None): """Callback a short while after mouseout occured, shows overlay""" - self.draw_blank = False self.show() - self.set_needs_redraw() + return False + + def set_config(self, config): + """Set the configuration of this overlay from the given config section""" + # Set Voice overlay options + x_align = get_h_align(config.get("x_align", fallback="none")) + if x_align is None: + right_align = config.getboolean("rightalign", fallback=True) + if right_align: + x_align = HorzAlign.RIGHT + else: + x_align = HorzAlign.LEFT + self.set_align_x(x_align) + + y_align = get_v_align(config.get("y_align", fallback="none")) + if y_align is None: + top_align = config.getint("topalign", fallback=1) + if top_align == 0: + y_align = VertAlign.TOP + elif top_align == 1: + y_align = VertAlign.MIDDLE + else: + y_align = VertAlign.BOTTOM + self.set_align_y(y_align) + + self.set_monitor(config.get("monitor", fallback="Any")) + + self.set_hide_on_mouseover(config.getboolean("autohide", fallback=False)) + self.set_mouseover_timer(config.getint("autohide_timer", fallback=5)) + self.set_enabled(config.getboolean("enabled", fallback=True)) + + self.set_visibility() + + def overlay(self, widget): + """Add this widget as the overlay. Must only be used once per overlay, and may not be used if `merged_overlay` has been used""" + if self.widget or self.amalgamation: + raise RuntimeError("Overlay window may only be set up once") + self.widget = widget + self.set_child(widget) + + def merged_overlay(self, widget_list): + """Add a collection of widgets to the overlay. Must only be used once per overlay, and may not be used if `overlay` has been used""" + if self.widget or self.amalgamation: + raise RuntimeError("Overlay window may only be set up once") + self.amalgamation = widget_list + box = Gtk.Box() + for widget in self.amalgamation: + box.append(widget) + self.set_child(box) + box.set_layout_manager(AmalgamationLayout()) + + # We won't receive config in this mode + box.show() + self.set_enabled(True) + + def set_visibility(self): + """Called by internal widget to state their own `should_show` may have changed value""" + if not self.enabled: + return + if self.should_show(): + self.show() + self.set_untouchable() # Bounding boxes probably moved! + else: + self.hide() + + def should_show(self): + """Should this show? Returns true if the overlay should be shown to user""" + if not self.enabled: + return False + if self.hidden: + return False + if self.widget and self.widget.should_show(): + return True + if self.amalgamation: + for widget in self.amalgamation: + if widget.should_show(): + return True return False diff --git a/discover_overlay/settings_window.py b/discover_overlay/settings_window.py index 640b7a6..5efd023 100644 --- a/discover_overlay/settings_window.py +++ b/discover_overlay/settings_window.py @@ -10,65 +10,58 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# pylint: disable=C0116 """Settings window holding all settings tab""" -# pylint: disable=missing-function-docstring + import gettext import logging import sys import os import json +import importlib_resources from configparser import ConfigParser import gi -import pkg_resources -from .autostart import Autostart, BazziteAutostart -from .draggable_window import DraggableWindow -from .draggable_window_wayland import DraggableWindowWayland +from .autostart import Autostart +from .overlay import get_h_align, get_v_align +from _version import __version__ + +gi.require_version("Gtk", "4.0") -gi.require_version("Gtk", "3.0") -# pylint: disable=wrong-import-position,wrong-import-order -from gi.repository import Gtk, Gdk, Gio # nopep8 +from gi.repository import Gtk, Gdk, Gio +logging.basicConfig(stream=sys.stdout) log = logging.getLogger(__name__) -t = gettext.translation( - 'default', pkg_resources.resource_filename('discover_overlay', 'locales'), fallback=True) +with importlib_resources.as_file( + importlib_resources.files("discover_overlay") / "locales" +) as path: + t = gettext.translation( + "default", + path, + fallback=True, + ) + _ = t.gettext _ = t.gettext -class MainSettingsWindow(): +class Settings(Gtk.Application): """Settings class""" - def __init__(self, config_file, rpc_file, channel_file, args): + def __init__(self, a, b, config_file, rpc_file, channel_file, args): + super().__init__(application_id=a, flags=b) self.args = args - # Detect Bazzite autostart - self.alternative_autostart = os.path.exists( - "/etc/default/discover-overlay") - # Detect flatpak en - self.disable_autostart = 'container' in os.environ + self.config_file = config_file + self.rpc_file = rpc_file + self.channel_file = channel_file + + self.disable_autostart = "container" in os.environ self.icon_name = "discover-overlay" self.tray_icon_name = "discover-overlay-tray" - self.spinning_focus = None self.scale_focus = None - - icon_theme = Gtk.IconTheme.get_default() - icon_theme.add_resource_path(os.path.expanduser( - '~/.local/share/pipx/venvs/discover-overlay/share/icons')) - if not icon_theme.has_icon("discover-overlay"): - log.error("No icon found in theme") - self.icon_name = 'user-info' - if not icon_theme.has_icon(self.tray_icon_name): - log.error("No tray icon found in theme") - self.tray_icon_name = 'user-info' self.steamos = False self.voice_placement_window = None self.text_placement_window = None - self.tray = None # Systemtray as fallback - self.ind = None # AppIndicator - if self.alternative_autostart: - self.autostart_helper = BazziteAutostart() - else: - self.autostart_helper = Autostart("discover_overlay") - + self.autostart_helper = Autostart("discover_overlay") self.autostart_helper_conf = Autostart("discover_overlay_configure") self.ind = None self.guild_ids = [] @@ -76,34 +69,33 @@ def __init__(self, config_file, rpc_file, channel_file, args): self.current_guild = "0" self.current_channel = "0" self.hidden_overlay = False - self.voice_floating_x = 0 - self.voice_floating_y = 0 - self.voice_floating_w = 0 - self.voice_floating_h = 0 - self.text_floating_x = 0 - self.text_floating_y = 0 - self.text_floating_w = 0 - self.text_floating_h = 0 - - self.menu = self.make_menu() - self.make_sys_tray_icon(self.menu) - - self.config_file = config_file - self.rpc_file = rpc_file - self.channel_file = channel_file - self.loading_config = False + self.widget = {} + self.is_wayland = False + self.window = None + self.super_focus = None + self.server_handler = None + self.channel_handler = None + self.hidden_overlay_handler = None + self.monitor_channel = None + + def start(self, _x): + """Start the Settings Window""" + + builder = Gtk.Builder(self) + with importlib_resources.as_file( + importlib_resources.files("discover_overlay") / "glade/settings.xml" + ) as ui_path: + builder.add_from_file(ui_path.as_posix()) - builder = Gtk.Builder.new_from_file(pkg_resources.resource_filename( - 'discover_overlay', 'glade/settings.glade')) window = builder.get_object("settings_window") + self.add_window(window) window.connect("destroy", self.close_window) - window.connect("delete-event", self.close_window) + # window.connect("delete-event", self.close_window) window.set_default_size(1280, 800) # Make an array of all named widgets - self.widget = {} for widget in builder.get_objects(): if widget.find_property("name"): name = widget.get_property("name") @@ -120,31 +112,33 @@ def __init__(self, config_file, rpc_file, channel_file, args): if name.endswith("_all"): widget.set_label(_(widget.get_label())) - self.widget['overview_main_text'].set_markup( - "%s%s (%s)%s%s\n\n%s %s %s %s%s\n\n\n\n\n\n" % ( - "", + self.widget["overview_main_text"].set_markup( + "%s%s (%s)%s%s\n\n%s %s %s %s%s\n\n\n\n\n\n" + % ( + '', _("Welcome to Discover Overlay"), - pkg_resources.get_distribution('discover_overlay').version, + __version__, "\n\n", - _("Discover-Overlay is a GTK3 overlay written in Python3." - " It can be configured to show who is currently talking" - " on discord or it can be set to display text and images" - " from a preconfigured channel. It is fully customisable" - " and can be configured to display anywhere on the screen." - " We fully support X11 and wlroots based environments. We " - "felt the need to make this project due to the shortcomings" - " in support on Linux by the official discord client."), + _( + "Discover-Overlay is a GTK4 overlay written in Python3." + " It can be configured to show who is currently talking" + " on discord or it can be set to display text and images" + " from a preconfigured channel. It is fully customisable" + " and can be configured to display anywhere on the screen." + " We fully support X11 and wlroots based environments. We " + "felt the need to make this project due to the shortcomings" + " in support on Linux by the official discord client." + ), _("Please visit our discord"), - "(https://discord.gg/jRKWMuDy5V)", + '(https://discord.gg/jRKWMuDy5V)', _(" for support. Or open an issue on our GitHub "), - "(", - "https://github.com/trigg/Discover)" + '(', + "https://github.com/trigg/Discover)", ) ) - screen = window.get_screen() + screen = window.get_display() screen_type = f"{screen}" - self.is_wayland = False if "Wayland" in screen_type: self.is_wayland = True self.window = window @@ -152,73 +146,80 @@ def __init__(self, config_file, rpc_file, channel_file, args): if "GAMESCOPE_WAYLAND_DISPLAY" in os.environ: self.steamos = True log.info( - "GameScope session detected. Enabling steam and gamescope integration") - self.steamos = True + "GameScope session detected. Enabling steam and gamescope integration" + ) + # pylint: disable=E1120 settings = Gtk.Settings.get_default() if settings: - settings.set_property( - "gtk-application-prefer-dark-theme", Gtk.true) + settings.set_property("gtk-application-prefer-dark-theme", True) self.set_steamos_window_size() - # Larger fonts needed + # pylint: disable=E1120 css = Gtk.CssProvider.new() css.load_from_data(bytes("* { font-size:18px; }", "utf-8")) window.get_style_context().add_provider( - css, Gtk.STYLE_PROVIDER_PRIORITY_USER) + css, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) else: - self.widget['overview_close_button'].hide() + self.widget["overview_close_button"].hide() + # pylint: disable=E1120 self.super_focus = Gtk.CssProvider.new() self.super_focus.load_from_data( bytes( """scale { background-color: rgba(100%, 0%, 0%, 0.3); background-image:unset; } spinbutton { background-color: rgba(100%, 0%, 0%, 0.3); background-image:unset;} - """, "utf-8")) + """, + "utf-8", + ) + ) - # Fill monitor & guild menus self.populate_monitor_menus() - window.get_screen().connect("monitors-changed", self.populate_monitor_menus) + self.window.get_display().get_monitors().connect( + "items-changed", self.populate_monitor_menus + ) - channel_file = Gio.File.new_for_path(channel_file) + log.info(self.channel_file) + channel_file = Gio.File.new_for_path(self.channel_file) self.monitor_channel = channel_file.monitor_file(0, None) self.monitor_channel.connect("changed", self.populate_guild_menu) - self.server_handler = self.widget['text_server'].connect( - 'changed', self.text_server_changed) - self.channel_handler = self.widget['text_channel'].connect( - 'changed', self.text_channel_changed) - self.hidden_overlay_handler = self.widget['core_hide_overlay'].connect( - 'toggled', self.core_hide_overlay_changed) + self.server_handler = self.widget["text_server"].connect( + "changed", self.text_server_changed + ) + self.channel_handler = self.widget["text_channel"].connect( + "changed", self.text_channel_changed + ) + self.hidden_overlay_handler = self.widget["core_hide_overlay"].connect( + "toggled", self.core_hide_overlay_changed + ) self.read_config() - self.populate_guild_menu() + # TODO Re-fix gamepad support + # window.connect('key-press-event', self.keypress_in_settings) - builder.connect_signals(self) - window.connect('key-press-event', self.keypress_in_settings) + if self.icon_name != "discover-overlay": + self.widget["overview_image"].set_from_icon_name( + self.icon_name, Gtk.IconSize.DIALOG + ) + self.widget["window"].set_default_icon_name(self.icon_name) - if '--minimized' in self.args: - self.start_minimized = True - if not self.start_minimized or not self.show_sys_tray_icon: - window.show() + self.window.show() - if self.icon_name != 'discover-overlay': - self.widget['overview_image'].set_from_icon_name( - self.icon_name, Gtk.IconSize.DIALOG) - self.widget['window'].set_default_icon_name(self.icon_name) + self.populate_guild_menu() def set_steamos_window_size(self): """Set window based on steamos usage""" # Huge bunch of assumptions. # Gamescope only has one monitor # Gamescope has no scale factor - display = Gdk.Display.get_default() - if "get_monitor" in dir(display): - monitor = display.get_monitor(0) - if monitor: - geometry = monitor.get_geometry() - log.info("%d %d", geometry.width, geometry.height) - self.window.set_size_request(geometry.width, geometry.height) + + monitor = self.window.get_display().get_monitors().get_item(0) + if monitor: + geometry = monitor.get_geometry() + log.info("%d %d", geometry.width, geometry.height) + self.window.set_size_request(geometry.width, geometry.height) def keypress_in_settings(self, window, event): """Callback to steal keypresses to assist SteamOS gamepad control""" @@ -241,10 +242,14 @@ def keypress_in_settings(self, window, event): value = self.spinning_focus.get_value() self.spinning_focus.set_value(value - step) case Gdk.KEY_space: - self.spinning_focus.get_style_context().remove_provider(self.super_focus) + self.spinning_focus.get_style_context().remove_provider( + self.super_focus + ) self.spinning_focus = None case Gdk.KEY_Escape: - self.spinning_focus.get_style_context().remove_provider(self.super_focus) + self.spinning_focus.get_style_context().remove_provider( + self.super_focus + ) self.spinning_focus = None elif self.scale_focus: @@ -262,10 +267,14 @@ def keypress_in_settings(self, window, event): value = self.scale_focus.get_value() self.scale_focus.set_value(value - 0.1) case Gdk.KEY_space: - self.scale_focus.get_style_context().remove_provider(self.super_focus) + self.scale_focus.get_style_context().remove_provider( + self.super_focus + ) self.scale_focus = None case Gdk.KEY_Escape: - self.scale_focus.get_style_context().remove_provider(self.super_focus) + self.scale_focus.get_style_context().remove_provider( + self.super_focus + ) self.scale_focus = None else: match event.keyval: @@ -278,9 +287,9 @@ def keypress_in_settings(self, window, event): case Gdk.KEY_Down: window.do_move_focus(window, Gtk.DirectionType.DOWN) case Gdk.KEY_F1: - self.widget['notebook'].prev_page() + self.widget["notebook"].prev_page() case Gdk.KEY_F2: - self.widget['notebook'].next_page() + self.widget["notebook"].next_page() case Gdk.KEY_Escape: return True case Gdk.KEY_space: @@ -288,16 +297,20 @@ def keypress_in_settings(self, window, event): if widget: # I really want there to be a better way... widget_type = f"{widget}" - if 'Gtk.SpinButton' in widget_type: + if "Gtk.SpinButton" in widget_type: self.spinning_focus = widget widget.get_style_context().add_provider( - self.super_focus, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + self.super_focus, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) return True - elif 'Gtk.Scale' in widget_type: + elif "Gtk.Scale" in widget_type: self.scale_focus = widget widget.get_style_context().add_provider( - self.super_focus, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + self.super_focus, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) return True return False case _: @@ -306,14 +319,18 @@ def keypress_in_settings(self, window, event): def request_channels_from_guild(self, guild_id): """Send RPC to overlay to request updated channel list""" - with open(self.rpc_file, 'w', encoding="utf-8") as f: + with open(self.rpc_file, "w", encoding="utf-8") as f: f.write(f"--rpc --guild-request={guild_id}") - def populate_guild_menu(self, _a=None, _b=None, _c=None, _d=None): + def populate_guild_menu( + self, _file=None, _o_file=None, _event=None, _d=None, _e=None + ): """Read guild data and repopulate widget. - Disable signal handling meanwhile to avoid recursive logic""" - g = self.widget['text_server'] - c = self.widget['text_channel'] + Disable signal handling meanwhile to avoid recursive logic""" + log.info(_o_file) + log.info("Populating guild and channel") + g = self.widget["text_server"] + c = self.widget["text_channel"] g.handler_block(self.server_handler) c.handler_block(self.channel_handler) try: @@ -335,7 +352,7 @@ def populate_guild_menu(self, _a=None, _b=None, _c=None, _d=None): c.append_text(channel['name']) self.channel_ids.append(channel['id']) except FileNotFoundError: - pass + log.error("Could not open channels file") if self.current_guild != "0" and self.current_guild in self.guild_ids: g.set_active(self.guild_ids.index(self.current_guild)) @@ -346,11 +363,11 @@ def populate_guild_menu(self, _a=None, _b=None, _c=None, _d=None): g.handler_unblock(self.server_handler) c.handler_unblock(self.channel_handler) - def populate_monitor_menus(self, _a=None, _b=None): + def populate_monitor_menus(self, _a=None, _b=None, _c=None, _d=None): """Get Monitor list from GTK and repopulate widget""" - voice = self.widget['voice_monitor'] - text = self.widget['text_monitor'] - notify = self.widget['notification_monitor'] + voice = self.widget["voice_monitor"] + text = self.widget["text_monitor"] + notify = self.widget["notification_monitor"] v_value = voice.get_active() t_value = text.get_active() @@ -364,20 +381,14 @@ def populate_monitor_menus(self, _a=None, _b=None): text.append_text("Any") notify.append_text("Any") - display = Gdk.Display.get_default() - screen = self.window.get_screen() - if "get_n_monitors" in dir(display): - count_monitors = display.get_n_monitors() - if count_monitors >= 1: - for i in range(0, count_monitors): - this_mon = display.get_monitor(i) - manufacturer = this_mon.get_manufacturer() - model = this_mon.get_model() - connector = screen.get_monitor_plug_name(i) - monitor_label = f"{manufacturer} {model}\n{connector}" - voice.append_text(monitor_label) - text.append_text(monitor_label) - notify.append_text(monitor_label) + for monitor in self.window.get_display().get_monitors(): + manufacturer = monitor.get_manufacturer() + model = monitor.get_model() + connector = monitor.get_connector() + monitor_label = f"{manufacturer} {model}\n{connector}" + voice.append_text(monitor_label) + text.append_text(monitor_label) + notify.append_text(monitor_label) voice.set_active(v_value) text.set_active(t_value) @@ -385,15 +396,7 @@ def populate_monitor_menus(self, _a=None, _b=None): def close_window(self, _widget=None, _event=None): """Hide the settings window for use at a later date""" - self.window.hide() - if self.ind is None and self.tray is None: - sys.exit(0) - if self.ind is not None: - # pylint: disable=import-outside-toplevel - from gi.repository import AppIndicator3 - if self.ind.get_status() == AppIndicator3.IndicatorStatus.PASSIVE: - sys.exit(0) - return True + sys.exit(0) def close_app(self, _widget=None, _event=None): """Close the app""" @@ -401,36 +404,9 @@ def close_app(self, _widget=None, _event=None): def present_settings(self, _a=None): """Show the settings window""" - self.widget['notebook'].set_current_page(0) + self.widget["notebook"].set_current_page(0) self.window.show() - def set_alignment_labels(self, horz): - """Relabel alignment pulldowns""" - m1 = self.widget['voice_align_1'].get_model() - m2 = self.widget['voice_align_2'].get_model() - i = m1.get_iter_first() - i2 = m2.get_iter_first() - if horz: - m1.set_value(i, 0, _("Top")) - i = m1.iter_next(i) - m1.set_value(i, 0, _("Bottom")) - - m2.set_value(i2, 0, _("Left")) - i2 = m2.iter_next(i2) - m2.set_value(i2, 0, _("Middle")) - i2 = m2.iter_next(i2) - m2.set_value(i2, 0, _("Right")) - else: - m1.set_value(i, 0, _("Left")) - i = m1.iter_next(i) - m1.set_value(i, 0, _("Right")) - - m2.set_value(i2, 0, _("Top")) - i2 = m2.iter_next(i2) - m2.set_value(i2, 0, _("Middle")) - i2 = m2.iter_next(i2) - m2.set_value(i2, 0, _("Bottom")) - def read_config(self): """Read config from disk""" self.loading_config = True @@ -440,26 +416,14 @@ def read_config(self): config.read(self.config_file) # Read Voice section + self.widget["voice_align_1"].set_active( + get_h_align(config.get("main", "x_align", fallback="left")).value + ) + self.widget["voice_align_2"].set_active( + get_v_align(config.get("main", "y_align", fallback="middle")).value + ) - self.voice_floating_x = config.getfloat( - "main", "floating_x", fallback=0) - self.voice_floating_y = config.getfloat( - "main", "floating_y", fallback=0) - self.voice_floating_w = config.getfloat( - "main", "floating_w", fallback=400) - self.voice_floating_h = config.getfloat( - "main", "floating_h", fallback=400) - - self.widget['voice_anchor_float'].set_active( - 0 if config.getboolean("main", "floating", fallback=False) else 1) - self.update_floating_anchor() - - self.widget['voice_align_1'].set_active( - config.getboolean("main", "rightalign", fallback=False)) - self.widget['voice_align_2'].set_active( - config.getint("main", "topalign", fallback=1)) - - self.widget['voice_monitor'].set_active( + self.widget["voice_monitor"].set_active( self.get_monitor_index_from_plug( config.get("main", "monitor", fallback="Any") ) @@ -467,151 +431,177 @@ def read_config(self): font = config.get("main", "font", fallback=None) if font: - self.widget['voice_font'].set_font(font) + self.widget["voice_font"].set_font(font) title_font = config.get("main", "title_font", fallback=None) if title_font: - self.widget['voice_title_font'].set_font(title_font) - - self.widget['voice_icon_spacing'].set_value( - config.getint("main", "icon_spacing", fallback=8)) + self.widget["voice_title_font"].set_font(title_font) - self.widget['voice_text_padding'].set_value( - config.getint("main", "text_padding", fallback=6)) + self.widget["voice_icon_spacing"].set_value( + config.getint("main", "icon_spacing", fallback=8) + ) - self.widget['voice_text_vertical_offset'].set_value( - config.getint("main", "text_baseline_adj", fallback=0)) + self.widget["voice_text_padding"].set_value( + config.getint("main", "text_padding", fallback=6) + ) - self.widget['voice_vertical_padding'].set_value( - config.getint("main", "vert_edge_padding", fallback=0)) + self.widget["voice_vertical_padding"].set_value( + config.getint("main", "vert_edge_padding", fallback=0) + ) - self.widget['voice_horizontal_padding'].set_value( - config.getint("main", "horz_edge_padding", fallback=0)) + self.widget["voice_horizontal_padding"].set_value( + config.getint("main", "horz_edge_padding", fallback=0) + ) horz = config.getboolean("main", "horizontal", fallback=False) - self.set_alignment_labels(horz) - self.widget['voice_display_horizontally'].set_active(horz) + self.widget["voice_display_horizontally"].set_active(horz) - self.widget['voice_highlight_self'].set_active( - config.getboolean("main", "highlight_self", fallback=False)) + self.widget["voice_highlight_self"].set_active( + config.getboolean("main", "highlight_self", fallback=False) + ) - self.widget['voice_display_speakers_only'].set_active( - config.getboolean("main", "only_speaking", fallback=False)) + self.widget["voice_display_speakers_only"].set_active( + config.getboolean("main", "only_speaking", fallback=False) + ) - self.widget['voice_display_speakers_grace_period'].set_value( - config.getint("main", "only_speaking_grace", fallback=0)) + self.widget["voice_display_speakers_grace_period"].set_value( + config.getint("main", "only_speaking_grace", fallback=0) + ) - self.widget['voice_show_test_content'].set_active( - config.getboolean("main", "show_dummy", fallback=False)) + self.widget["voice_show_test_content"].set_active( + config.getboolean("main", "show_dummy", fallback=False) + ) - self.widget['voice_talking_foreground'].set_rgba(self.make_colour(config.get( - "main", "fg_hi_col", fallback="[1.0,1.0,1.0,1.0]"))) + self.widget["voice_talking_foreground"].set_rgba( + self.make_colour( + config.get("main", "fg_hi_col", fallback="[1.0,1.0,1.0,1.0]") + ) + ) - self.widget['voice_talking_background'].set_rgba(self.make_colour(config.get( - "main", "hi_col", fallback="[0.0,0.0,0.0,0.5]"))) + self.widget["voice_talking_background"].set_rgba( + self.make_colour(config.get("main", "hi_col", fallback="[0.0,0.0,0.0,0.5]")) + ) - self.widget['voice_talking_border'].set_rgba(self.make_colour(config.get( - "main", "tk_col", fallback="[0.0,0.7,0.0,1.0]"))) + self.widget["voice_talking_border"].set_rgba( + self.make_colour(config.get("main", "tk_col", fallback="[0.0,0.7,0.0,0.2]")) + ) - self.widget['voice_idle_foreground'].set_rgba(self.make_colour(config.get( - "main", "fg_col", fallback="[1.0,1.0,1.0,1.0]"))) + self.widget["voice_idle_foreground"].set_rgba( + self.make_colour(config.get("main", "fg_col", fallback="[1.0,1.0,1.0,1.0]")) + ) - self.widget['voice_idle_background'].set_rgba(self.make_colour(config.get( - "main", "bg_col", fallback="[0.0,0.0,0.0,0.5]"))) + self.widget["voice_idle_background"].set_rgba( + self.make_colour(config.get("main", "bg_col", fallback="[0.0,0.0,0.0,0.5]")) + ) - self.widget['voice_idle_border'].set_rgba(self.make_colour(config.get( - "main", "bo_col", fallback="[0.0,0.0,0.0,0.0]"))) + self.widget["voice_idle_border"].set_rgba( + self.make_colour(config.get("main", "bo_col", fallback="[0.0,0.0,0.0,0.0]")) + ) - self.widget['voice_mute_foreground'].set_rgba(self.make_colour(config.get( - "main", "mt_col", fallback="[0.6,0.0,0.0,1.0]"))) + self.widget["voice_mute_foreground"].set_rgba( + self.make_colour(config.get("main", "mt_col", fallback="[0.6,0.0,0.0,1.0]")) + ) - self.widget['voice_mute_background'].set_rgba(self.make_colour(config.get( - "main", "mt_bg_col", fallback="[0.0,0.0,0.0,0.5]"))) + self.widget["voice_mute_background"].set_rgba( + self.make_colour( + config.get("main", "mt_bg_col", fallback="[0.0,0.0,0.0,0.5]") + ) + ) - self.widget['voice_avatar_background'].set_rgba(self.make_colour(config.get( - "main", "avatar_bg_col", fallback="[0.0,0.0,0.0,0.0]"))) + self.widget["voice_avatar_background"].set_rgba( + self.make_colour( + config.get("main", "avatar_bg_col", fallback="[0.0,0.0,0.0,0.0]") + ) + ) - self.widget['voice_avatar_opacity'].set_value(config.getfloat( - "main", "icon_transparency", fallback=1.0)) + self.widget["voice_avatar_opacity"].set_value( + config.getfloat("main", "icon_transparency", fallback=1.0) + ) - self.widget['voice_nick_length'].set_value( - config.getint("main", "nick_length", fallback=32)) + self.widget["voice_nick_length"].set_value( + config.getint("main", "nick_length", fallback=32) + ) - self.widget['voice_avatar_size'].set_value( - config.getint("main", "avatar_size", fallback=48)) + self.widget["voice_avatar_size"].set_value( + config.getint("main", "avatar_size", fallback=48) + ) show_name = not config.getboolean("main", "icon_only", fallback=False) - self.widget['voice_display_icon_only'].set_active(show_name) + self.widget["voice_display_icon_only"].set_active(show_name) self.voice_show_name_hide_others(show_name) - self.widget['voice_square_avatar'].set_active(config.getboolean( - "main", "square_avatar", fallback=True)) - - self.widget['voice_fancy_avatar_shapes'].set_active( - config.getboolean("main", "fancy_border", fallback=True)) - - self.widget['voice_order_avatars_by'].set_active( - config.getint("main", "order", fallback=0)) - - self.widget['voice_border_width'].set_value( - config.getint("main", "border_width", fallback=2)) + self.widget["voice_order_avatars_by"].set_active( + config.getint("main", "order", fallback=0) + ) - self.widget['voice_overflow_style'].set_active( - config.getint("main", "overflow", fallback=0)) + self.widget["voice_border_width"].set_value( + config.getint("main", "border_width", fallback=2) + ) - self.widget['voice_show_title'].set_active(config.getboolean( - "main", "show_title", fallback=False)) + self.widget["voice_show_title"].set_active( + config.getboolean("main", "show_title", fallback=False) + ) - show_avatar = config.getboolean( - "main", "show_avatar", fallback=True) - self.widget['voice_show_avatar'].set_active(show_avatar) + show_avatar = config.getboolean("main", "show_avatar", fallback=True) + self.widget["voice_show_avatar"].set_active(show_avatar) self.voice_show_avatar_hide_others(show_avatar) - self.widget['voice_show_connection_status'].set_active(config.getboolean( - "main", "show_connection", fallback=False)) + self.widget["voice_show_connection_status"].set_active( + config.getboolean("main", "show_connection", fallback=False) + ) - self.widget['voice_show_disconnected'].set_active(config.getboolean( - "main", "show_disconnected", fallback=False)) + self.widget["voice_show_disconnected"].set_active( + config.getboolean("main", "show_disconnected", fallback=True) + ) - self.widget['voice_dummy_count'].set_value( - config.getint("main", "dummy_count", fallback=50)) + self.widget["voice_dummy_count"].set_value( + config.getint("main", "dummy_count", fallback=50) + ) - self.widget['voice_inactive_fade'].set_active( + self.widget["voice_inactive_fade"].set_active( config.getboolean("main", "fade_out_inactive", fallback=False) ) - self.widget['voice_inactive_opacity'].set_value( + self.widget["voice_inactive_opacity"].set_value( config.getfloat("main", "fade_out_limit", fallback=0.3) ) - self.widget['voice_inactive_time'].set_value( + self.widget["voice_inactive_time"].set_value( config.getint("main", "inactive_time", fallback=10) ) - self.widget['voice_inactive_fade_time'].set_value( + self.widget["voice_inactive_fade_time"].set_value( config.getint("main", "inactive_fade_time", fallback=30) ) - self.widget['voice_hide_mouseover'].set_active( + self.widget["voice_hide_mouseover"].set_active( config.getboolean("main", "autohide", fallback=False) ) - self.widget['voice_show_mouseover'].set_value( + self.widget["voice_show_mouseover"].set_value( config.getint("main", "autohide_timer", fallback=5) ) - # Read Text section + self.widget["voice_text_side"].set_active( + config.getint("main", "text_side", fallback=3) + ) - self.text_floating_x = config.getfloat( - "text", "floating_x", fallback=0) - self.text_floating_y = config.getfloat( - "text", "floating_y", fallback=0) - self.text_floating_w = config.getfloat( - "text", "floating_w", fallback=400) - self.text_floating_h = config.getfloat( - "text", "floating_h", fallback=400) + # Read Text section + self.widget["text_align_1"].set_active( + get_h_align(config.get("text", "x_align", fallback="left")).value + ) + self.widget["text_align_2"].set_active( + get_v_align(config.get("text", "y_align", fallback="middle")).value + ) - self.widget['text_enable'].set_active( - config.getboolean("text", "enabled", fallback=False)) + self.widget["text_monitor"].set_active( + self.get_monitor_index_from_plug( + config.get("text", "monitor", fallback="Any") + ) + ) + self.widget["text_enable"].set_active( + config.getboolean("text", "enabled", fallback=False) + ) - self.widget['text_popup_style'].set_active( - config.getboolean("text", "popup_style", fallback=False)) + self.widget["text_popup_style"].set_active( + config.getboolean("text", "popup_style", fallback=False) + ) - self.widget['text_popup_time'].set_value( + self.widget["text_popup_time"].set_value( config.getint("text", "text_time", fallback=30) ) @@ -621,121 +611,126 @@ def read_config(self): font = config.get("text", "font", fallback=None) if font: - self.widget['text_font'].set_font(font) + self.widget["text_font"].set_font(font) - self.widget['text_colour'].set_rgba(self.make_colour(config.get( - "text", "fg_col", fallback="[1.0,1.0,1.0,1.0]"))) + self.widget["text_colour"].set_rgba( + self.make_colour(config.get("text", "fg_col", fallback="[1.0,1.0,1.0,1.0]")) + ) - self.widget['text_background_colour'].set_rgba(self.make_colour(config.get( - "text", "bg_col", fallback="[0.0,0.0,0.0,0.5]"))) + self.widget["text_background_colour"].set_rgba( + self.make_colour(config.get("text", "bg_col", fallback="[0.0,0.0,0.0,0.5]")) + ) - self.widget['text_monitor'].set_active( + self.widget["text_monitor"].set_active( self.get_monitor_index_from_plug( config.get("text", "monitor", fallback="Any") ) ) - self.widget['text_show_attachments'].set_active(config.getboolean( - "text", "show_attach", fallback=True)) + self.widget["text_show_attachments"].set_active( + config.getboolean("text", "show_attach", fallback=True) + ) - self.widget['text_line_limit'].set_value( - config.getint("text", "line_limit", fallback=20)) + self.widget["text_line_limit"].set_value( + config.getint("text", "line_limit", fallback=20) + ) - self.widget['text_hide_mouseover'].set_active( + self.widget["text_hide_mouseover"].set_active( config.getboolean("text", "autohide", fallback=False) ) - self.widget['text_show_mouseover'].set_value( + self.widget["text_show_mouseover"].set_value( config.getint("text", "autohide_timer", fallback=5) ) # Read Notification section - self.widget['notification_enable'].set_active( - config.getboolean("notification", "enabled", fallback=False)) - - self.widget['notification_reverse_order'].set_active( - config.getboolean("notification", "rev", fallback=False)) + self.widget["notification_enable"].set_active( + config.getboolean("notification", "enabled", fallback=False) + ) - self.widget['notification_popup_timer'].set_value( - config.getint("notification", "text_time", fallback=10)) + self.widget["notification_popup_timer"].set_value( + config.getint("notification", "text_time", fallback=10) + ) - self.widget['notification_limit_popup_width'].set_value( - config.getint("notification", "limit_width", fallback=400)) + self.widget["notification_limit_popup_width"].set_value( + config.getint("notification", "limit_width", fallback=400) + ) font = config.get("notification", "font", fallback=None) if font: - self.widget['notification_font'].set_font(font) + self.widget["notification_font"].set_font(font) - self.widget['notification_text_colour'].set_rgba(self.make_colour(config.get( - "notification", "fg_col", fallback="[1.0,1.0,1.0,1.0]"))) - self.widget['notification_background_colour'].set_rgba(self.make_colour(config.get( - "notification", "bg_col", fallback="[0.0,0.0,0.0,0.5]"))) + self.widget["notification_text_colour"].set_rgba( + self.make_colour( + config.get("notification", "fg_col", fallback="[1.0,1.0,1.0,1.0]") + ) + ) + self.widget["notification_background_colour"].set_rgba( + self.make_colour( + config.get("notification", "bg_col", fallback="[0.0,0.0,0.0,0.5]") + ) + ) - self.widget['notification_monitor'].set_active( + self.widget["notification_monitor"].set_active( self.get_monitor_index_from_plug( config.get("notification", "monitor", fallback="Any") ) ) - self.widget['notification_align_1'].set_active(config.getboolean( - "notification", "rightalign", fallback=True)) - - self.widget['notification_align_2'].set_active( - config.getint("notification", "topalign", fallback=2)) + self.widget["notification_align_1"].set_active( + get_h_align(config.get("notification", "x_align", fallback="left")).value + ) + self.widget["notification_align_2"].set_active( + get_v_align(config.get("notification", "y_align", fallback="middle")).value + ) - self.widget['notification_show_icon'].set_active( - config.getboolean("notification", "show_icon", fallback=True)) + self.widget["notification_show_icon"].set_active( + config.getboolean("notification", "show_icon", fallback=True) + ) - self.widget['notification_icon_position'].set_active(config.getboolean( - "notification", "icon_left", fallback=True)) + self.widget["notification_icon_position"].set_active( + 0 if config.getboolean("notification", "icon_left", fallback=True) else 1 + ) - self.widget['notification_icon_padding'].set_value(config.getint( - "notification", "icon_padding", fallback=8)) + self.widget["notification_icon_padding"].set_value( + config.getint("notification", "icon_padding", fallback=8) + ) - self.widget['notification_icon_size'].set_value(config.getint( - "notification", "icon_size", fallback=32)) + self.widget["notification_icon_size"].set_value( + config.getint("notification", "icon_size", fallback=32) + ) - self.widget['notification_padding_between'].set_value(config.getint( - "notification", "padding", fallback=8)) + self.widget["notification_padding_between"].set_value( + config.getint("notification", "padding", fallback=8) + ) - self.widget['notification_border_radius'].set_value(config.getint( - "notification", "border_radius", fallback=8)) + self.widget["notification_border_radius"].set_value( + config.getint("notification", "border_radius", fallback=8) + ) - self.widget['notification_show_test_content'].set_active(config.getboolean( - "notification", "show_dummy", fallback=False)) + self.widget["notification_text_justify"].set_active( + get_h_align(config.get("notification", "text_align", fallback="left")).value + ) # Read Core section - self.widget['core_run_on_startup'].set_active( - self.autostart_helper.is_auto()) + self.widget["core_run_on_startup"].set_active(self.autostart_helper.is_auto()) - self.widget['core_run_conf_on_startup'].set_active( - self.autostart_helper_conf.is_auto()) + self.widget["core_run_conf_on_startup"].set_active( + self.autostart_helper_conf.is_auto() + ) if self.disable_autostart: - self.widget['core_run_on_startup'].set_sensitive(False) - self.widget['core_run_conf_on_startup'].set_sensitive(False) - - self.widget['core_force_xshape'].set_active( - config.getboolean("general", "xshape", fallback=False)) - - self.show_sys_tray_icon = config.getboolean( - "general", "showsystray", fallback=True) - self.set_sys_tray_icon_visible(self.show_sys_tray_icon) - self.widget['core_show_tray_icon'].set_active(self.show_sys_tray_icon) + self.widget["core_run_on_startup"].set_sensitive(False) + self.widget["core_run_conf_on_startup"].set_sensitive(False) self.hidden_overlay = config.getboolean( - "general", "hideoverlay", fallback=False) + "general", "hideoverlay", fallback=False + ) self.update_toggle_overlay() - self.start_minimized = config.getboolean( - "general", "start_min", fallback=False) - - self.widget['core_settings_min'].set_active(self.start_minimized) - - self.widget['core_settings_min'].set_sensitive(self.show_sys_tray_icon) - - self.widget['core_audio_assist'].set_active( - config.getboolean("general", "audio_assist", fallback=False)) + self.widget["core_audio_assist"].set_active( + config.getboolean("general", "audio_assist", fallback=False) + ) self.loading_config = False @@ -753,88 +748,25 @@ def parse_guild_ids(self, guild_ids_str): guild_ids.append(guild_id) return guild_ids - def get_monitor_index_from_plug(self, monitor): + def get_monitor_index_from_plug(self, plug): """Get monitor index from plug name""" - if not monitor or monitor == "Any": + if not plug or plug == "Any": return 0 - display = Gdk.Display.get_default() - screen = self.window.get_screen() - if "get_n_monitors" in dir(display): - count_monitors = display.get_n_monitors() - if count_monitors >= 1: - for i in range(0, count_monitors): - connector = screen.get_monitor_plug_name(i) - if connector == monitor: - return i+1 + + i = 0 + for monitor in self.window.get_display().get_monitors(): + connector = monitor.get_connector() + if connector == plug: + return i + 1 + i += 1 return 0 def get_monitor_obj(self, idx): """Helper function to find the monitor object of the monitor""" - display = Gdk.Display.get_default() - return display.get_monitor(idx) - - def make_sys_tray_icon(self, menu): - """ - Attempt to create an AppIndicator icon, failing that attempt to make - a systemtray icon - """ - try: - gi.require_version('AppIndicator3', '0.1') - # pylint: disable=import-outside-toplevel - from gi.repository import AppIndicator3 - self.ind = AppIndicator3.Indicator.new( - "discover_overlay", - self.tray_icon_name, - AppIndicator3.IndicatorCategory.APPLICATION_STATUS) - self.ind.set_title(_("Discover Overlay Configuration")) - # Hide for now since we don't know if it should be shown yet - self.ind.set_status(AppIndicator3.IndicatorStatus.PASSIVE) - self.ind.set_menu(menu) - except (ImportError, ValueError) as exception: - # Create System Tray - log.info("Falling back to Systray : %s", exception) - self.tray = Gtk.StatusIcon.new_from_icon_name( - self.tray_icon_name) - self.tray.connect('popup-menu', self.show_menu) - self.tray.set_title(_("Discover Overlay Configuration")) - # Hide for now since we don't know if it should be shown yet - self.tray.set_visible(False) - - def show_menu(self, obj, button, time): - """Show menu when System Tray icon is clicked""" - self.menu.show_all() - self.menu.popup( - None, None, Gtk.StatusIcon.position_menu, obj, button, time) - - def set_sys_tray_icon_visible(self, visible): - """Sets whether the tray icon is visible""" - if self.ind is not None: - # pylint: disable=import-outside-toplevel - from gi.repository import AppIndicator3 - self.ind.set_status( - AppIndicator3.IndicatorStatus.ACTIVE if visible else AppIndicator3.IndicatorStatus.PASSIVE) - elif self.tray is not None: - self.tray.set_visible(visible) - - def make_menu(self): - """Create System Menu""" - menu = Gtk.Menu() - settings_opt = Gtk.MenuItem.new_with_label(_("Open Configuration")) - self.toggle_opt = Gtk.MenuItem.new_with_label(_("Hide Overlay")) - close_overlay_opt = Gtk.MenuItem.new_with_label(_("Quit Overlay")) - close_opt = Gtk.MenuItem.new_with_label(_("Quit Configuration")) - - menu.append(settings_opt) - menu.append(self.toggle_opt) - menu.append(close_overlay_opt) - menu.append(close_opt) - - settings_opt.connect("activate", self.present_settings) - self.toggle_opt.connect("activate", self.toggle_overlay) - close_overlay_opt.connect("activate", self.close_overlay) - close_opt.connect("activate", self.close_app) - menu.show_all() - return menu + display = self.window.get_display() + if idx == 0: + return None + return display.get_monitors().get_item(idx - 1) def toggle_overlay(self, _a=None, _b=None): """Toggle overlay visibility""" @@ -844,129 +776,26 @@ def toggle_overlay(self, _a=None, _b=None): def update_toggle_overlay(self, _a=None, _b=None): """Update gui to reflect state of overlay visibility""" - self.widget['core_hide_overlay'].handler_block( - self.hidden_overlay_handler) + self.widget["core_hide_overlay"].handler_block(self.hidden_overlay_handler) - self.widget['core_hide_overlay'].set_active(self.hidden_overlay) + self.widget["core_hide_overlay"].set_active(self.hidden_overlay) - self.widget['core_hide_overlay'].handler_unblock( - self.hidden_overlay_handler) - if self.hidden_overlay: - self.toggle_opt.set_label(_("Show Overlay")) - else: - self.toggle_opt.set_label(_("Hide Overlay")) + self.widget["core_hide_overlay"].handler_unblock(self.hidden_overlay_handler) def close_overlay(self, _a=None, _b=None): """Send RPC to tell the overlay to close""" - with open(self.rpc_file, 'w', encoding="utf-8") as f: - f.write('--rpc --close') + with open(self.rpc_file, "w", encoding="utf-8") as f: + f.write("--rpc --close") def overview_close(self, _button): """Gui callback to close overlay. Remove and use close_overlay?""" log.info("Quit pressed") self.close_overlay() - def voice_place_window(self, button): - """Toggle the voice placement""" - if self.voice_placement_window: - (pos_x, pos_y, width, height) = self.voice_placement_window.get_coords() - self.voice_floating_x = pos_x - self.voice_floating_y = pos_y - self.voice_floating_w = width - self.voice_floating_h = height - - config = ConfigParser(interpolation=None) - config.read(self.config_file) - if "main" not in config.sections(): - config.add_section("main") - config.set("main", "floating_x", f"{self.voice_floating_x:f}") - config.set("main", "floating_y", f"{self.voice_floating_y:f}") - config.set("main", "floating_w", f"{self.voice_floating_w:f}") - config.set("main", "floating_h", f"{self.voice_floating_h:f}") - - with open(self.config_file, 'w', encoding="utf-8") as file: - config.write(file) - if button: - button.set_label(_("Place Window")) - self.voice_placement_window.close() - self.voice_placement_window = None - if self.steamos: - self.window.show() - else: - if self.steamos: - self.window.hide() - if self.is_wayland or self.steamos: - self.voice_placement_window = DraggableWindowWayland( - pos_x=self.voice_floating_x, pos_y=self.voice_floating_y, - width=self.voice_floating_w, height=self.voice_floating_h, - message=_("Place & resize this window then press Green!"), settings=self, - steamos=self.steamos, - monitor=self.widget['voice_monitor'].get_active()-1) - else: - self.voice_placement_window = DraggableWindow( - pos_x=self.voice_floating_x, pos_y=self.voice_floating_y, - width=self.voice_floating_w, height=self.voice_floating_h, - message=_("Place & resize this window then press Save!"), - settings=self, monitor=self.widget['voice_monitor'].get_active()-1) - if button: - button.set_label(_("Save this position")) - - def text_place_window(self, button): - """Toggle the text placement""" - if self.text_placement_window: - (pos_x, pos_y, width, height) = self.text_placement_window.get_coords() - self.text_floating_x = pos_x - self.text_floating_y = pos_y - self.text_floating_w = width - self.text_floating_h = height - - config = ConfigParser(interpolation=None) - config.read(self.config_file) - if "text" not in config.sections(): - config.add_section("text") - config.set("text", "floating_x", f"{self.text_floating_x:f}") - config.set("text", "floating_y", f"{self.text_floating_y:f}") - config.set("text", "floating_w", f"{self.text_floating_w:f}") - config.set("text", "floating_h", f"{self.text_floating_h:f}") - - with open(self.config_file, 'w', encoding="utf-8") as file: - config.write(file) - if button: - button.set_label(_("Place Window")) - self.text_placement_window.close() - self.text_placement_window = None - if self.steamos: - self.window.show() - else: - if self.steamos: - self.window.hide() - if self.is_wayland or self.steamos: - self.text_placement_window = DraggableWindowWayland( - pos_x=self.text_floating_x, pos_y=self.text_floating_y, - width=self.text_floating_w, height=self.text_floating_h, - message=_("Place & resize this window then press Green!"), settings=self, - steamos=self.steamos, - monitor=self.widget['text_monitor'].get_active()-1) - else: - self.text_placement_window = DraggableWindow( - pos_x=self.text_floating_x, pos_y=self.text_floating_y, - width=self.text_floating_w, height=self.text_floating_h, - message=_("Place & resize this window then press Save!"), - settings=self, monitor=self.widget['text_monitor'].get_active()-1) - if button: - button.set_label(_("Save this position")) - - def change_placement(self, placement_window): - """Finish window placement""" - if placement_window == self.text_placement_window: - self.text_place_window(None) - elif placement_window == self.voice_placement_window: - self.voice_place_window(None) - def text_server_refresh(self, _button): """Send RPC to overlay to request a list of text channels""" - with open(self.rpc_file, 'w', encoding="utf-8") as f: - f.write('--rpc --refresh-guilds') + with open(self.rpc_file, "w", encoding="utf-8") as f: + f.write("--rpc --refresh-guilds") def config_set(self, context, key, value): """Write one key to config and save to disk""" @@ -977,7 +806,7 @@ def config_set(self, context, key, value): if not context in config.sections(): config.add_section(context) config.set(context, key, value) - with open(self.config_file, 'w', encoding="utf-8") as file: + with open(self.config_file, "w", encoding="utf-8") as file: config.write(file) def config_remove_section(self, context): @@ -990,40 +819,36 @@ def config_remove_section(self, context): config.remove_section(context) else: log.error("Unable to remove section %s", context) - with open(self.config_file, 'w', encoding="utf-8") as file: + with open(self.config_file, "w", encoding="utf-8") as file: config.write(file) - def voice_anchor_float_changed(self, button): - self.config_set("main", "floating", f"{(button.get_active() == 0)}") - self.update_floating_anchor() - - def update_floating_anchor(self): - floating = self.widget['voice_anchor_float'].get_active() == 0 - - if floating: - self.widget['voice_align_1'].hide() - self.widget['voice_align_2'].hide() - self.widget['voice_place_window_button'].show() - else: - self.widget['voice_align_1'].show() - self.widget['voice_align_2'].show() - self.widget['voice_place_window_button'].hide() - def voice_monitor_changed(self, button): - screen = self.window.get_screen() + idx = button.get_active() plug = "Any" if idx > 0: - monitor = screen.get_monitor_plug_name(button.get_active()-1) - if monitor: - plug = monitor + plug = self.get_monitor_obj(idx).get_connector() self.config_set("main", "monitor", plug) def voice_align_1_changed(self, button): - self.config_set("main", "rightalign", f"{button.get_active()}") + value = "none" + if button.get_active() == 0: + value = "left" + elif button.get_active() == 1: + value = "middle" + else: + value = "right" + self.config_set("main", "x_align", f"{value}") def voice_align_2_changed(self, button): - self.config_set("main", "topalign", f"{button.get_active()}") + value = "none" + if button.get_active() == 0: + value = "top" + elif button.get_active() == 1: + value = "middle" + else: + value = "bottom" + self.config_set("main", "y_align", f"{value}") def voice_font_changed(self, button): self.config_set("main", "font", button.get_font()) @@ -1038,20 +863,16 @@ def voice_text_padding_changed(self, button): self.config_set("main", "text_padding", f"{int(button.get_value())}") def voice_text_vertical_offset_changed(self, button): - self.config_set("main", "text_baseline_adj", - f"{int(button.get_value())}") + self.config_set("main", "text_baseline_adj", f"{int(button.get_value())}") def voice_vertical_padding_changed(self, button): - self.config_set("main", "vert_edge_padding", - f"{int(button.get_value())}") + self.config_set("main", "vert_edge_padding", f"{int(button.get_value())}") def voice_horizontal_padding_changed(self, button): - self.config_set("main", "horz_edge_padding", - f"{int(button.get_value())}") + self.config_set("main", "horz_edge_padding", f"{int(button.get_value())}") def voice_display_horizontally_changed(self, button): self.config_set("main", "horizontal", f"{button.get_active()}") - self.set_alignment_labels(button.get_active()) def voice_highlight_self_changed(self, button): self.config_set("main", "highlight_self", f"{button.get_active()}") @@ -1060,8 +881,7 @@ def voice_display_speakers_only(self, button): self.config_set("main", "only_speaking", f"{button.get_active()}") def voice_display_speakers_grace_period(self, button): - self.config_set("main", "only_speaking_grace", - f"{int(button.get_value())}") + self.config_set("main", "only_speaking_grace", f"{int(button.get_value())}") def voice_toggle_test_content(self, button): self.config_set("main", "show_dummy", f"{button.get_active()}") @@ -1112,8 +932,7 @@ def voice_avatar_background_changed(self, button): self.config_set("main", "avatar_bg_col", json.dumps(colour)) def voice_avatar_opacity_changed(self, button): - self.config_set("main", "icon_transparency", - f"{button.get_value():.2f}") + self.config_set("main", "icon_transparency", f"{button.get_value():.2f}") def voice_avatar_size_changed(self, button): self.config_set("main", "avatar_size", f"{int(button.get_value())}") @@ -1125,9 +944,6 @@ def voice_display_icon_only_changed(self, button): self.config_set("main", "icon_only", f"{(not button.get_active())}") self.voice_show_name_hide_others(button.get_active()) - def voice_square_avatar_changed(self, button): - self.config_set("main", "square_avatar", f"{button.get_active()}") - def voice_fancy_avatar_shapes_changed(self, button): self.config_set("main", "fancy_border", f"{button.get_active()}") @@ -1137,8 +953,8 @@ def voice_order_avatars_by_changed(self, button): def voice_border_width_changed(self, button): self.config_set("main", "border_width", f"{int(button.get_value())}") - def voice_overflow_style_changed(self, button): - self.config_set("main", "overflow", f"{int(button.get_active())}") + def voice_avatar_circle_changed(self, button): + self.config_set("main", "square_avatar", f"{ not button.get_active()}") def voice_show_title_changed(self, button): self.config_set("main", "show_title", f"{button.get_active()}") @@ -1159,30 +975,24 @@ def voice_show_avatar_changed(self, button): def voice_show_name_hide_others(self, val): if val: # Show name options - self.widget['voice_font'].set_sensitive(True) - self.widget['voice_text_padding'].set_sensitive(True) - self.widget['voice_text_vertical_offset'].set_sensitive(True) - self.widget['voice_nick_length'].set_sensitive(True) + self.widget["voice_font"].set_sensitive(True) + self.widget["voice_text_padding"].set_sensitive(True) + self.widget["voice_nick_length"].set_sensitive(True) else: # Hide name options - self.widget['voice_font'].set_sensitive(False) - self.widget['voice_text_padding'].set_sensitive(False) - self.widget['voice_text_vertical_offset'].set_sensitive(False) - self.widget['voice_nick_length'].set_sensitive(False) + self.widget["voice_font"].set_sensitive(False) + self.widget["voice_text_padding"].set_sensitive(False) + self.widget["voice_nick_length"].set_sensitive(False) def voice_show_avatar_hide_others(self, val): if val: # Show avatar options - self.widget['voice_square_avatar'].set_sensitive(True) - self.widget['voice_fancy_avatar_shapes'].set_sensitive(True) - self.widget['voice_avatar_size'].set_sensitive(True) - self.widget['voice_avatar_opacity'].set_sensitive(True) + self.widget["voice_avatar_size"].set_sensitive(True) + self.widget["voice_avatar_opacity"].set_sensitive(True) else: # Hide avatar options - self.widget['voice_square_avatar'].set_sensitive(False) - self.widget['voice_fancy_avatar_shapes'].set_sensitive(False) - self.widget['voice_avatar_size'].set_sensitive(False) - self.widget['voice_avatar_opacity'].set_sensitive(False) + self.widget["voice_avatar_size"].set_sensitive(False) + self.widget["voice_avatar_opacity"].set_sensitive(False) def text_enable_changed(self, button): self.config_set("text", "enabled", f"{button.get_active()}") @@ -1201,7 +1011,10 @@ def text_server_changed(self, button): if guild and self.current_guild != guild: self.current_guild = guild self.config_set("text", "guild", guild) + self.populate_guild_menu() self.request_channels_from_guild(guild) + else: + log.warning("Unable to select GUILD : %s", guild) def text_channel_changed(self, button): if button.get_active() < 0: @@ -1226,15 +1039,32 @@ def text_background_colour_changed(self, button): self.config_set("text", "bg_col", json.dumps(colour)) def text_monitor_changed(self, button): - screen = self.window.get_screen() + idx = button.get_active() plug = "Any" - monitor = None - if button.get_active()>0: - monitor = screen.get_monitor_plug_name(button.get_active()-1) - if monitor: - plug = monitor + if idx > 0: + plug = self.get_monitor_obj(idx).get_connector() self.config_set("text", "monitor", plug) + def text_align_1_changed(self, button): + value = "none" + if button.get_active() == 0: + value = "left" + elif button.get_active() == 1: + value = "middle" + else: + value = "right" + self.config_set("text", "x_align", f"{value}") + + def text_align_2_changed(self, button): + value = "none" + if button.get_active() == 0: + value = "top" + elif button.get_active() == 1: + value = "middle" + else: + value = "bottom" + self.config_set("text", "y_align", f"{value}") + def text_show_attachments_changed(self, button): self.config_set("text", "show_attach", f"{button.get_active()}") @@ -1244,16 +1074,11 @@ def text_line_limit_changed(self, button): def notification_enable_changed(self, button): self.config_set("notification", "enabled", f"{button.get_active()}") - def notification_reverse_order_changed(self, button): - self.config_set("notification", "rev", f"{button.get_active()}") - def notification_popup_timer_changed(self, button): - self.config_set("notification", "text_time", - f"{int(button.get_value())}") + self.config_set("notification", "text_time", f"{int(button.get_value())}") def notification_limit_popup_width_changed(self, button): - self.config_set("notification", "limit_width", - f"{int(button.get_value())}") + self.config_set("notification", "limit_width", f"{int(button.get_value())}") def notification_font_changed(self, button): self.config_set("notification", "font", button.get_font()) @@ -1269,20 +1094,31 @@ def notification_background_colour_changed(self, button): self.config_set("notification", "bg_col", json.dumps(colour)) def notification_monitor_changed(self, button): - screen = self.window.get_screen() + idx = button.get_active() plug = "Any" - monitor = None - if button.get_active()>0: - monitor = screen.get_monitor_plug_name(button.get_active()-1) - if monitor: - plug = monitor + if idx > 0: + plug = self.get_monitor_obj(idx).get_connector() self.config_set("notification", "monitor", plug) def notification_align_1_changed(self, button): - self.config_set("notification", "rightalign", f"{button.get_active()}") + value = "none" + if button.get_active() == 0: + value = "left" + elif button.get_active() == 1: + value = "middle" + else: + value = "right" + self.config_set("notification", "x_align", f"{value}") def notification_align_2_changed(self, button): - self.config_set("notification", "topalign", f"{button.get_active()}") + value = "none" + if button.get_active() == 0: + value = "top" + elif button.get_active() == 1: + value = "middle" + else: + value = "bottom" + self.config_set("notification", "y_align", f"{value}") def notification_show_icon(self, button): self.config_set("notification", "show_icon", f"{button.get_active()}") @@ -1291,23 +1127,19 @@ def notification_icon_position_changed(self, button): self.config_set("notification", "icon_left", f"{int(button.get_active() != 1)}") def notification_icon_padding_changed(self, button): - self.config_set("notification", "icon_padding", - f"{int(button.get_value())}") + self.config_set("notification", "icon_padding", f"{int(button.get_value())}") def notification_icon_size_changed(self, button): - self.config_set("notification", "icon_size", - f"{int(button.get_value())}") + self.config_set("notification", "icon_size", f"{int(button.get_value())}") def notification_padding_between_changed(self, button): - self.config_set("notification", "padding", - f"{int(button.get_value())}") + self.config_set("notification", "padding", f"{int(button.get_value())}") def notification_border_radius_changed(self, button): - self.config_set("notification", "border_radius", - f"{int(button.get_value())}") + self.config_set("notification", "border_radius", f"{int(button.get_value())}") - def notification_show_test_content_changed(self, button): - self.config_set("notification", "show_dummy", f"{button.get_active()}") + def notification_show_test_content_changed(self, _button): + self.config_set("notification", "show_dummy", "True") def core_run_on_startup_changed(self, button): self.autostart_helper.set_autostart(button.get_active()) @@ -1315,14 +1147,6 @@ def core_run_on_startup_changed(self, button): def core_run_conf_on_startup_changed(self, button): self.autostart_helper_conf.set_autostart(button.get_active()) - def core_force_xshape_changed(self, button): - self.config_set("general", "xshape", f"{button.get_active()}") - - def core_show_tray_icon_changed(self, button): - self.set_sys_tray_icon_visible(button.get_active()) - self.config_set("general", "showsystray", f"{button.get_active()}") - self.widget['core_settings_min'].set_sensitive(button.get_active()) - def core_hide_overlay_changed(self, _button): self.toggle_overlay() @@ -1361,15 +1185,24 @@ def inactive_fade_changed(self, button): self.config_set("main", "fade_out_inactive", f"{button.get_active()}") def inactive_fade_opacity_changed(self, button): - self.config_set("main", "fade_out_limit", - f"{button.get_value():.2f}") + self.config_set("main", "fade_out_limit", f"{button.get_value():2.2f}") def inactive_time_changed(self, button): self.config_set("main", "inactive_time", f"{int(button.get_value())}") def inactive_fade_time_changed(self, button): - self.config_set("main", "inactive_fade_time", - f"{int(button.get_value())}") + self.config_set("main", "inactive_fade_time", f"{int(button.get_value())}") def core_audio_assist_changed(self, button): self.config_set("general", "audio_assist", f"{button.get_active()}") + + def voice_text_side_changed(self, button): + self.config_set("main", "text_side", f"{int(button.get_active())}") + + def notification_text_justify_changed(self, button): + value = "left" + if button.get_active() == 1: + value = "middle" + elif button.get_active() == 2: + value = "right" + self.config_set("notification", "text_align", value) diff --git a/discover_overlay/text_overlay.py b/discover_overlay/text_overlay.py index dbe4e28..b874a98 100644 --- a/discover_overlay/text_overlay.py +++ b/discover_overlay/text_overlay.py @@ -12,336 +12,140 @@ # along with this program. If not, see . """Overlay window for text""" import logging -import time -import re -import cairo import gi -from .image_getter import get_surface, draw_img_to_rect, get_aspected_size -from .overlay import OverlayWindow -gi.require_version("Gtk", "3.0") -gi.require_version('PangoCairo', '1.0') -# pylint: disable=wrong-import-position,wrong-import-order -from gi.repository import Pango, PangoCairo # nopep8 +import cairo +from .css_helper import col_to_css, font_string_to_css_font_string +from .message import Message +from .layout import MessageBoxLayout +from .overlay import HorzAlign, VertAlign, get_h_align, get_v_align + +gi.require_version("Gtk", "4.0") + +from gi.repository import Gtk log = logging.getLogger(__name__) -class TextOverlayWindow(OverlayWindow): +class TextOverlayWindow(Gtk.Box): """Overlay window for text""" - def __init__(self, discover, piggyback=None): - OverlayWindow.__init__(self, discover, piggyback) - self.text_spacing = 4 - self.content = [] - self.text_font = None - self.text_size = 13 + def __init__(self, discover): + Gtk.Box.__init__(self) + self.set_layout_manager(MessageBoxLayout()) + self.set_overflow(Gtk.Overflow.HIDDEN) + self.set_orientation(Gtk.Orientation.VERTICAL) + self.add_css_class("messagebox") + self.discover = discover + self.text_time = None self.show_attach = None self.popup_style = None - self.line_limit = 100 - # 0, 0, self.text_size, self.text_size) - self.pango_rect = Pango.Rectangle() - self.pango_rect.width = self.text_size * Pango.SCALE - self.pango_rect.height = self.text_size * Pango.SCALE - - self.connected = True - self.bg_col = [0.0, 0.6, 0.0, 0.1] - self.fg_col = [1.0, 1.0, 1.0, 1.0] - self.attachment = {} - self.image_list = [] - self.img_finder = re.compile(r"`") - self.warned_filetypes = [] - self.set_title("Discover Text") - self.redraw() + self.width_limit = 500 + self.height_limit = 300 + self.align_x = HorzAlign.RIGHT + self.align_y = VertAlign.BOTTOM + self.show() def set_blank(self): - """ Set contents blank and redraw """ - self.content = [] - self.set_needs_redraw() - - def tick(self): - """ Check for old images """ - if len(self.attachment) > self.line_limit: - # We've probably got old images! - oldlist = self.attachment - self.attachment = {} - log.info("Cleaning old images") - for message in self.content: - if 'attach' in message and message['attach']: - url = message['attach'][0]['url'] - log.info("keeping %s", url) - self.attachment[url] = oldlist[url] + """Set contents blank and redraw""" + child = self.get_first_child() + while child: + n_child = child.get_next_sibling() + self.remove(child) + child = n_child + self.get_root().set_visibility() + + def new_line(self, message): + """Add a new message to text overlay. Does not sanity check the data""" + message = Message(self, message) + if not message.skip: + self.append(message) + self.get_root().set_visibility() def set_text_time(self, timer): """Config option: Time before messages disappear from overlay""" - if self.text_time != timer or self.timer_after_draw != timer: + if self.text_time != timer: self.text_time = timer - self.timer_after_draw = timer - self.set_needs_redraw() - - def set_text_list(self, tlist, altered): - """Change contents of overlay""" - self.content = tlist[-self.line_limit:] - if altered: - self.set_needs_redraw() - - def set_fg(self, fg_col): - """Config option: Sets the text colour""" - if self.fg_col != fg_col: - self.fg_col = fg_col - self.set_needs_redraw() - - def set_bg(self, bg_col): - """Config option: Set the background colour""" - if self.bg_col != bg_col: - self.bg_col = bg_col - self.set_needs_redraw() + self.set_blank() def set_show_attach(self, attachment): """Config option: Show image attachments""" - if self.attachment != attachment: + if self.show_attach != attachment: self.show_attach = attachment - self.set_needs_redraw() + self.update_all() + + def set_font(self, font): + """ + Set the font used by the overlay + """ + self.set_css("font", "* { font: %s; }" % (font_string_to_css_font_string(font))) def set_popup_style(self, boolean): """Config option: Messages should disappear after being shown for some time""" if self.popup_style != boolean: self.popup_style = boolean + self.set_blank() - def set_font(self, font): - """Config option: Set font used for rendering""" - if self.text_font != font: - self.text_font = font - - self.pango_rect = Pango.Rectangle() - font = Pango.FontDescription(self.text_font) - self.pango_rect.width = font.get_size() * Pango.SCALE - self.pango_rect.height = font.get_size() * Pango.SCALE - self.set_needs_redraw() - - def set_line_limit(self, limit): - """Config option: Limit number of lines rendered""" - if self.line_limit != limit: - self.line_limit = limit - - def make_line(self, message): - """Decode a recursive JSON object into pango markup.""" - ret = "" - if isinstance(message, list): - for inner_message in message: - ret = f"{ret}{self.make_line(inner_message)}" - elif isinstance(message, str): - ret = self.sanitize_string(message) - elif message['type'] == 'strong': - ret = f"{self.make_line(message['content'])}" - elif message['type'] == 'text': - ret = self.sanitize_string(message['content']) - elif message['type'] == 'link': - ret = f"{self.make_line(message['content'])}" - elif message['type'] == 'emoji': - if 'surrogate' in message: - # ['src'] is SVG URL - # ret = msg - ret = message['surrogate'] - else: - ### Add Image ### - self.image_list.append(f"https://cdn.discordapp.com/emojis/{message['emojiId']}.png?v=1") - ret = "`" - elif (message['type'] == 'inlineCode' or - message['type'] == 'codeBlock' or - message['type'] == 'blockQuote'): - ret = f"{self.make_line(message['content'])}" - elif message['type'] == 'u': - ret = f"{self.make_line(message['content'])}" - elif message['type'] == 'em': - ret = f"{self.make_line(message['content'])}" - elif message['type'] == 's': - ret = f"{self.make_line(message['content'])}" - elif message['type'] == 'channel': - ret = self.make_line(message['content']) - elif message['type'] == 'mention': - ret = self.make_line(message['content']) - elif message['type'] == 'br': - ret = '\n' - else: - if message["type"] not in self.warned_filetypes: - log.error("Unknown text type : %s", message["type"]) - self.warned_filetypes.append(message['type']) - return ret - - def recv_attach(self, identifier, pix, _mask): - """Callback from image_getter""" - self.attachment[identifier] = pix - self.set_needs_redraw() - - def has_content(self): + def should_show(self): """Returns true if overlay has meaningful content to render""" - if self.piggyback and self.piggyback.has_content(): - return True - if not self.enabled: - return False - if self.hidden: - return False - return self.content - - def overlay_draw(self, w, context, data=None): - """Draw the overlay""" - if self.piggyback: - self.piggyback.overlay_draw(w, context, data) - if not self.enabled: - return - self.context = context - if not self.piggyback_parent: - context.set_antialias(cairo.ANTIALIAS_GOOD) - context.set_source_rgba(0.0, 0.0, 0.0, 0.0) - context.set_operator(cairo.OPERATOR_SOURCE) - context.paint() - self.tick() - context.save() - if self.is_wayland or self.piggyback_parent or self.discover.steamos: - # Special case! - # The window is full-screen regardless of what the user has selected. - # We need to set a clip and a transform to imitate original behaviour - # Used in wlroots & gamescope - (floating_x, floating_y, floating_width, - floating_height) = self.get_floating_coords() - if self.floating: - context.new_path() - context.translate(floating_x, floating_y) - context.rectangle(0, 0, floating_width, floating_height) - context.clip() - (floating_x, floating_y, floating_width, - floating_height) = self.get_floating_coords() - current_y = floating_height - tnow = time.time() - for line in reversed(self.content): - if self.popup_style and tnow - line['time'] > self.text_time: - break - out_line = "" - self.image_list = [] - - col = "#fff" - if 'nick_col' in line and line['nick_col']: - col = line['nick_col'] - for in_line in line['content']: - out_line = f"{out_line}{self.make_line(in_line)}" - if line['attach'] and self.show_attach: - attachment = line['attach'][0] - url = attachment['url'] - extension = attachment['filename'] - extension = extension.rsplit(".", 1)[1] - extension = extension.lower() - if extension in ['jpeg', 'jpg', 'png', 'gif']: - if url in self.attachment: - current_y = self.draw_attach(current_y, url) - else: - get_surface(self.recv_attach, - url, - url, None) - self.attachment[url] = None # Avoid asking repeatedly - else: - log.warning("Unknown file extension '%s'", extension) - # cy = self.draw_text(cy, "%s" % (line['attach'])) - message = f"{self.sanitize_string(line['nick'])}: {out_line}" - current_y = self.draw_text(current_y, message) - if current_y <= 0: - # We've done enough - break - context.restore() - self.context = None - - def draw_attach(self, pos_y, url): - """Draw an attachment""" - (_floating_x, _floating_y, floating_width, - floating_height) = self.get_floating_coords() - if url in self.attachment and self.attachment[url]: - pix = self.attachment[url] - image_width = min(pix.get_width(), floating_width) - image_height = min(pix.get_height(), (floating_height * .7)) - (_ax, _ay, _aw, aspect_height) = get_aspected_size( - pix, image_width, image_height) - self.col(self.bg_col) - self.context.rectangle(0, pos_y - aspect_height, - floating_width, aspect_height) - - self.context.fill() - self.context.set_operator(cairo.OPERATOR_OVER) - _new_w, new_h = draw_img_to_rect( - pix, self.context, 0, pos_y - image_height, image_width, image_height, aspect=True) - return pos_y - new_h - return pos_y - - def draw_text(self, pos_y, text): - """Draw a text message, returning the Y position of the next message""" - layout = self.create_pango_layout(text) - layout.set_auto_dir(True) - layout.set_markup(text, -1) - attr = layout.get_attributes() - - (_floating_x, _floating_y, floating_width, - _floating_height) = self.get_floating_coords() - layout.set_width(Pango.SCALE * floating_width) - layout.set_spacing(Pango.SCALE * 3) - if self.text_font: - font = Pango.FontDescription(self.text_font) - layout.set_font_description(font) - _tw, text_height = layout.get_pixel_size() - self.col(self.bg_col) - self.context.rectangle(0, pos_y - text_height, - floating_width, text_height) - self.context.fill() - self.context.set_operator(cairo.OPERATOR_OVER) - self.col(self.fg_col) - - self.context.move_to(0, pos_y - text_height) - PangoCairo.context_set_shape_renderer( - self.get_pango_context(), self.render_custom, None) - - text = layout.get_text() - count = 0 - - for loc in self.img_finder.finditer(text): - idx = loc.start() - - if len(self.image_list) <= count: - break # We fucked up. Who types ` anyway - # url = self.imgList[count] - - attachment = Pango.attr_shape_new_with_data( - self.pango_rect, self.pango_rect, count, None) - attachment.start_index = idx - attachment.end_index = idx + 1 - attr.insert(attachment) - count += 1 - layout.set_attributes(attr) - - PangoCairo.show_layout(self.context, layout) - return pos_y - text_height - - def render_custom(self, ctx, shape, path, _data): - """Draw an inline image as a custom emoticon""" - if shape.data >= len(self.image_list): - log.warning("%s >= %s", shape.data, len(self.image_list)) - return - # key is the url to the image - key = self.image_list[shape.data] - if key not in self.attachment: - get_surface(self.recv_attach, - key, - key, None) - return - pix = self.attachment[key] - (pos_x, pos_y) = ctx.get_current_point() - draw_img_to_rect(pix, ctx, pos_x, pos_y - self.text_size, self.text_size, - self.text_size, path=path) - return True - - def sanitize_string(self, string): - """Sanitize a text message so that it doesn't intefere with Pango's XML format""" - string = string.replace("&", "&") - string = string.replace("<", "<") - string = string .replace(">", ">") - string = string.replace("'", "'") - string = string.replace("\"", """) - return string + return self.get_first_child() is not None + + def update(self): + """Call when removing a message automatically, allows hiding of overlay when empty""" + self.get_root().set_visibility() + + def update_all(self): + """Tell all messages we've had something changed""" + child = self.get_first_child() + while child: + child.update() + child = child.get_next_sibling() + + def set_config(self, config): + """Set self and children from config""" + channel = config.get("channel", fallback="0") + guild = config.get("guild", fallback="0") + self.discover.connection.set_text_channel(channel, guild) + + font = config.get("font", fallback=None) + + self.set_css( + "background", + ".messagebox { background-color: %s; }" + % (col_to_css(config.get("bg_col", fallback="[0.0,0.0,0.0,0.5]"))), + ) + self.set_css( + "text-color", + ".messagebox .message { color: %s; }" + % (col_to_css(config.get("fg_col", fallback="[1.0,1.0,1.0,1.0]"))), + ) + self.set_popup_style(config.getboolean("popup_style", fallback=False)) + self.set_text_time(config.getint("text_time", fallback=30)) + self.set_show_attach(config.getboolean("show_attach", fallback=True)) + + self.width_limit = config.getint("width_limit", fallback=500) + self.height_limit = config.getint("height_limit", fallback=300) + self.set_size_request(self.width_limit, self.height_limit) + self.align_x = get_h_align(config.get("align_x", "right")) + self.align_y = get_v_align(config.get("align_y", "bottom")) + + if font: + self.set_font(font) + + def set_css(self, css_id, rule): + """Set a CSS Rule on window""" + self.get_root().set_css(css_id, rule) + + def get_align(self): + """Get alignment requested""" + return (self.align_x, self.align_y) + + def get_boxes(self): + """Return a bounding box of this window""" + return [ + # pylint: disable=E1101 + cairo.RectangleInt( + x=0, y=0, width=self.width_limit, height=self.height_limit + ) + ] diff --git a/discover_overlay/userbox.py b/discover_overlay/userbox.py new file mode 100644 index 0000000..9a6d211 --- /dev/null +++ b/discover_overlay/userbox.py @@ -0,0 +1,376 @@ +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""A Gtk Box with direction""" +import gettext +import logging +import gi +import importlib_resources +from .image_getter import get_surface +from .layout import UserBoxLayout +from .connection_state import ConnectionState + +gi.require_version("Gtk", "4.0") + + +from gi.repository import Gtk, GLib + +log = logging.getLogger(__name__) +with importlib_resources.as_file( + importlib_resources.files("discover_overlay") / "locales" +) as path: + t = gettext.translation( + "default", + path, + fallback=True, + ) + _ = t.gettext + + +class UserBox(Gtk.Box): + """A GtkBox with information about the user it is displaying""" + + def __init__(self, overlay, userid): + super().__init__() + self.overlay = overlay + self.userid = userid + + self.add_css_class("user") + + self.is_in_chat = False + self.talking = False + + self.image = Gtk.Image() + self.label = Gtk.Label() + self.mute = Gtk.Image() + self.deaf = Gtk.Image() + + self.image.set_overflow(Gtk.Overflow.HIDDEN) + + self.image.add_css_class("usericon") + self.label.add_css_class("userlabel") + self.mute.add_css_class("usermute") + self.deaf.add_css_class("userdeaf") + + self.image.set_halign(Gtk.Align.CENTER) + self.image.set_valign(Gtk.Align.CENTER) + self.mute.set_halign(Gtk.Align.CENTER) + self.mute.set_valign(Gtk.Align.CENTER) + self.deaf.set_halign(Gtk.Align.CENTER) + self.deaf.set_valign(Gtk.Align.CENTER) + + self.append(self.label) + self.append(self.image) + self.append(self.mute) + self.append(self.deaf) + + self.mute.hide() + self.deaf.hide() + + self.pixbuf = None + self.pixbuf_requested = False + self.previous_avatar_url = None + self.name = "" + + self.grace_timeout = None + + self.set_layout_manager(UserBoxLayout()) + self.update_image() + + def update_user_data(self, userblob): + """Set internals based on most recent object from connector. Avoid flickering/reflow where possible""" + name = userblob["username"] + if "nick" in userblob: + name = userblob["nick"] + if self.name != name: + self.name = name + self.update_label() + + # These are set by server, from multiple sources. + if "mute" in userblob: + self.set_mute(userblob["mute"]) + if "deaf" in userblob: + self.set_deaf(userblob["deaf"]) + + url = f"https://cdn.discordapp.com/avatars/{userblob['id']}/{userblob['avatar']}.png" + + if not self.pixbuf_requested and url != self.previous_avatar_url: + get_surface(self.recv_avatar, url, userblob["id"], self.get_display()) + self.pixbuf_requested = True + + def update_label(self): + """Update the label widget, assuming config has changed""" + if self.overlay.icon_only: + self.label.hide() + return + self.label.show() + + if len(self.name) < self.overlay.nick_length: + self.label.set_text(self.name) + else: + self.label.set_text(self.name[: (self.overlay.nick_length - 1)] + "\u2026") + + def update_image(self): + """Update the image widget, assuming config has changed""" + if self.overlay.deafpix: + self.deaf.set_from_pixbuf(self.overlay.deafpix) + if self.overlay.mutepix: + self.mute.set_from_pixbuf(self.overlay.mutepix) + + if not self.overlay.show_avatar: + self.image.hide() + return + self.image.show() + + if self.pixbuf: + self.image.set_from_pixbuf(self.pixbuf) + elif self.overlay.def_avatar: + self.image.set_from_pixbuf(self.overlay.def_avatar) + + def recv_avatar(self, _identifier, pix): + """Callback to return an image to main thread""" + self.pixbuf = pix + self.pixbuf_requested = False + self.image.set_from_pixbuf(self.pixbuf) + + def set_mute(self, mute): + """Set this user to display as muted""" + if mute: + self.add_css_class("mute") + self.mute.show() + else: + self.remove_css_class("mute") + self.mute.hide() + + def set_deaf(self, deaf): + """Set this user to display as deafened""" + if deaf: + self.add_css_class("deaf") + self.deaf.show() + else: + self.remove_css_class("deaf") + self.deaf.hide() + + def set_talking(self, talking): + """Called by connector when user starts or stops talking""" + self.talking = talking + if self.grace_timeout: + GLib.source_remove(self.grace_timeout) + self.grace_timeout = None + if talking: + self.add_css_class("talking") + else: + self.remove_css_class("talking") + if self.overlay.only_speaking: + grace = self.overlay.only_speaking_grace_period + if grace > 0: + self.grace_timeout = GLib.timeout_add_seconds(grace, self.grace_cb) + self.set_shown() + + def user_left(self): + """This user has left the room""" + self.is_in_chat = False + self.set_shown() + + def user_join(self): + """This user has joined the room""" + self.is_in_chat = True + self.set_shown() + + def set_shown(self): + """Set widget to shown based on information available""" + if self.should_show(): + self.show() + return + self.hide() + + def should_show(self): + """Should this widget be shown""" + return self.is_user_visible() + + def is_user_visible(self): + """Is this a user and visible.""" + if self.grace_timeout: + return True # We're awaiting a timeout, keep showing + if self.overlay.only_speaking and not self.talking: + return False + if self.is_in_chat: + return True + return False + + def grace_cb(self): + """Called X seconds after user stops talking. Remove callback ID and hide self if needed""" + self.grace_timeout = None + self.set_shown() + return False # Do not repeat + + +class UserBoxConnection(UserBox): + """A User-like box to show the connection state before users in voice overlay""" + + def __init__(self, overlay): + self.show_always = False + self.show_disconnected = True + self.last = ConnectionState.NO_DISCORD + super().__init__(overlay, None) + + def set_show_always(self, show): + """Config option: Show this widget always. Overrides disconnected config option""" + self.show_always = show + + def set_show_only_disconnected(self, show): + """Config option: Show this widget only when connection to local discord is lost, or discord is not connected to a room""" + self.show_disconnected = show + + def get_image_name(self): + """Lookup pixbuf for given connection string""" + level = self.last + if level == ConnectionState.NO_DISCORD: + return "network-wired-disconnected" + if level == ConnectionState.DISCORD_INVALID: + return "dialog-error" + elif level == ConnectionState.NO_VOICE_CHAT: + return "network-cellular-signal-ok" + elif level == ConnectionState.VOICE_CHAT_NOT_CONNECTED: + return "network-wired-disconnected" + elif level == ConnectionState.CONNECTED: + return "network-cellular-signal-excellent" + else: + return "" + + def get_label_text(self): + """Lookup text string for state. Intentionally verbose to force i18n""" + level = self.last + if level == ConnectionState.NO_DISCORD: + return _("NO DISCORD") + elif level == ConnectionState.DISCORD_INVALID: + return _("DISCORD INVALID") + elif level == ConnectionState.NO_VOICE_CHAT: + return _("NO VOICE CHAT") + elif level == ConnectionState.VOICE_CHAT_NOT_CONNECTED: + return _("VOICE CHAT NOT CONNECTED") + elif level == ConnectionState.CONNECTED: + return _("CONNECTED") + else: + return _("ERROR") + + def set_connection(self, level): + """Set connection string. Updates image and label""" + self.last = level + self.image.set_from_icon_name(self.get_image_name()) + self.update_label() + self.update_image() + self.set_shown() + self.get_root().set_visibility() + + def should_show(self): + """Returns True if this should show in overlay, False otherwise""" + if self.last == ConnectionState.DISCORD_INVALID: + return True + if self.show_always: + return True + elif self.show_disconnected and ( + self.last == ConnectionState.NO_DISCORD + or self.last == ConnectionState.VOICE_CHAT_NOT_CONNECTED + ): + return True + return False + + def update_image(self): + """Updates the image, assuming there is changed config or info""" + if not self.overlay.show_avatar: + self.image.hide() + return + self.image.show() + self.image.set_from_icon_name(self.get_image_name()) + + def update_label(self): + """Updates the label, assuming there is changed config or info""" + self.set_shown() + if self.overlay.icon_only: + self.label.hide() + return + self.label.show() + label_text = self.get_label_text() + if len(label_text) < self.overlay.nick_length: + self.label.set_text(label_text) + else: + self.label.set_text(label_text[: (self.overlay.nick_length - 1)] + "\u2026") + + def is_user_visible(self): + return False + + +class UserBoxTitle(UserBox): + """A Widget to show user icon, name, mute & deaf state""" + + def __init__(self, overlay): + super().__init__(overlay, None) + self.show_title = True + self.last = "" + + def set_show(self, show): + """Config option: if this should be shown""" + self.show_title = show + + def set_label(self, label): + """Sets the channel title""" + self.last = label + if self.overlay.icon_only: + self.label.hide() + else: + self.label.show() + self.update_label() + self.set_shown() + + def set_image(self, image): + """Sets the channel image""" + if self.show_title: + self.show() + if self.overlay.show_avatar: + self.image.show() + self.pixbuf = image + self.image.set_from_pixbuf(self.pixbuf) + + def blank(self): + """Blanks image and hides self""" + self.pixbuf = None + self.last = None + self.set_shown() + + def should_show(self): + """If this widget should be shown""" + return self.show_title and self.last + + def update_image(self): + if not self.overlay.show_avatar: + self.image.hide() + return + self.image.show() + self.image.set_from_pixbuf(self.pixbuf) + + def update_label(self): + self.set_shown() + if self.overlay.icon_only: + self.label.hide() + return + self.label.show() + if self.last: + if len(self.last) < self.overlay.nick_length: + self.label.set_text(self.last) + else: + self.label.set_text( + self.last[: (self.overlay.nick_length - 1)] + "\u2026" + ) + + def is_user_visible(self): + return False diff --git a/discover_overlay/voice_overlay.py b/discover_overlay/voice_overlay.py index b84e517..25063de 100644 --- a/discover_overlay/voice_overlay.py +++ b/discover_overlay/voice_overlay.py @@ -14,121 +14,257 @@ import random import gettext import logging -import math -import sys import locale -from time import perf_counter import cairo -import pkg_resources -from .overlay import OverlayWindow -from .image_getter import get_surface, draw_img_to_rect, draw_img_to_mask -# pylint: disable=wrong-import-order +import json +import importlib_resources +from time import perf_counter +from .layout import get_h_align, get_v_align, HorzAlign, VertAlign +from .image_getter import get_surface +from .css_helper import font_string_to_css_font_string, col_to_css +from .userbox import UserBox, UserBoxConnection, UserBoxTitle import gi -gi.require_version('PangoCairo', '1.0') -# pylint: disable=wrong-import-position,wrong-import-order -from gi.repository import Pango, PangoCairo, GLib # nopep8 -log = logging.getLogger(__name__) +gi.require_version("Gtk", "4.0") -t = gettext.translation( - 'default', pkg_resources.resource_filename('discover_overlay', 'locales'), fallback=True) -_ = t.gettext +from gi.repository import Gtk, GLib - -class VoiceOverlayWindow(OverlayWindow): +log = logging.getLogger(__name__) +with importlib_resources.as_file( + importlib_resources.files("discover_overlay") / "locales" +) as path: + t = gettext.translation( + "default", + path, + fallback=True, + ) + _ = t.gettext + + +class VoiceOverlayWindow(Gtk.Box): """Overlay window for voice""" - def __init__(self, discover, piggyback=None): - OverlayWindow.__init__(self, discover, piggyback) + def __init__(self, discover): + Gtk.Box.__init__(self) + self.discover = discover - self.avatars = {} - self.avatar_masks = {} - - self.dummy_data = [] - mostly_false = [False, False, False, False, False, False, False, True] - for i in range(0, 100): - speaking = mostly_false[random.randint(0, 7)] - scream = '' - if random.randint(0, 20) == 2: - scream = random.randint(8, 15)*'a' - name = f"Player {i} {scream}" - self.dummy_data.append({ - "id": i, - "username": name, - "avatar": None, - "deaf": mostly_false[random.randint(0, 7)], - "mute": mostly_false[random.randint(0, 7)], - "speaking": speaking, - 'lastspoken': random.randint(2000, 2100) if speaking else random.randint(10, 30), - 'friendlyname': name, - }) + # Config initial values + self.text_x_align = "middle" + self.text_y_align = "middle" self.show_avatar = True self.avatar_size = 48 self.nick_length = 32 - self.text_pad = 6 - self.text_font = None - self.title_font = None - self.text_size = 13 - self.text_baseline_adj = 0 - self.icon_spacing = 8 - self.vert_edge_padding = 0 - self.horz_edge_padding = 0 self.only_speaking = None self.highlight_self = None self.order = None - self.def_avatar = None - self.def_avatar_mask = None - self.channel_icon = None - self.channel_mask = None - self.channel_icon_url = None self.overflow = None self.use_dummy = False self.dummy_count = 10 - self.show_title = True - self.show_connection = True - self.show_disconnected = True - self.channel_title = "" self.border_width = 2 - self.icon_transparency = 0.0 - self.fancy_border = False self.only_speaking_grace_period = 0 - + self.text_side = 3 + self.rounded_avatar = True + self.align_x = None + self.align_y = None self.fade_out_inactive = True self.fade_out_limit = 0.1 self.inactive_time = 10 # Seconds self.inactive_fade_time = 20 # Seconds - self.fade_opacity = 1.0 self.fade_start = 0 - - self.inactive_timeout = None - self.fadeout_timeout = None - - self.round_avatar = True self.icon_only = True self.talk_col = [0.0, 0.6, 0.0, 0.1] self.text_col = [1.0, 1.0, 1.0, 1.0] - self.text_hili_col = [1.0, 1.0, 1.0, 1.0] - self.norm_col = [0.0, 0.0, 0.0, 0.5] - self.wind_col = [0.0, 0.0, 0.0, 0.0] - self.mute_col = [0.7, 0.0, 0.0, 1.0] + self.mute_col = None self.mute_bg_col = [0.0, 0.0, 0.0, 0.5] - self.hili_col = [0.0, 0.0, 0.0, 0.9] self.border_col = [0.0, 0.0, 0.0, 0.0] self.avatar_bg_col = [0.0, 0.0, 1.0, 1.0] - self.userlist = [] - self.connection_status = "DISCONNECTED" - self.horizontal = False - self.guild_ids = tuple() - self.force_location() - get_surface(self.recv_avatar, - "discover-overlay-default", - 'def', self.avatar_size) - self.set_title("Discover Voice") - self.redraw() + + # Create necessary widgets + self.connection = UserBoxConnection(self) + self.title = UserBoxTitle(self) + self.append(self.title) + self.append(self.connection) + self.add_css_class("container") + self.dummy_data = [] + mostly_false = [False, False, False, False, False, False, False, True] + for i in range(0, 100): + speaking = mostly_false[random.randint(0, 7)] + scream = "" + if random.randint(0, 20) == 2: + scream = random.randint(8, 15) * "a" + name = f"Player {i} {scream}" + self.dummy_data.append( + { + "id": i, + "username": name, + "avatar": None, + "deaf": mostly_false[random.randint(0, 7)], + "mute": mostly_false[random.randint(0, 7)], + "speaking": speaking, + "lastspoken": ( + random.randint(2000, 2100) + if speaking + else random.randint(10, 30) + ), + "friendlyname": name, + } + ) + # Stored pixbufs for userdata + self.def_avatar = None + self.mutepix = None + self.deafpix = None + + # Glib timeout references + self.inactive_timeout = None + self.fadeout_timeout = None + + # Start thread to grab default avatar pixbuf + with importlib_resources.as_file( + importlib_resources.files("discover_overlay") + / "img/discover-overlay-default.png" + ) as def_path: + get_surface( + self.recv_avatar, + str(def_path), + "def", + self.get_display(), + ) + + # Set widget initial states + self.set_overflow(Gtk.Overflow.HIDDEN) + self.title.set_label(None) + self.title.update_label() + self.show() + + def recolour_icons(self): + """Reload mute and deaf icons from file system""" + with importlib_resources.as_file( + importlib_resources.files("discover_overlay") + / "img/discover-overlay-mute.png" + ) as mute_path: + get_surface( + self.recv_avatar, + str(mute_path), + "mute", + self.get_display(), + self.mute_col, + ) + with importlib_resources.as_file( + importlib_resources.files("discover_overlay") + / "img/discover-overlay-deaf.png" + ) as deaf_path: + get_surface( + self.recv_avatar, + str(deaf_path), + "deaf", + self.get_display(), + self.mute_col, + ) + + def update_all(self): + """Update each child widget, assuming changed config""" + child = self.get_first_child() + while child: + child.update_label() + child.update_image() + child = child.get_next_sibling() + + def update_all_images(self): + """Update each child widgets image, assuming changed config""" + child = self.get_first_child() + while child: + child.update_image() + child = child.get_next_sibling() + + def update_all_labels(self): + """Update each child widgets label, assuming changed config""" + child = self.get_first_child() + while child: + child.update_label() + child = child.get_next_sibling() + + def create_user_widget(self, user): + """Create a widget for the user""" + userbox = UserBox(self, user["id"]) + self.append(userbox) + return userbox + + def get_user_widget(self, userid): + """Get user widget from children""" + child = self.get_first_child() + while child: + if userid == child.userid: + return child + child = child.get_next_sibling() + return None + + def set_align_x(self, align): + """Set layout of self based on user preference""" + self.align_x = align + if align == HorzAlign.LEFT: + self.set_halign(Gtk.Align.START) + elif align == HorzAlign.MIDDLE: + self.set_halign(Gtk.Align.CENTER) + else: + self.set_halign(Gtk.Align.END) + + def set_align_y(self, align): + """Set layout of self based on user preference""" + self.align_y = align + if align == VertAlign.TOP: + self.set_valign(Gtk.Align.START) + elif align == VertAlign.MIDDLE: + self.set_valign(Gtk.Align.CENTER) + else: + self.set_valign(Gtk.Align.END) + + def get_align(self): + """Get alignment requested""" + return (self.align_x, self.align_y) + + def update_user(self, user): + """Find users widget and update details in it""" + widget = self.get_user_widget(user["id"]) + if not widget: + widget = self.create_user_widget(user) + widget.update_user_data(user) + widget.user_join() + self.get_root().set_visibility() + + def del_user(self, user): + """Hide user""" + widget = self.get_user_widget(user["id"]) + if widget: + widget.user_left() + self.get_root().set_visibility() + + def set_talking(self, userid, talking): + """Set the user as talking or not""" + widget = self.get_user_widget(userid) + if widget: + widget.set_talking(talking) + else: + log.warning("Set talking on missing user: %s", userid) + + def set_mute(self, userid, muted): + """Set the user as muted or not""" + widget = self.get_user_widget(userid) + if widget: + widget.set_mute(muted) + else: + log.warning("Set mute on missing user: %s", userid) + + def set_deaf(self, userid, deafened): + """Set the user as deafened or not""" + widget = self.get_user_widget(userid) + if widget: + widget.set_deaf(deafened) + else: + log.warning("Set deaf on missing user: %s", userid) def reset_action_timer(self): """Reset time since last voice activity""" - self.fade_opacity = 1.0 + self.set_css("fade-opacity", "") # Remove both fading-out effect and timer set last time this happened if self.inactive_timeout: @@ -141,324 +277,129 @@ def reset_action_timer(self): # If we're using this feature, schedule a new inactivity timer if self.fade_out_inactive: self.inactive_timeout = GLib.timeout_add_seconds( - self.inactive_time, self.overlay_inactive) + self.inactive_time, self.overlay_inactive + ) def overlay_inactive(self): """Timed callback when inactivity limit is hit""" self.fade_start = perf_counter() # Fade out in 200 steps over X seconds. self.fadeout_timeout = GLib.timeout_add( - self.inactive_fade_time/200 * 1000, self.overlay_fadeout) + self.inactive_fade_time / 200 * 1000, self.overlay_fadeout + ) self.inactive_timeout = None return False def overlay_fadeout(self): """Repeated callback after inactivity started""" - self.set_needs_redraw() # There's no guarantee over the granularity of the callback here, # so use our time-since to work out how faded out we should be # Might look choppy on systems under high cpu usage but that's just how it's going to be now = perf_counter() time_percent = (now - self.fade_start) / self.inactive_fade_time if time_percent >= 1.0: - self.fade_opacity = self.fade_out_limit + + fade_opacity = self.fade_out_limit self.fadeout_timeout = None + self.set_css("fade-out", ".container { opacity: %2.2f;}" % (fade_opacity)) return False - self.fade_opacity = self.fade_out_limit + \ - ((1.0 - self.fade_out_limit) * (1.0 - time_percent)) + fade_opacity = self.fade_out_limit + ( + (1.0 - self.fade_out_limit) * (1.0 - time_percent) + ) + self.set_css("fade-out", ".container { opacity: %2.2f;}" % (fade_opacity)) return True - def col(self, col, alpha=1.0): - """Convenience function to set the cairo context next colour. - Altered to account for fade-out function""" - if alpha is None: - self.context.set_source_rgba(col[0], col[1], col[2], col[3]) - else: - self.context.set_source_rgba( - col[0], col[1], col[2], col[3] * alpha * self.fade_opacity) - - def set_icon_transparency(self, trans): - """Config option: icon transparency""" - if self.icon_transparency != trans: - self.icon_transparency = trans - self.set_needs_redraw() - def set_blank(self): """Set data to blank and redraw""" - self.userlist = [] - self.channel_icon = None - self.channel_icon_url = None - self.channel_title = None - self.connection_status = "DISCONNECTED" - self.set_needs_redraw() + child = self.get_first_child() + while child: + child.user_left() + child = child.get_next_sibling() + self.title.set_label(None) + self.title.update_label() + self.get_root().set_visibility() def set_fade_out_inactive(self, enabled, fade_time, fade_duration, fade_to): """Config option: fade out options""" - if (self.fade_out_inactive != enabled or self.inactive_time != fade_time or - self.inactive_fade_time != fade_duration or self.fade_out_limit != fade_to): + if ( + self.fade_out_inactive != enabled + or self.inactive_time != fade_time + or self.inactive_fade_time != fade_duration + or self.fade_out_limit != fade_to + ): self.fade_out_inactive = enabled self.inactive_time = fade_time self.inactive_fade_time = fade_duration self.fade_out_limit = fade_to self.reset_action_timer() + def set_font(self, font): + """ + Set the font used by the overlay + """ + self.set_css("font", "* { font: %s; }" % (font_string_to_css_font_string(font))) + def set_title_font(self, font): - """Config option: font used to render title""" - if self.title_font != font: - self.title_font = font - self.set_needs_redraw() - - def set_show_connection(self, show_connection): - """Config option: show connection status alongside users""" - if self.show_connection != show_connection: - self.show_connection = show_connection - self.set_needs_redraw() - - def set_show_avatar(self, show_avatar): - """Config option: show avatar icons""" - if self.show_avatar != show_avatar: - self.show_avatar = show_avatar - self.set_needs_redraw() - - def set_show_title(self, show_title): - """Config option: show channel title alongside users""" - if self.show_title != show_title: - self.show_title = show_title - self.set_needs_redraw() - - def set_show_disconnected(self, show_disconnected): - """Config option: show even when disconnected from voice chat""" - if self.show_disconnected != show_disconnected: - self.show_disconnected = show_disconnected - self.set_needs_redraw() - - def set_show_dummy(self, show_dummy): - """Config option: Show placeholder information""" - if self.use_dummy != show_dummy: - self.use_dummy = show_dummy - self.set_needs_redraw() - - def set_dummy_count(self, dummy_count): - """Config option: Change the count of placeholders""" - if self.dummy_count != dummy_count: - self.dummy_count = dummy_count - self.set_needs_redraw() + """ + Set the font used by the overlay + """ + self.set_css( + "font", ".title { font: %s; }" % (font_string_to_css_font_string(font)) + ) def set_overflow_style(self, overflow): """Config option: Change handling of too many users to render""" if self.overflow != overflow: self.overflow = overflow - self.set_needs_redraw() - - def set_bg(self, background_colour): - """Config option: Set background colour. Used to draw the transparent window. - Should not be changed as then the entire screen is obscured""" - if self.norm_col != background_colour: - self.norm_col = background_colour - self.set_needs_redraw() - - def set_fg(self, foreground_colour): - """Config option: Set foreground colour. Used to render text""" - if self.text_col != foreground_colour: - self.text_col = foreground_colour - self.set_needs_redraw() - - def set_tk(self, talking_colour): - """Config option: Set talking border colour. - Used to render border around users who are talking""" - if self.talk_col != talking_colour: - self.talk_col = talking_colour - self.set_needs_redraw() - - def set_mt(self, mute_colour): - """Config option: Set mute colour. Used to render mute and deaf images""" - if self.mute_col != mute_colour: - self.mute_col = mute_colour - self.set_needs_redraw() - - def set_mute_bg(self, mute_bg_col): - """Config option: Set mute background colour. - Used to tint the user avatar before rendering the mute or deaf image above it""" - if self.mute_bg_col != mute_bg_col: - self.mute_bg_col = mute_bg_col - self.set_needs_redraw() - - def set_avatar_bg_col(self, avatar_bg_col): - """Config option: Set avatar background colour. - Drawn before user avatar but only visible if default fallback avatar can't be found""" - if self.avatar_bg_col != avatar_bg_col: - self.avatar_bg_col = avatar_bg_col - self.set_needs_redraw() - - def set_hi(self, highlight_colour): - """Config option: Set talking background colour. - Used to render the background behind users name.""" - if self.hili_col != highlight_colour: - self.hili_col = highlight_colour - self.set_needs_redraw() - - def set_fg_hi(self, highlight_colour): - """Config option: Set talking text colour. - Used to render the usernames of users who are talking""" - if self.text_hili_col != highlight_colour: - self.text_hili_col = highlight_colour - self.set_needs_redraw() - - def set_bo(self, border_colour): - """Config option: Set border colour. Used to render border around users""" - if self.border_col != border_colour: - self.border_col = border_colour - self.set_needs_redraw() - - def set_avatar_size(self, size): - """Config option: Set avatar size in window-space pixels""" - if self.avatar_size != size: - self.avatar_size = size - self.set_needs_redraw() - - def set_nick_length(self, size): - """Config option: Limit username length""" - if self.nick_length != size: - self.nick_length = size - self.set_needs_redraw() - - def set_icon_spacing(self, i): - """Config option: Space between users in the list, in window-space pixels""" - if self.icon_spacing != i: - self.icon_spacing = i - self.set_needs_redraw() - - def set_text_padding(self, i): - """Config option: Space between user avatar and username, in window-space pixels""" - if self.text_pad != i: - self.text_pad = i - self.set_needs_redraw() - - def set_text_baseline_adj(self, i): - """Config option: Vertical offset used to render all text, in window-space pixels""" - if self.text_baseline_adj != i: - self.text_baseline_adj = i - self.set_needs_redraw() - - def set_vert_edge_padding(self, i): - """Config option: Vertical offset from edge of window, in window-space pixels""" - if self.vert_edge_padding != i: - self.vert_edge_padding = i - self.set_needs_redraw() - - def set_horz_edge_padding(self, i): - """Config option: Horizontal offset from edge of window, in window-space pixels""" - if self.horz_edge_padding != i: - self.horz_edge_padding = i - self.set_needs_redraw() - - def set_square_avatar(self, i): - """Config option: Mask avatar with a circle before rendering""" - if self.round_avatar == i: - self.round_avatar = not i - self.set_needs_redraw() - - def set_fancy_border(self, border): - """Config option: Use transparent edges of image as border, - instead of mask (square/circle)""" - if self.fancy_border != border: - self.fancy_border = border - self.set_needs_redraw() - - def set_only_speaking(self, only_speaking): - """Config option: Filter user list to only those who - are talking and those who have stopped talking recently""" - if self.only_speaking != only_speaking: - self.only_speaking = only_speaking - self.set_needs_redraw() - - def set_only_speaking_grace_period(self, grace_period): - """Config option: How long after stopping speaking the user remains shown""" - self.only_speaking_grace_period = grace_period - self.timer_after_draw = grace_period - - def set_highlight_self(self, highlight_self): - """Config option: Local User should be kept at top of list""" - if self.highlight_self != highlight_self: - self.highlight_self = highlight_self - self.set_needs_redraw() - - def set_order(self, i): - """Config option: Set method used to order user list""" - if self.order != i: - self.order = i - self.sort_list(self.userlist) - self.set_needs_redraw() - - def set_icon_only(self, i): - """Config option: Show only the avatar, without text or its background""" - if self.icon_only != i: - self.icon_only = i - self.set_needs_redraw() - - def set_drawn_border_width(self, width): - """Config option: Set width of border around username and avatar""" - if self.border_width != width: - self.border_width = width - self.set_needs_redraw() - - def set_horizontal(self, horizontal=False): - """Config option: Userlist should be drawn horizontally""" - if self.horizontal != horizontal: - self.horizontal = horizontal - self.set_needs_redraw() - - def set_wind_col(self): - """Use window colour to draw""" - self.col(self.wind_col, None) - - def set_norm_col(self): - """Use background colour to draw""" - self.col(self.norm_col) - - def set_talk_col(self, alpha=1.0): - """Use talking colour to draw""" - self.col(self.talk_col, alpha) - - def set_mute_col(self): - """Use mute colour to draw""" - self.col(self.mute_col) + + def set_borders(self): + """Update all border CSS rules based on config""" + width = self.border_width + col = col_to_css(self.border_col) + talk_col = col_to_css(self.talk_col) + rounded = "border-radius: 50%;" if self.rounded_avatar else "" + + drop_shadow_normal = "" + drop_shadow_talking = "" + for i in range(-width, width + 1): + for j in range(-width, width + 1): + drop_shadow_talking += f" drop-shadow({i}px {j}px 0 {talk_col})" + drop_shadow_normal += f" drop-shadow({i}px {j}px 0 {col})" + + self.set_css( + "talking-border", + f""" + .talking.user + {{ + filter: {drop_shadow_talking}; + }} + .user + {{ + filter: {drop_shadow_normal}; + }} + .usericon, .usermute, .userdeaf + {{ + {rounded} + }} + .container {{ padding: {width*2}px; }} + """, + ) def set_channel_title(self, channel_title): """Set title above voice list""" - if self.channel_title != channel_title: - self.channel_title = channel_title - self.set_needs_redraw() + self.title.set_label(channel_title) def set_channel_icon(self, url): """Change the icon for channel""" if not url: - self.channel_icon = None - self.channel_icon_url = None + self.title.blank() else: - get_surface(self.recv_avatar, url, "channel", - self.avatar_size) - self.channel_icon_url = url - - def set_user_list(self, userlist, alt): - """Set the users in list to draw""" - self.userlist = userlist - for user in userlist: - if "nick" in user: - user["friendlyname"] = user["nick"] - else: - user["friendlyname"] = user["username"] - self.sort_list(self.userlist) - if alt: - self.reset_action_timer() - self.set_needs_redraw() + get_surface(self.recv_avatar, url, "channel", self.get_display()) def set_connection_status(self, connection): """Set if discord has a clean connection to server""" - if self.connection_status != connection['state']: - self.connection_status = connection['state'] - self.set_needs_redraw() + self.connection.set_connection(connection) def sort_list(self, in_list): """Take a userlist and sort it according to config option""" @@ -468,695 +409,205 @@ def sort_list(self, in_list): in_list.sort(key=lambda x: x["lastspoken"], reverse=True) in_list.sort(key=lambda x: x["speaking"], reverse=True) else: # Name sort - in_list.sort(key=lambda x: locale.strxfrm(x['friendlyname'])) + in_list.sort(key=lambda x: locale.strxfrm(x["friendlyname"])) return in_list - def has_content(self): + def should_show(self): """Returns true if overlay has meaningful content to render""" - if not self.enabled: - return False - if self.hidden: - return False + if self.connection.show_always: + return True + if self.connection.should_show(): + return True if self.use_dummy: return True - return self.userlist - - def overlay_draw(self, w, context, data=None): - """Draw the Overlay""" - self.context = context - context.set_antialias(cairo.ANTIALIAS_GOOD) - # Get size of window - (width, height) = self.get_size() - - # Make background transparent - self.set_wind_col() - # Don't layer drawing over each other, always replace - context.set_operator(cairo.OPERATOR_SOURCE) - context.paint() - context.save() - if self.piggyback: - self.piggyback.overlay_draw(w, context, data) - (floating_x, floating_y, floating_width, - floating_height) = self.get_floating_coords() - if self.is_wayland or self.piggyback_parent or self.discover.steamos: - # Special case! - # The window is full-screen regardless of what the user has selected. - # We need to set a clip and a transform to imitate original behaviour - # Used in wlroots & gamescope - - if self.floating: - context.new_path() - context.translate(floating_x, floating_y) - context.rectangle(0, 0, floating_width, floating_height) - context.clip() - - context.set_operator(cairo.OPERATOR_OVER) - if (not self.show_disconnected and self.connection_status == "DISCONNECTED" - and not self.use_dummy): - return - - connection = self.discover.connection - if not connection: - return - self_user = connection.user - - # Gather which users to draw - users_to_draw = self.userlist[:] - userlist = self.userlist - if self.use_dummy: # Sorting every frame is an awful idea. Maybe put this off elsewhere? - users_to_draw = self.sort_list(self.dummy_data[0:self.dummy_count]) - userlist = self.dummy_data - now = perf_counter() + child = self.get_first_child() + while child: + if child.is_user_visible(): + return True + child = child.get_next_sibling() + return False - for user in userlist: - # Bad object equality here, so we need to reassign - if "id" in self_user and user["id"] == self_user["id"]: - self_user = user - - # Update friendly name with nick if possible - if "nick" in user: - user["friendlyname"] = user["nick"] - else: - user["friendlyname"] = user["username"] - - # Remove users that haven't spoken within the grace period - if self.only_speaking: - speaking = "speaking" in user and user["speaking"] - - # Extend timer if mid-speaking - if self.highlight_self and self_user == user: - continue - if speaking: - user['lastspoken'] = perf_counter() - else: - grace = self.only_speaking_grace_period - - if ( - grace > 0 - and (last_spoke := user['lastspoken']) - and (now - last_spoke) < grace - ): - # The user spoke within the grace period, so don't hide - # them just yet - pass - - elif user in users_to_draw: - users_to_draw.remove(user) - - if self.highlight_self: - if self_user in users_to_draw: - users_to_draw.remove(self_user) - users_to_draw.insert(0, self_user) - - avatar_size = self.avatar_size if self.show_avatar else 0 - line_height = self.avatar_size - avatars_per_row = sys.maxsize - - # Calculate height needed to show overlay - do_title = False - do_connection = False - if self.show_connection: - users_to_draw.insert(0, None) - do_connection = True - if self.show_title and self.channel_title: - users_to_draw.insert(0, None) - do_title = True - - if self.horizontal: - needed_width = (len(users_to_draw) * line_height) + \ - (len(users_to_draw) + 1) * self.icon_spacing - - if needed_width > width: - if self.overflow == 1: # Wrap - avatars_per_row = int( - width / (avatar_size+self.icon_spacing)) - elif self.overflow == 2: # Shrink - available_size = width / len(users_to_draw) - avatar_size = available_size - self.icon_spacing - if avatar_size < 8: - avatar_size = 8 - - current_y = 0 + self.vert_edge_padding - offset_y = avatar_size + self.icon_spacing - if self.align_right: # A lie. Align bottom - current_y = (height - avatar_size) - self.vert_edge_padding - offset_y = -(avatar_size + self.icon_spacing) - rows_to_draw = [] - while len(users_to_draw) > 0: - row = [] - for _i in range(0, min(avatars_per_row, len(users_to_draw))): - row.append(users_to_draw.pop(0)) - rows_to_draw.append(row) - for row in rows_to_draw: - needed_width = (len(row) * (line_height + self.icon_spacing)) - current_x = 0 + self.horz_edge_padding - if self.align_vert == 1: - current_x = (width / 2) - (needed_width) / 2 - elif self.align_vert == 2: - current_x = width - needed_width - self.horz_edge_padding - - for user in row: - if not user: - if do_title: - do_title = False - text_width = self.draw_title( - context, current_x, current_y, avatar_size, line_height) - elif do_connection: - text_width = self.draw_connection( - context, current_x, current_y, avatar_size, line_height) - do_connection = False - else: - self.draw_avatar(context, user, current_x, - current_y, avatar_size, line_height) - current_x += avatar_size + self.icon_spacing - current_y += offset_y - else: - needed_height = ((len(users_to_draw)+0) * line_height) + \ - (len(users_to_draw) + 1) * self.icon_spacing - - if needed_height > height: - if self.overflow == 1: # Wrap - avatars_per_row = int( - height / (avatar_size + self.icon_spacing)) - elif self.overflow == 2: # Shrink - available_size = height / len(users_to_draw) - avatar_size = available_size - self.icon_spacing - if avatar_size < 8: - avatar_size = 8 - - current_x = 0 + self.horz_edge_padding - offset_x_mult = 1 - offset_x = avatar_size + self.horz_edge_padding - if self.align_right: - offset_x_mult = -1 - current_x = floating_width - avatar_size - self.horz_edge_padding - - # Choose where to start drawing - current_y = 0 + self.vert_edge_padding - if self.align_vert == 1: - current_y = (height / 2) - (needed_height / 2) - elif self.align_vert == 2: - current_y = height - needed_height - self.vert_edge_padding - - cols_to_draw = [] - while len(users_to_draw) > 0: - col = [] - for _i in range(0, min(avatars_per_row, len(users_to_draw))): - col.append(users_to_draw.pop(0)) - cols_to_draw.append(col) - for col in cols_to_draw: - needed_height = (len(col) * (line_height + self.icon_spacing)) - current_y = 0 + self.vert_edge_padding - if self.align_vert == 1: - current_y = (height/2) - (needed_height / 2) - elif self.align_vert == 2: - current_y = height - needed_height - self.vert_edge_padding - largest_text_width = 0 - for user in col: - if not user: - if do_title: - # Draw header - text_width = self.draw_title( - context, current_x, current_y, avatar_size, line_height) - largest_text_width = max( - text_width, largest_text_width) - current_y += line_height + self.icon_spacing - do_title = False - elif do_connection: - # Draw header - text_width = self.draw_connection( - context, current_x, current_y, avatar_size, line_height) - largest_text_width = max( - text_width, largest_text_width) - current_y += line_height + self.icon_spacing - do_connection = False - - else: - text_width = self.draw_avatar( - context, user, current_x, current_y, avatar_size, line_height) - largest_text_width = max( - text_width, largest_text_width) - current_y += line_height + self.icon_spacing - if largest_text_width > 0: - largest_text_width += self.text_pad - else: - largest_text_width = self.icon_spacing - current_x += offset_x_mult * (offset_x + largest_text_width) - - context.restore() - self.context = None - - def recv_avatar(self, identifier, pix, mask): + def recv_avatar(self, identifier, pix): """Called when image_getter has downloaded an image""" - if identifier == 'def': + if identifier == "def": self.def_avatar = pix - self.def_avatar_mask = mask - elif identifier == 'channel': - self.channel_icon = pix - self.channel_mask = mask - else: - self.avatars[identifier] = pix - self.avatar_masks[identifier] = mask - self.set_needs_redraw() - - def delete_avatar(self, identifier): - """Remove avatar image""" - if identifier in self.avatars: - del self.avatars[identifier] - - def draw_title(self, context, pos_x, pos_y, avatar_size, line_height): - """Draw title at given Y position. Includes both text and image based on settings""" - tw = 0 - if not self.horizontal and not self.icon_only: - title = self.channel_title - if self.use_dummy: - title = "Dummy Title" - tw = self.draw_text( - context, title, - pos_x, - pos_y, - self.text_col, - self.norm_col, - avatar_size, - line_height, - self.title_font - ) - if self.channel_icon: - self.draw_avatar_pix(context, self.channel_icon, self.channel_mask, - pos_x, pos_y, None, avatar_size) - else: - self.blank_avatar(context, pos_x, pos_y, avatar_size) - if self.channel_icon_url: - get_surface(self.recv_avatar, self.channel_icon_url, "channel", - self.avatar_size) - return tw - - def unused_fn_needed_translations(self): - """ - These are here to force them to be picked up for translations - - They're fed right through from Discord client as string literals - """ - _("DISCONNECTED") - _("NO_ROUTE") - _("VOICE_DISCONNECTED") - _("ICE_CHECKING") - _("AWAITING_ENDPOINT") - _("AUTHENTICATING") - _("CONNECTING") - _("CONNECTED") - _("VOICE_CONNECTING") - _("VOICE_CONNECTED") - - def draw_connection(self, context, pos_x, pos_y, avatar_size, line_height): - """Draw title at given Y position. Includes both text and image based on settings""" - tw = 0 - if not self.horizontal and not self.icon_only: - tw = self.draw_text( - context, _(self.connection_status), - pos_x, - pos_y, - self.text_col, - self.norm_col, - avatar_size, - line_height, - self.text_font - ) - self.blank_avatar(context, pos_x, pos_y, avatar_size) - self.draw_connection_icon(context, pos_x, pos_y, avatar_size) - return tw - - def draw_avatar(self, context, user, pos_x, pos_y, avatar_size, line_height): - """Draw avatar at given Y position. Includes both text and image based on settings""" - # Ensure pixbuf for avatar - if user["id"] not in self.avatars and user["avatar"] and avatar_size > 0: - url = f"https://cdn.discordapp.com/avatars/{user['id']}/{user['avatar']}.png" - get_surface(self.recv_avatar, url, user["id"], - self.avatar_size) - - # Set the key with no value to avoid spamming requests - self.avatars[user["id"]] = None - self.avatar_masks[user["id"]] = None - - colour = None - mute = False - deaf = False - bg_col = None - fg_col = None - tw = 0 - - if "mute" in user and user["mute"]: - mute = True - if "deaf" in user and user["deaf"]: - deaf = True - if "speaking" in user and user["speaking"] and not deaf and not mute: - colour = self.talk_col - if "speaking" in user and user["speaking"] and not deaf and not mute: - bg_col = self.hili_col - fg_col = self.text_hili_col - else: - bg_col = self.norm_col - fg_col = self.text_col - - pix = None - mask = None - if user["id"] in self.avatars: - pix = self.avatars[user["id"]] - mask = self.avatar_masks[user["id"]] - if not self.horizontal: - if not self.icon_only: - tw = self.draw_text( - context, user["friendlyname"], - pos_x, - pos_y, - fg_col, - bg_col, - avatar_size, - line_height, - self.text_font - ) - self.draw_avatar_pix(context, pix, mask, pos_x, - pos_y, colour, avatar_size) - if deaf: - self.draw_deaf(context, pos_x, pos_y, - self.mute_bg_col, avatar_size) - elif mute: - self.draw_mute(context, pos_x, pos_y, - self.mute_bg_col, avatar_size) - return tw - - def draw_text(self, context, string, pos_x, pos_y, - tx_col, bg_col, avatar_size, line_height, font): - """Draw username & background at given position""" - if self.nick_length < 32 and len(string) > self.nick_length: - string = string[:(self.nick_length-1)] + "\u2026" - - context.save() - layout = self.create_pango_layout(string) - layout.set_auto_dir(True) - layout.set_markup(string, -1) - (_floating_x, _floating_y, floating_width, - _floating_height) = self.get_floating_coords() - layout.set_width(Pango.SCALE * floating_width) - layout.set_spacing(Pango.SCALE * 3) + self.update_all_images() + elif identifier == "mute": + self.mutepix = pix + self.update_all_images() + elif identifier == "deaf": + self.deafpix = pix + self.update_all_images() + elif identifier == "channel": + self.title.set_image(pix) + + def set_config(self, config): + """Update self and children from config""" + horizontal = config.getboolean("horizontal", fallback=False) + + self.set_align_x(get_h_align(config.get("align_x", fallback="middle"))) + self.set_align_y(get_v_align(config.get("align_y", fallback="top"))) + + mute_col = json.loads(config.get("mt_col", fallback="[0.6,0.0,0.0,1.0]")) + if self.mute_col != mute_col: + self.mute_col = mute_col + self.recolour_icons() + + # Text colour + self.set_css( + "foreground-color", + "* { color: " + + col_to_css(config.get("fg_col", fallback="[1.0,1.0,1.0,1.0]")) + + ";}", + ) + # Text colour while talking + self.set_css( + "talking-text", + ".talking .userlabel { color: " + + col_to_css(config.get("fg_hi_col", fallback="[1.0,1.0,1.0,1.0]")) + + ";}", + ) + self.talk_col = json.loads(config.get("tk_col", fallback="[0.0,0.7,0.0,0.2]")) + # Background colour + self.set_css( + "background-color", + ".usericon, .userlabel { background-color: " + + col_to_css(config.get("bg_col", fallback="[0.0,0.0,0.0,0.2]")) + + ";}", + ) + # Mute/deaf background colour + m_bg_col = col_to_css(config.get("mt_bg_col", fallback=[0.0, 0.0, 0.0, 0.5])) + self.set_css( + "mute-background", + f""" + .usermute, .userdeaf + {{ + filter: drop-shadow(-3px -3px {m_bg_col}) + drop-shadow(3px -3px {m_bg_col}) + drop-shadow(-3px 3px {m_bg_col}) + drop-shadow(3px 3px {m_bg_col}); + }}""", + ) + self.set_css( + "talking-background", + ".talking .userlabel, .talking .usericon { background-color: " + + col_to_css(config.get("hi_col", fallback="[0.0,0.0,0.0,0.5]")) + + ";}", + ) + + self.rounded_avatar = not config.getboolean("square_avatar", True) + + self.border_col = json.loads(config.get("bo_col", fallback="[0.0,0.0,0.0,0.0]")) + self.set_css( + "avatar-bg-color", + ".usericon { background-color: " + + col_to_css(config.get("avatar_bg_col", fallback="[0.0,0.0,0.0,0.0]")) + + ";}", + ) + + self.avatar_size = config.getint("avatar_size", fallback=48) + self.set_css( + "avatar_size", + ".usericon, .usermute, .userdeaf { -gtk-icon-size:%spx; }" + % (self.avatar_size), + ) + + self.nick_length = config.getint("nick_length", fallback=32) + + self.set_spacing(config.getint("icon_spacing", fallback=8)) + + self.set_css( + "text_padding", + ".userlabel { padding: %spx; }" + % (config.getint("text_padding", fallback=6)), + ) + + self.only_speaking = config.getboolean("only_speaking", fallback=False) + + self.only_speaking_grace_period = config.getint( + "only_speaking_grace", fallback=0 + ) + self.highlight_self = config.getboolean("highlight_self", fallback=False) + self.icon_only = config.getboolean("icon_only", fallback=False) + + vert_edge_padding = config.getint("vert_edge_padding", fallback=0) + self.set_css( + "vertical-edge", + ".container { margin-top: %spx; margin-bottom: %spx;}" + % (vert_edge_padding, vert_edge_padding), + ) + horz_edge_padding = config.getint("horz_edge_padding", fallback=0) + self.set_css( + "horizontal-edge", + ".container { margin-left: %spx; margin-right: %spx;}" + % (horz_edge_padding, horz_edge_padding), + ) + self.order = config.getint("order", fallback=0) + + self.set_orientation( + Gtk.Orientation.HORIZONTAL if horizontal else Gtk.Orientation.VERTICAL + ) + + self.connection.set_show_always( + config.getboolean("show_connection", fallback=False) + ) + + self.title.set_show(config.getboolean("show_title", fallback=False)) + + self.text_side = config.getint("text_side", fallback=3) + + self.connection.set_show_only_disconnected( + config.getboolean("show_disconnected", fallback=True) + ) + self.border_width = config.getint("border_width", fallback=2) + + self.show_avatar = config.getboolean("show_avatar", fallback=True) + + self.set_css( + "icon_transparency", + ".usericon { opacity: %2.2f; }" + % (config.getfloat("icon_transparency", fallback=1.0)), + ) + + self.use_dummy = config.getboolean("show_dummy", fallback=False) + + self.dummy_count = config.getint("dummy_count", fallback=10) + + font = config.get("font", fallback=None) + title_font = config.get("title_font", fallback=None) if font: - font = Pango.FontDescription(font) - layout.set_font_description(font) - (ink_rect, logical_rect) = layout.get_pixel_extents() - text_height = logical_rect.height - text_width = logical_rect.width - - self.col(tx_col) - height_offset = (line_height / 2) - (text_height / 2) - text_y_offset = height_offset + self.text_baseline_adj - - if self.align_right: - context.move_to(0, 0) - self.col(bg_col) - context.rectangle( - pos_x - text_width - (self.text_pad * 2), - pos_y + height_offset - self.text_pad, - text_width + (self.text_pad * 4), - text_height + (self.text_pad * 2) - ) - context.fill() - - self.col(tx_col) - context.move_to( - pos_x - text_width - self.text_pad - ink_rect.x, - pos_y + text_y_offset - ) - layout.set_alignment(Pango.Alignment.RIGHT) - PangoCairo.show_layout(self.context, layout) - else: - context.move_to(0, 0) - self.col(bg_col) - context.rectangle( - pos_x - (self.text_pad * 2) + avatar_size, - pos_y + height_offset - self.text_pad, - text_width + (self.text_pad * 4), - text_height + (self.text_pad * 2) - ) - context.fill() - - self.col(tx_col) - context.move_to( - pos_x + self.text_pad + avatar_size- ink_rect.x, - pos_y + text_y_offset + self.set_font(font) + if title_font: + self.set_title_font(title_font) + + self.text_x_align = config.get("text_x_align", fallback="middle") + self.text_y_align = config.get("text_y_align", fallback="middle") + + self.set_borders() + + self.set_fade_out_inactive( + config.getboolean("fade_out_inactive", fallback=False), + config.getint("inactive_time", fallback=10), + config.getint("inactive_fade_time", fallback=30), + config.getfloat("fade_out_limit", fallback=0.3), + ) + + self.update_all() + + def set_css(self, css_id, rule): + """Add or replace CSS Rule""" + self.get_root().set_css(css_id, rule) + + def get_boxes(self): + """Return a list of cairo.RectangleInt which are bounding boxes of widgets in view""" + boxes = [] + child = self.get_first_child() + while child: + box = child.get_allocation() + # pylint: disable=E1101 + region = cairo.RectangleInt( + x=box.x, y=box.y, width=box.width, height=box.height ) - layout.set_alignment(Pango.Alignment.LEFT) - PangoCairo.show_layout(self.context, layout) - context.restore() - return text_width - - def blank_avatar(self, context, pos_x, pos_y, avatar_size): - """Draw a cut-out of the previous shape with a forcible transparent hole""" - context.save() - if self.round_avatar: - context.arc(pos_x + (avatar_size / 2), pos_y + - (avatar_size / 2), avatar_size / 2, 0, 2 * math.pi) - context.clip() - self.col(self.avatar_bg_col) - context.set_operator(cairo.OPERATOR_SOURCE) - context.rectangle(pos_x, pos_y, avatar_size, avatar_size) - context.fill() - context.restore() - - def draw_avatar_pix(self, context, pixbuf, mask, pos_x, pos_y, border_colour, avatar_size): - """Draw avatar image at given position""" - if not self.show_avatar: - return - # Empty the space for this - self.blank_avatar(context, pos_x, pos_y, avatar_size) - - # fallback default or fallback further to no image here - if not pixbuf: - pixbuf = self.def_avatar - if not pixbuf: - return - if not mask: - mask = self.def_avatar_mask - if not mask: - return - - # Draw the "border" by doing a scaled-up copy in a flat colour - if border_colour: - self.col(border_colour) - if self.fancy_border: - context.set_operator(cairo.OPERATOR_SOURCE) - for off_x in range(-self.border_width, self.border_width+1): - for off_y in range(-self.border_width, self.border_width+1): - context.save() - if self.round_avatar: - context.new_path() - context.arc(pos_x + off_x + (avatar_size / 2), pos_y + off_y + - (avatar_size / 2), avatar_size / 2, 0, 2 * math.pi) - context.clip() - draw_img_to_mask(mask, context, pos_x + off_x, pos_y + off_y, - avatar_size, avatar_size) - context.restore() - else: - if self.round_avatar: - context.new_path() - context.arc(pos_x + (avatar_size / 2), pos_y + - (avatar_size / 2), avatar_size / 2 + - (self.border_width/2.0), 0, 2 * math.pi) - context.set_line_width(self.border_width) - context.stroke() - else: - context.new_path() - context.rectangle(pos_x - (self.border_width/2), - pos_y - (self.border_width/2), - avatar_size + self.border_width, - avatar_size + self.border_width) - context.set_line_width(self.border_width) - - context.stroke() - - # Cut the image back out - context.save() - if self.round_avatar: - context.new_path() - context.arc(pos_x + (avatar_size / 2), pos_y + - (avatar_size / 2), avatar_size / 2, 0, 2 * math.pi) - context.clip() - self.col([0.0, 0.0, 0.0, 0.0]) - context.set_operator(cairo.OPERATOR_SOURCE) - draw_img_to_mask(mask, context, pos_x, pos_y, - avatar_size, avatar_size) - context.restore() - # Draw the image - context.save() - if self.round_avatar: - context.new_path() - context.arc(pos_x + (avatar_size / 2), pos_y + - (avatar_size / 2), avatar_size / 2, 0, 2 * math.pi) - context.clip() - context.set_operator(cairo.OPERATOR_OVER) - draw_img_to_rect(pixbuf, context, pos_x, pos_y, - avatar_size, avatar_size, False, False, 0, 0, - self.fade_opacity * self.icon_transparency) - context.restore() - - def draw_mute(self, context, pos_x, pos_y, bg_col, avatar_size): - """Draw Mute logo""" - if avatar_size <= 0: - return - context.save() - context.translate(pos_x, pos_y) - context.scale(avatar_size, avatar_size) - - # Add a dark background - context.set_operator(cairo.OPERATOR_ATOP) - context.rectangle(0.0, 0.0, 1.0, 1.0) - self.col(bg_col, None) - context.fill() - context.set_operator(cairo.OPERATOR_OVER) - - self.set_mute_col() - context.save() - - # Clip Strike-through - context.new_path() - context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) - context.set_line_width(0.1) - context.move_to(0.0, 0.0) - context.line_to(1.0, 0.0) - context.line_to(1.0, 1.0) - context.line_to(0.0, 1.0) - context.line_to(0.0, 0.0) - context.close_path() - context.new_sub_path() - context.arc(0.9, 0.1, 0.05, 1.25 * math.pi, 2.25 * math.pi) - context.arc(0.1, 0.9, 0.05, .25 * math.pi, 1.25 * math.pi) - context.close_path() - context.clip() - - # Center - context.set_line_width(0.07) - context.arc(0.5, 0.3, 0.1, math.pi, 2 * math.pi) - context.arc(0.5, 0.5, 0.1, 0, math.pi) - context.close_path() - context.fill() - - context.set_line_width(0.05) - - # Stand rounded - context.arc(0.5, 0.5, 0.15, 0, 1.0 * math.pi) - context.stroke() - - # Stand vertical - context.move_to(0.5, 0.65) - context.line_to(0.5, 0.75) - context.stroke() - - # Stand horizontal - context.move_to(0.35, 0.75) - context.line_to(0.65, 0.75) - context.stroke() - - context.restore() - # Strike through - context.arc(0.7, 0.3, 0.035, 1.25 * math.pi, 2.25 * math.pi) - context.arc(0.3, 0.7, 0.035, .25 * math.pi, 1.25 * math.pi) - context.close_path() - context.fill() - context.set_fill_rule(cairo.FILL_RULE_WINDING) - - context.restore() - - def draw_deaf(self, context, pos_x, pos_y, bg_col, avatar_size): - """Draw deaf logo""" - if avatar_size <= 0: - return - context.save() - context.translate(pos_x, pos_y) - context.scale(avatar_size, avatar_size) - - # Add a dark background - context.set_operator(cairo.OPERATOR_ATOP) - context.rectangle(0.0, 0.0, 1.0, 1.0) - self.col(bg_col, None) - context.fill() - context.set_operator(cairo.OPERATOR_OVER) - - self.set_mute_col() - context.save() - - # Clip Strike-through - context.new_path() - context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) - context.set_line_width(0.1) - context.move_to(0.0, 0.0) - context.line_to(1.0, 0.0) - context.line_to(1.0, 1.0) - context.line_to(0.0, 1.0) - context.line_to(0.0, 0.0) - context.close_path() - context.new_sub_path() - context.arc(0.9, 0.1, 0.05, 1.25 * math.pi, 2.25 * math.pi) - context.arc(0.1, 0.9, 0.05, .25 * math.pi, 1.25 * math.pi) - context.close_path() - context.clip() - - # Top band - context.arc(0.5, 0.5, 0.2, 1.0 * math.pi, 0) - context.stroke() - - # Left band - context.arc(0.28, 0.65, 0.075, 1.5 * math.pi, 0.5 * math.pi) - context.move_to(0.3, 0.5) - context.line_to(0.3, 0.75) - context.stroke() - - # Right band - context.arc(0.72, 0.65, 0.075, 0.5 * math.pi, 1.5 * math.pi) - context.move_to(0.7, 0.5) - context.line_to(0.7, 0.75) - context.stroke() - - context.restore() - # Strike through - context.arc(0.7, 0.3, 0.035, 1.25 * math.pi, 2.25 * math.pi) - context.arc(0.3, 0.7, 0.035, .25 * math.pi, 1.25 * math.pi) - context.close_path() - context.fill() - context.set_fill_rule(cairo.FILL_RULE_WINDING) - - context.restore() - - def draw_connection_icon(self, context, pos_x, pos_y, avatar_size): - """Draw a series of bars to show connectivity state""" - context.save() - context.translate(pos_x, pos_y) - context.scale(avatar_size, avatar_size) - - bars = 0 - s = self.connection_status - if s == "DISCONNECTED" or s == "NO_ROUTE" or s == "VOICE_DISCONNECTED": - bars = 0 - self.col([1.0, 0.0, 0.0, 1.0]) - elif s == "ICE_CHECKING" or s == "AWAITING_ENDPOINT" or s == "AUTHENTICATING": - bars = 1 - self.col([1.0, 0.0, 0.0, 1.0]) - elif s == "CONNECTING" or s == "CONNECTED" or s == "VOICE_CONNECTING": - bars = 2 - self.col([1.0, 1.0, 0.0, 1.0]) - elif s == "VOICE_CONNECTED": - bars = 3 - self.col([0.0, 1.0, 0.0, 1.0]) - context.set_line_width(0.1) - - if bars >= 1: - context.move_to(0.3, 0.8) - context.line_to(0.3, 0.6) - context.stroke() - if bars >= 2: - context.move_to(0.5, 0.8) - context.line_to(0.5, 0.4) - context.stroke() - if bars == 3: - context.move_to(0.7, 0.8) - context.line_to(0.7, 0.2) - context.stroke() - context.restore() + boxes.append(region) + child = child.get_next_sibling() + return boxes diff --git a/setup.py b/setup.py index 470e9fd..93bc0d8 100644 --- a/setup.py +++ b/setup.py @@ -1,58 +1,76 @@ from setuptools import setup, find_namespace_packages +from _version import __version__ def readme(): - return open('README.md', 'r').read() + return open("README.md", "r", encoding="utf-8").read() setup( - name='discover-overlay', - author='trigg', - author_email='', - version='0.7.9', - description='Voice chat overlay', + name="discover-overlay", + author="trigg", + author_email="", + version=__version__, + description="Voice chat overlay", long_description=readme(), - long_description_content_type='text/markdown', - url='https://github.com/trigg/Discover', + long_description_content_type="text/markdown", + url="https://github.com/trigg/Discover", packages=find_namespace_packages(), include_package_data=True, data_files=[ - ('share/applications', [ - 'discover_overlay.desktop', 'discover_overlay_configure.desktop' - ]), - ('share/icons/hicolor/256x256/apps', - ['discover-overlay.png', 'discover-overlay-tray.png', 'discover-overlay-default.png']), - ('share/icons/hicolor/scalable/apps', - ['discover-overlay.svg', 'discover-overlay-tray.svg', 'discover-overlay-default.svg']) + ( + "share/applications", + ["discover_overlay.desktop", "discover_overlay_configure.desktop"], + ), + ( + "share/icons/hicolor/256x256/apps", + [ + "discover-overlay.png", + "discover-overlay-tray.png", + "discover-overlay-default.png", + ], + ), + ( + "share/icons/hicolor/scalable/apps", + [ + "discover-overlay.svg", + "discover-overlay-tray.svg", + "discover-overlay-default.svg", + ], + ), ], install_requires=[ - 'PyGObject>=3.22', - 'websocket-client', - 'pyxdg', - 'requests', - 'pillow', - 'python-xlib', - 'setuptools', - 'pulsectl-asyncio' + "PyGObject>=3.22", + "websocket-client", + "pyxdg", + "requests", + "pillow", + "python-xlib", + "setuptools", + "pulsectl-asyncio", + "importlib_resources", + "cairo", + "ewmh", + "pillow", ], entry_points={ - 'console_scripts': [ - 'discover-overlay = discover_overlay.discover_overlay:entrypoint', + "console_scripts": [ + "discover-overlay = discover_overlay.discover_overlay:entrypoint", ] }, classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: X11 Applications :: GTK', - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python :: 3 :: Only', - 'Topic :: Communications :: Chat', - 'Topic :: Communications :: Conferencing', + "Development Status :: 4 - Beta", + "Environment :: X11 Applications :: GTK", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Communications :: Chat", + "Topic :: Communications :: Conferencing", ], package_data={ - 'discover_overlay': ['locales/*/LC_MESSAGES/*.mo', 'glade/*'] + "discover_overlay": ["locales/*/LC_MESSAGES/*.mo", "glade/*", "img/*"] }, - keywords='discord overlay voice linux', - license='GPLv3+', + keywords="discord overlay voice linux", + license="GPLv3+", )