From 853fa298d32be639e2c982eeefcb576900b0e71a Mon Sep 17 00:00:00 2001 From: trigg Date: Thu, 4 Sep 2025 18:03:13 +0100 Subject: [PATCH 01/10] - Gtk dependency pushed up to 4.0 - Ripped out entire Cairo code paths - Removed features from voice overlay - - Circle avatars removed - - Right align split into two options - - Removed text baseline adjustment - - Floating window positions - - Removed user overflow options - Reorganised config reading into each overlay - Broken and not yet fixed: - - voice: Only speaking & speaking grace - - voice: Hide on mouseover - - text: All of it - - notification: All of it - - settings: monitor selection - - settings: systemtray & menu - - x11: all support --- .pylintrc | 643 +++- CODING | 4 +- _version.py | 1 + discover_overlay/audio_assist.py | 25 +- discover_overlay/autostart.py | 56 +- discover_overlay/discord_connector.py | 276 +- discover_overlay/discover_overlay.py | 349 +-- discover_overlay/draggable_window.py | 178 -- discover_overlay/draggable_window_wayland.py | 211 -- discover_overlay/font_helper.py | 40 + discover_overlay/glade/settings.glade | 2847 ------------------ discover_overlay/glade/settings.xml | 2283 ++++++++++++++ discover_overlay/image_getter.py | 242 +- discover_overlay/notification_overlay.py | 442 +-- discover_overlay/overlay.py | 466 ++- discover_overlay/settings_window.py | 1034 +++---- discover_overlay/text_overlay.py | 261 +- discover_overlay/userbox.py | 330 ++ discover_overlay/voice_overlay.py | 1362 +++------ setup.py | 89 +- 20 files changed, 4915 insertions(+), 6224 deletions(-) create mode 100644 _version.py delete mode 100644 discover_overlay/draggable_window.py delete mode 100644 discover_overlay/draggable_window_wayland.py create mode 100644 discover_overlay/font_helper.py delete mode 100644 discover_overlay/glade/settings.glade create mode 100644 discover_overlay/glade/settings.xml create mode 100644 discover_overlay/userbox.py diff --git a/.pylintrc b/.pylintrc index daee0a9..663022b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,4 +1,645 @@ [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 positional arguments for function / method. +max-positional-arguments=5 + +# 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=cairo.* + +# 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/_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..2b5589f 100644 --- a/discover_overlay/autostart.py +++ b/discover_overlay/autostart.py @@ -14,6 +14,7 @@ import os import logging import shutil + try: from xdg.BaseDirectory import xdg_config_home, xdg_data_home except ModuleNotFoundError: @@ -30,14 +31,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 +63,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 +75,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/discord_connector.py b/discover_overlay/discord_connector.py index c13bc81..0bf0822 100644 --- a/discover_overlay/discord_connector.py +++ b/discover_overlay/discord_connector.py @@ -44,7 +44,8 @@ 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 = {} @@ -78,13 +79,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)) @@ -169,28 +169,33 @@ def add_text(self, message): """ 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.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 def update_text(self, message_in): @@ -198,13 +203,15 @@ 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']} + 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 @@ -214,7 +221,7 @@ def delete_text(self, message_in): Delete a line of text """ for idx, message in enumerate(self.text): - if message['id'] == message_in['id']: + if message["id"] == message_in["id"]: del self.text[idx] self.text_altered = True return @@ -277,7 +284,7 @@ def on_message(self, message): """ 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,17 +294,26 @@ def on_message(self, message): if j["evt"] == "READY": self.req_auth() elif j["evt"] == "VOICE_STATE_UPDATE": - self.list_altered = True + # 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 + 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 self.current_voice != "0": + 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.update_user(thisuser) self.set_in_room(thisuser["id"], True) elif j["evt"] == "VOICE_STATE_CREATE": @@ -320,19 +336,20 @@ def on_message(self, message): self.discover.voice_overlay.set_channel_icon(None) # User might have been forcibly moved room elif j["evt"] == "SPEAKING_START": - self.list_altered = True + # 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 + # 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": @@ -348,20 +365,20 @@ 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: @@ -373,14 +390,11 @@ def on_message(self, message): 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("ID is %s", self.user["id"]) + log.info("Logged in as %s", self.user["username"]) self.authed = True self.on_connected() return @@ -398,13 +412,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,31 +426,35 @@ 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 @@ -445,8 +463,7 @@ def on_message(self, message): 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"]) @@ -460,18 +477,17 @@ 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'] + 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): """ @@ -507,10 +523,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 +532,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 +563,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 +571,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 +655,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 +683,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,10 +695,8 @@ 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)) @@ -746,8 +719,7 @@ def update_overlays_from_data(self): 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.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: @@ -757,10 +729,8 @@ def update_overlays_from_data(self): cmd = { "cmd": "GET_CHANNELS", - "args": { - "guild_id": guild - }, - "nonce": guild + "args": {"guild_id": guild}, + "nonce": guild, } self.websocket.send(json.dumps(cmd)) self.last_rate_limit_send = now @@ -814,7 +784,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.1, ) if self.socket_watch: GLib.source_remove(self.socket_watch) @@ -822,7 +792,7 @@ 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, ) except ConnectionError as _error: self.schedule_reconnect() @@ -839,9 +809,11 @@ 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, + json.decoder.JSONDecodeError, + ): self.on_close() break self.update_overlays_from_data() diff --git a/discover_overlay/discover_overlay.py b/discover_overlay/discover_overlay.py index 705e7f0..646d787 100755 --- a/discover_overlay/discover_overlay.py +++ b/discover_overlay/discover_overlay.py @@ -11,6 +11,7 @@ # 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 @@ -19,20 +20,31 @@ import logging import json import signal -from configparser import ConfigParser +import importlib_resources +from configparser import ConfigParser, RawConfigParser +from ctypes import CDLL +from _version import __version__ + +CDLL("libgtk4-layer-shell.so") + import gi -import pkg_resources -from .settings_window import MainSettingsWindow +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 +from .overlay import get_h_align, get_v_align, HorzAlign, VertAlign -gi.require_version("Gtk", "3.0") -# pylint: disable=wrong-import-position,wrong-import-order -from gi.repository import Gtk, GLib, Gio # nopep8 +try: + gi.require_version("Gtk4LayerShell", "1.0") + from gi.repository import Gtk4LayerShell +except (ImportError, ValueError): + pass + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, GLib, Gio try: from xdg.BaseDirectory import xdg_config_home @@ -40,15 +52,23 @@ 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): + # pylint: disable=E1120 + Gtk.init() self.mix_settings = False self.ind = None self.tray = None @@ -65,14 +85,16 @@ def __init__(self, rpc_file, config_file, channel_file, debug_file, args): 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): """ @@ -103,25 +127,30 @@ def do_args(self, data, normal_close): print(_("from Discord client")) print("") print(" -c, --configure ", _("Open configuration window")) - print(" -x, --close ", - _("Close currently running instance")) - print(" -v, --debug ", - _("Verbose output for aid in debugging")) + print(" -x, --close ", _("Close currently running instance")) + print(" -v, --debug ", _("Verbose output for aid in debugging")) print(" -h, --help ", _("This screen")) print(" --hide ", _("Hide overlay")) print(" --show ", _("Show overlay")) - print(" --rpc ", - _("Send command, not start new instance.")) + print( + " --rpc ", _("Send command, not start new instance.") + ) print(" --mute ", _("Set own user to mute")) print(" --unmute ", _("Set unmuted")) print(" --toggle-mute ", _("Toggle muted")) print(" --deaf ", _("Set own user to deafened")) print(" --undeaf ", _("Unset user deafened state")) print(" --toggle-deaf ", _("Toggle deaf")) - print(" --moveto=XX ", - _("Move the user into voice room, by Room ID")) - print(" --minimized ", - _("If tray icon is enabled, start with only tray icon and no configuration window")) + print( + " --moveto=XX ", + _("Move the user into voice room, by Room ID"), + ) + print( + " --minimized ", + _( + "If tray icon is enabled, start with only tray icon and no configuration window" + ), + ) print("") print(_("For gamescope compatibility ensure ENV has 'GDK_BACKEND=x11'")) if normal_close: @@ -173,7 +202,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(self): @@ -198,221 +227,27 @@ def config_changed(self, _a=None, _b=None, _c=None, _d=None): # 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) - - 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) - ) + voice_section = RawConfigParser("") + if config.has_section("main"): + voice_section = config["main"] + 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) + text_section = RawConfigParser("") + if config.has_section("text"): + text_section = config["text"] + 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") - ) - 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)) + 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""" @@ -432,14 +267,22 @@ def create_gui(self): if self.steamos: self.text_overlay = TextOverlayWindow(self, self.voice_overlay) self.notification_overlay = NotificationOverlayWindow( - self, self.text_overlay) + self, self.text_overlay + ) else: self.text_overlay = TextOverlayWindow(self) self.notification_overlay = NotificationOverlayWindow(self) if self.mix_settings: - MainSettingsWindow( - self.config_file, self.rpc_file, self.channel_file, []) + 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) def toggle_show(self, _obj=None): """Toggle all overlays off or on""" @@ -455,21 +298,11 @@ 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) + sys.exit() def set_show_task(self, visible): """Set if the overlay should allow itself to appear on taskbar. - Not working at last check""" + Not working at last check""" if self.voice_overlay: self.voice_overlay.set_task(visible) if self.text_overlay: @@ -520,12 +353,14 @@ 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.unsetenv("WAYLAND_DISPLAY") # Catch any errors and log them @@ -541,19 +376,25 @@ def entrypoint(): else: if "-c" in sys.argv or "--configure" in sys.argv: # Show config window - _settings = MainSettingsWindow( - config_file, rpc_file, channel_file, sys.argv[1:]) - Gtk.main() + 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() else: # Tell any other running overlay to close with open(rpc_file, "w", encoding="utf-8") as tfile: tfile.write("--close") # Show the overlay - Discover(rpc_file, config_file, channel_file, - debug_file, sys.argv[1:]) + Discover(rpc_file, config_file, channel_file, debug_file, sys.argv[1:]) return - - except Exception as ex: # pylint: disable=broad-except + # pylint: disable=W0718 + except Exception as ex: log.error(ex) log.error(traceback.format_exc()) sys.exit(1) 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/font_helper.py b/discover_overlay/font_helper.py new file mode 100644 index 0000000..d4d8afa --- /dev/null +++ b/discover_overlay/font_helper.py @@ -0,0 +1,40 @@ +# 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 + +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): + size = "" + if desc.get_size_is_absolute(): + size = "%dpx" % (desc.get_size() / Pango.SCALE) + else: + size = "%dpt" % (desc.get_size() / Pango.SCALE) + mods = "" + family = desc.get_family() + font = '%s %s "%s"' % (mods, size, family) + return font + + +def font_string_to_css_font_string(string_in): + 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()) 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..aedd4f4 --- /dev/null +++ b/discover_overlay/glade/settings.xml @@ -0,0 +1,2283 @@ + + + + + 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_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_monitor + + + 1 + 9 + + + + + + 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 + 15 + + + + + + text_hide_mouseover + 1 + + + 1 + 13 + + + + + + text_show_mouseover + 1 + 1 + text_show_mouseover_adj + 1 + + + 1 + 14 + + + + + + + + + + + + + + + + + notebook_text_label + Text + + + + + + + 4 + False + + + notification_grid + 5 + 5 + 5 + 5 + 1 + 1 + + + notification_enable_label + Enable + 0 + + 0 + 0 + + + + + + notification_reverse_order_label + Reverse Order + 0 + + 0 + 1 + + + + + + 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_reverse_order + 1 + + + 1 + 1 + + + + + + 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 + + + + + + notification_show_test_content + 1 + + + 1 + 16 + + + + + + notification_show_test_content_label + Show Test Content + 0 + + 0 + 16 + + + + + + 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 + Middle + Right + + + + 1 + 11 + + + + + + Reset Notification Settings + notification_reset_all + 1 + 1 + + + 1 + 17 + + + + + + + + + + + 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_show_tray_icon_label + Show Tray Icon + 0 + + 0 + 3 + + + + + + core_show_tray_icon + 1 + + + 1 + 3 + + + + + + core_settings_min_label + Start Settings Minimized + 0 + + 0 + 4 + + + + + + core_settings_min + 1 + + + 1 + 4 + + + + + + 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 + + + + + + + + diff --git a/discover_overlay/image_getter.py b/discover_overlay/image_getter.py index 9fc3466..bdca13b 100644 --- a/discover_overlay/image_getter.py +++ b/discover_overlay/image_getter.py @@ -14,151 +14,124 @@ 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 + +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(): +class SurfaceGetter: """Download and decode image using PIL and store as a cairo surface""" - def __init__(self, func, url, identifier, size): + def __init__(self, func, url, identifier, display): self.func = func self.identifier = identifier self.url = url - self.size = size + self.display = display 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) + return 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 + + loader = GdkPixbuf.PixbufLoader() + try: + loader.write(resp.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_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) - 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) + image = GdkPixbuf.Pixbuf.new_from_file(icon.get_file().get_path()) + self.func(self.identifier, image) + return + 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}") + else: + errors.append("Not an icon : self.url") # Not found in theme, try some common locations - locations = [os.path.expanduser('~/.local/'), '/usr/', '/app'] + 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") + mixpath = os.path.join( + os.path.join(prefix, "share/icons/hicolor/256x256/apps/"), + self.url + ".png", + ) + if not os.path.isfile(mixpath): + errors.append(f"File not found: {mixpath}") + continue image = None try: - image = Image.open(mixpath) + image = GdkPixbuf.Pixbuf.new_from_file(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 + self.func(self.identifier, image) + 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) - - -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()) - - -def get_surface(func, identifier, ava, size): +def get_surface(func, identifier, ava, display): """Download to cairo surface""" - image_getter = SurfaceGetter(func, identifier, ava, size) - if identifier.startswith('http'): + image_getter = SurfaceGetter(func, identifier, ava, display) + if identifier.startswith("http"): thread = threading.Thread(target=image_getter.get_url) thread.start() else: @@ -192,80 +165,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/notification_overlay.py b/discover_overlay/notification_overlay.py index bb4132f..0d61930 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -13,15 +13,13 @@ """Notification window for text""" import logging import time -import math - -import cairo +import json import gi -from .image_getter import get_surface, draw_img_to_rect +from .image_getter import get_surface 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 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk log = logging.getLogger(__name__) @@ -39,55 +37,52 @@ def __init__(self, discover, piggyback=None): "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": ( + "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, "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": ( + "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] @@ -105,12 +100,10 @@ def __init__(self, discover, piggyback=None): 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""" @@ -119,48 +112,51 @@ def tick(self): oldsize = len(self.content) # Iterate over and remove messages older than 30s for message in self.content: - if message['time'] + self.text_time > now: + 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() + pass def add_notification_message(self, data): """Add new message to dataset""" noti = None - data = data['data'] - message_id = data['message']['id'] + data = data["data"] + message_id = data["message"]["id"] for message in self.content: - if message['id'] == message_id: + 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} + 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} + 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() 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() 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() def set_icon_size(self, size): """Config option: Size of icons, in window-space pixels""" @@ -173,13 +169,11 @@ 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""" @@ -187,11 +181,9 @@ def set_text_time(self, timer): self.timer_after_draw = timer def set_limit_width(self, limit): - """Config option: Word wrap limit, in window-space pixels - """ + """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""" @@ -202,100 +194,41 @@ def get_all_images(self): icon = line["icon"] if icon and icon not in self.image_list: - get_surface(self.recv_icon, icon, icon, - self.icon_size) + get_surface(self.recv_icon, icon, icon, self.get_display()) - def recv_icon(self, identifier, pix, _mask): + def recv_icon(self, identifier, pix): """Callback from image_getter for icons""" self.image_list[identifier] = pix - self.set_needs_redraw() 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() 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() 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() 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): """Return true if this overlay has meaningful content to show""" @@ -307,246 +240,39 @@ def has_content(self): 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("'", "'") - 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() + + def set_config(self, config): + OverlayWindow.set_config(self, config) + self.set_enabled(config.getboolean("enabled", fallback=False)) + + font = config.get("font", fallback=None) + 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_reverse_order(config.getboolean("rev", fallback=False)) + self.set_limit_width(config.getint("limit_width", fallback=400)) + self.set_icon_left(config.getboolean("icon_left", fallback=True)) + self.set_icon_pad(config.getint("icon_padding", fallback=8)) + self.set_icon_size(config.getint("icon_size", fallback=32)) + self.set_padding(config.getint("padding", fallback=8)) + self.set_border_radius(config.getint("border_radius", fallback=8)) + self.set_testing(config.getboolean("show_dummy", fallback=False)) + + if font: + self.set_font(font) + + self.set_monitor(config.get("monitor", fallback="Any")) diff --git a/discover_overlay/overlay.py b/discover_overlay/overlay.py index 58e2ac5..84bbf59 100644 --- a/discover_overlay/overlay.py +++ b/discover_overlay/overlay.py @@ -14,50 +14,86 @@ 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") +from enum import Enum import logging +import json 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 .font_helper import desc_to_css_font, font_string_to_css_font_string + +gi.require_version("Gtk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") + + +from gi.repository import Gtk, Gdk, GLib, Gtk4LayerShell log = logging.getLogger(__name__) +class VertAlign(Enum): + TOP = 0 + MIDDLE = 1 + BOTTOM = 2 + + +class HorzAlign(Enum): + LEFT = 0 + MIDDLE = 1 + RIGHT = 2 + + +def get_h_align(in_str): + 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 + log.error("Unknown H Align : %s", in_str) + return None + + +def get_v_align(in_str): + 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 + log.error("Unknown V Align : %s", in_str) + return None + + class OverlayWindow(Gtk.Window): """ Overlay parent class. Helpful if we need more overlay 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 - """ + def __init__(self, discover, piggyback=None): + Gtk.Window.__init__(self) + window = Gtk.Window() - screen = window.get_screen() - screen_type = f"{screen}" + display = window.get_display() + screen_type = f"{display}" self.is_wayland = False if "Wayland" in screen_type: self.is_wayland = True - return Gtk.WindowType.TOPLEVEL - return Gtk.WindowType.POPUP + self.css_prov = {} - def __init__(self, discover, piggyback=None): - Gtk.Window.__init__(self, type=self.detect_type()) + self.set_css( + "transparent_background", "window { background-color: rgba(0,0,0,0.0); }" + ) self.is_xatom_set = False self.discover = discover - screen = self.get_screen() self.text_font = None self.text_size = None self.pos_x = None @@ -66,39 +102,30 @@ def __init__(self, discover, piggyback=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_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_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_can_focus(False) + self.horzalign = HorzAlign.LEFT + self.vertalign = VertAlign.TOP self.set_wayland_state() self.piggyback = None self.piggyback_parent = None if not piggyback: - self.show_all() + self.show() if discover.steamos: self.set_gamescope_xatom(1) 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 @@ -109,19 +136,49 @@ def __init__(self, discover, piggyback=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) + # TODO Find compositor-change for GTK4 + # self.connect("composited-changed", self.check_composite) + + # TODO Find monitor hook for GTK4 + # self.get_display().connect("monitors-changed", self.screen_changed) + + # TODO Find monitor resize hook for GTK4 + # self.get_display().connect("size-changed", self.screen_changed) + + # TODO GTK4 mouse events + # 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) + + # TODO Find size-allocate for GTK4 + # self.connect("size-allocate", self.set_untouchable) 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) + + def remove_css(self, cssid): + 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): + 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""" @@ -137,15 +194,13 @@ def set_gamescope_xatom(self, enabled): self.is_xatom_set = enabled display = Display() atom = display.intern_atom("GAMESCOPE_EXTERNAL_OVERLAY") - # Since unused: _NET_WM_WINDOW_OPACITY - + # pylint: disable=E1101 if self.get_toplevel().get_window(): topw = display.create_resource_object( - "window", self.get_toplevel().get_window().get_xid()) + "window", self.get_toplevel().get_window().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,20 +208,31 @@ 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) + # pylint: disable=E1120 + if not Gtk4LayerShell.is_supported(): + 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 set_piggyback(self, other_overlay): """Sets as piggybacking off the given (other) overlay""" @@ -177,89 +243,28 @@ 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() - - 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() + self.set_css("font", "* { font: %s; }" % (font_string_to_css_font_string(font))) - 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 """ - (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) + if self.get_surface(): + self.get_surface().set_input_region(cairo.Region()) 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() + pass else: self.set_untouchable() @@ -267,35 +272,26 @@ 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() - return - if not self.is_wayland: - self.set_decorated(False) - self.set_keep_above(True) + (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords() - (floating_x, floating_y, floating_width, - floating_height) = self.get_floating_coords() - self.resize(floating_width, floating_height) - self.move(floating_x, floating_y) + # 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) - self.set_needs_redraw() + if self.is_wayland: + self.set_wayland_state() + else: + self.set_decorated(False) + self.set_keep_above(True) + self.resize(screen_width, screen_height) + self.move(screen_x, screen_y) + self.set_untouchable() def get_display_coords(self): """Get screen space co-ordinates of the monitor""" @@ -303,72 +299,13 @@ def get_display_coords(self): 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 @@ -384,20 +321,20 @@ def set_monitor(self, idx=None): if self.is_wayland: 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() 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 @@ -411,23 +348,27 @@ def get_monitor_from_plug(self): return this_mon return None - def set_align_x(self, align_right): + def set_align_x(self, align: HorzAlign): """ - Set the alignment (True for right, False for left) + Set the alignment """ - if self.align_right != align_right: - self.align_right = align_right - self.force_location() - self.set_needs_redraw() + if not isinstance(align, HorzAlign): + log.error("Unable to set Align X %s", align) + return - def set_align_y(self, align_vert): + self.horzalign = align + self.force_location() + + def set_align_y(self, align: VertAlign): """ Set the veritcal alignment """ - if self.align_vert != align_vert: - self.align_vert = align_vert - self.force_location() - self.set_needs_redraw() + if not isinstance(align, VertAlign): + log.error("Unable to set Align Y %s", align) + return + + self.vertalign = align + self.force_location() def col(self, col, alpha=1.0): """ @@ -435,42 +376,22 @@ def col(self, col, alpha=1.0): """ self.context.set_source_rgba(col[0], col[1], col[2], col[3] * alpha) - def set_force_xshape(self, force): - """ - Set if XShape should be forced - """ - self.force_xshape = force - - 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 - 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 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): """Callback to set monitor to display on""" self.set_monitor(self.monitor) @@ -478,7 +399,6 @@ def screen_changed(self, _screen=None): def mouseover(self, _a=None, _b=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 @@ -489,7 +409,41 @@ def mouseout(self, _a=None, _b=None): 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 col_to_css(self, 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})" + + 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")) diff --git a/discover_overlay/settings_window.py b/discover_overlay/settings_window.py index 2c4ac8d..f9f7ecc 100644 --- a/discover_overlay/settings_window.py +++ b/discover_overlay/settings_window.py @@ -11,64 +11,57 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """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 .font_helper import desc_to_css_font +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 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,35 @@ 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.loading_config = False + self.widget = {} + self.is_wayland = False + self.window = None + self.super_focus = None + self.start_minimized = False + self.server_handler = None + self.channel_handler = None + self.hidden_overlay_handler = None + + def start(self, x): 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 + 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()) - self.loading_config = False - - 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 +114,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,66 +148,77 @@ 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", Gtk.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) + # TODO Monitor callback + # window.get_display().connect("monitors-changed", self.populate_monitor_menus) - channel_file = Gio.File.new_for_path(channel_file) - self.monitor_channel = channel_file.monitor_file(0, None) - self.monitor_channel.connect("changed", self.populate_guild_menu) + channel_file = Gio.File.new_for_path(self.channel_file) + monitor_channel = channel_file.monitor_file(0, None) + 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() - builder.connect_signals(self) - window.connect('key-press-event', self.keypress_in_settings) + # TODO Re-fix gamepad support + # window.connect('key-press-event', self.keypress_in_settings) - if '--minimized' in self.args: + if "--minimized" in self.args: self.start_minimized = True if not self.start_minimized or not self.show_sys_tray_icon: 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) + 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) 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) @@ -241,10 +248,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 +273,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 +293,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 +303,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 +325,14 @@ 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): """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""" + g = self.widget["text_server"] + c = self.widget["text_channel"] g.handler_block(self.server_handler) c.handler_block(self.channel_handler) try: @@ -325,13 +344,13 @@ def populate_guild_menu(self, _a=None, _b=None, _c=None, _d=None): self.channel_ids = [] g.remove_all() c.remove_all() - for guild in data['guild'].values(): - g.append_text(guild['name']) - self.guild_ids.append(guild['id']) - if guild['id'] == self.current_guild and 'channels' in guild: - for channel in guild['channels']: - c.append_text(channel['name']) - self.channel_ids.append(channel['id']) + for guild in data["guild"].values(): + g.append_text(guild["name"]) + self.guild_ids.append(guild["id"]) + if guild["id"] == self.current_guild and "channels" in guild: + for channel in guild["channels"]: + c.append_text(channel["name"]) + self.channel_ids.append(channel["id"]) except FileNotFoundError: pass @@ -346,9 +365,9 @@ def populate_guild_menu(self, _a=None, _b=None, _c=None, _d=None): def populate_monitor_menus(self, _a=None, _b=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() @@ -363,7 +382,7 @@ def populate_monitor_menus(self, _a=None, _b=None): notify.append_text("Any") display = Gdk.Display.get_default() - screen = self.window.get_screen() + screen = self.window.get_display() if "get_n_monitors" in dir(display): count_monitors = display.get_n_monitors() if count_monitors >= 1: @@ -387,8 +406,9 @@ def close_window(self, _widget=None, _event=None): 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 @@ -399,36 +419,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 @@ -438,26 +431,14 @@ def read_config(self): config.read(self.config_file) # Read Voice section + 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.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") ) @@ -465,151 +446,165 @@ 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_title_font"].set_font(title_font) - self.widget['voice_icon_spacing'].set_value( - config.getint("main", "icon_spacing", fallback=8)) - - 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=False) + ) - 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.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) + self.widget["voice_text_side"].set_active( + config.getint("main", "text_side", fallback=3) + ) - self.widget['text_enable'].set_active( - config.getboolean("text", "enabled", fallback=False)) + # Read Text section + 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) ) @@ -619,121 +614,143 @@ 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_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_reverse_order"].set_active( + config.getboolean("notification", "rev", 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_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_2"].set_active( + config.getint("notification", "topalign", fallback=2) + ) - 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( + config.getboolean("notification", "icon_left", fallback=True) + ) - 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_show_test_content"].set_active( + config.getboolean("notification", "show_dummy", fallback=False) + ) # 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.widget["core_run_on_startup"].set_sensitive(False) + self.widget["core_run_conf_on_startup"].set_sensitive(False) self.show_sys_tray_icon = config.getboolean( - "general", "showsystray", fallback=True) + "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_show_tray_icon"].set_active(self.show_sys_tray_icon) 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.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_active(self.start_minimized) - self.widget['core_settings_min'].set_sensitive(self.show_sys_tray_icon) + 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 @@ -755,19 +772,21 @@ def get_monitor_index_from_plug(self, monitor): """Get monitor index from plug name""" if not monitor or monitor == "Any": return 0 + display = Gdk.Display.get_default() - screen = self.window.get_screen() + screen = self.window.get_display() 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 + return 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) @@ -777,62 +796,63 @@ def make_sys_tray_icon(self, menu): a systemtray icon """ try: - gi.require_version('AppIndicator3', '0.1') - # pylint: disable=import-outside-toplevel + gi.require_version("AppIndicator3", "0.1") + from gi.repository import AppIndicator3 + self.ind = AppIndicator3.Indicator.new( "discover_overlay", self.tray_icon_name, - AppIndicator3.IndicatorCategory.APPLICATION_STATUS) + 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) + log.info("No AppIndicator : %s", exception) def show_menu(self, obj, button, time): """Show menu when System Tray icon is clicked""" + return # TODO Menu self.menu.show_all() - self.menu.popup( - None, None, Gtk.StatusIcon.position_menu, obj, button, time) + 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 + + from gi.repository import AppIndicator4 + self.ind.set_status( - AppIndicator3.IndicatorStatus.ACTIVE if visible else AppIndicator3.IndicatorStatus.PASSIVE) + AppIndicator4.IndicatorStatus.ACTIVE + if visible + else AppIndicator4.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 + return None # TODO 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 def toggle_overlay(self, _a=None, _b=None): """Toggle overlay visibility""" @@ -842,129 +862,31 @@ 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) + ## TODO Fix menu + # if self.hidden_overlay: + # self.toggle_opt.set_label(_("Show Overlay")) + # else: + # self.toggle_opt.set_label(_("Hide Overlay")) 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""" @@ -975,7 +897,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): @@ -988,46 +910,46 @@ 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() + screen = self.window.get_display() idx = button.get_active() plug = "Any" if idx > 0: - monitor = screen.get_monitor_plug_name(button.get_active()-1) + monitor = screen.get_monitor_plug_name(button.get_active() - 1) if monitor: plug = monitor 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()) + desc = button.get_font_description() + self.config_set("main", "font", desc_to_css_font(desc)) def voice_title_font_changed(self, button): - self.config_set("main", "title_font", button.get_font()) + desc = button.get_font_description() + self.config_set("main", "title_font", desc_to_css_font(desc)) def voice_icon_spacing_changed(self, button): self.config_set("main", "icon_spacing", f"{int(button.get_value())}") @@ -1036,20 +958,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()}") @@ -1058,8 +976,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()}") @@ -1110,8 +1027,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())}") @@ -1123,9 +1039,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()}") @@ -1135,9 +1048,6 @@ 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_show_title_changed(self, button): self.config_set("main", "show_title", f"{button.get_active()}") @@ -1157,30 +1067,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()}") @@ -1211,7 +1115,8 @@ def text_channel_changed(self, button): self.config_set("text", "channel", channel) def text_font_changed(self, button): - self.config_set("text", "font", button.get_font()) + desc = button.get_font_description() + self.config_set("text", "font", desc_to_css_font(desc)) def text_colour_changed(self, button): colour = button.get_rgba() @@ -1224,15 +1129,35 @@ 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() + screen = self.window.get_display() plug = "Any" monitor = None - if button.get_active()>0: - monitor = screen.get_monitor_plug_name(button.get_active()-1) + if button.get_active() > 0: + monitor = screen.get_monitor_plug_name(button.get_active() - 1) if monitor: plug = monitor 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()}") @@ -1246,15 +1171,14 @@ 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()) + desc = button.get_font_description() + self.config_set("notification", "font", desc_to_css_font(desc)) def notification_text_colour_changed(self, button): colour = button.get_rgba() @@ -1267,20 +1191,34 @@ 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() + screen = self.window.get_display() plug = "Any" monitor = None - if button.get_active()>0: - monitor = screen.get_monitor_plug_name(button.get_active()-1) + if button.get_active() > 0: + monitor = screen.get_monitor_plug_name(button.get_active() - 1) if monitor: plug = monitor 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()}") @@ -1289,20 +1227,16 @@ 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()}") @@ -1313,13 +1247,10 @@ 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()) + self.widget["core_settings_min"].set_sensitive(button.get_active()) def core_hide_overlay_changed(self, _button): self.toggle_overlay() @@ -1359,15 +1290,16 @@ 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())}") diff --git a/discover_overlay/text_overlay.py b/discover_overlay/text_overlay.py index dbe4e28..cc3d5a9 100644 --- a/discover_overlay/text_overlay.py +++ b/discover_overlay/text_overlay.py @@ -12,16 +12,14 @@ # along with this program. If not, see . """Overlay window for text""" import logging -import time import re -import cairo +import json 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 + +gi.require_version("Gtk", "4.0") + +from gi.repository import Pango log = logging.getLogger(__name__) @@ -53,23 +51,23 @@ def __init__(self, discover, piggyback=None): self.img_finder = re.compile(r"`") self.warned_filetypes = [] self.set_title("Discover Text") - self.redraw() + self.width_limit = 500 + self.height_limit = 300 def set_blank(self): - """ Set contents blank and redraw """ + """Set contents blank and redraw""" self.content = [] - self.set_needs_redraw() def tick(self): - """ Check for old images """ + """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'] + if "attach" in message and message["attach"]: + url = message["attach"][0]["url"] log.info("keeping %s", url) self.attachment[url] = oldlist[url] @@ -78,31 +76,27 @@ def set_text_time(self, timer): if self.text_time != timer or self.timer_after_draw != 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:] + self.content = tlist[-self.line_limit :] if altered: - self.set_needs_redraw() + pass 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() def set_show_attach(self, attachment): """Config option: Show image attachments""" if self.attachment != attachment: self.show_attach = attachment - self.set_needs_redraw() def set_popup_style(self, boolean): """Config option: Messages should disappear after being shown for some time""" @@ -118,7 +112,6 @@ def set_font(self, font): 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""" @@ -133,47 +126,50 @@ def make_line(self, message): ret = f"{ret}{self.make_line(inner_message)}" elif isinstance(message, str): ret = self.sanitize_string(message) - elif message['type'] == 'strong': + 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': + 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: + elif message["type"] == "emoji": + if "surrogate" in message: # ['src'] is SVG URL # ret = msg - ret = message['surrogate'] + ret = message["surrogate"] else: ### Add Image ### - self.image_list.append(f"https://cdn.discordapp.com/emojis/{message['emojiId']}.png?v=1") + 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'): + elif ( + message["type"] == "inlineCode" + or message["type"] == "codeBlock" + or message["type"] == "blockQuote" + ): ret = f"{self.make_line(message['content'])}" - elif message['type'] == 'u': + elif message["type"] == "u": ret = f"{self.make_line(message['content'])}" - elif message['type'] == 'em': + elif message["type"] == "em": ret = f"{self.make_line(message['content'])}" - elif message['type'] == 's': + 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' + 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']) + self.warned_filetypes.append(message["type"]) return ret - def recv_attach(self, identifier, pix, _mask): + def recv_attach(self, identifier, pix): """Callback from image_getter""" self.attachment[identifier] = pix - self.set_needs_redraw() def has_content(self): """Returns true if overlay has meaningful content to render""" @@ -185,163 +181,34 @@ def has_content(self): 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("'", "'") - string = string.replace("\"", """) + string = string.replace('"', """) return string + + def set_config(self, config): + OverlayWindow.set_config(self, config) + self.set_enabled(config.getboolean("enabled", fallback=False)) + + 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_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_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.set_line_limit(config.getint("line_limit", fallback=20)) + self.set_hide_on_mouseover(config.getboolean("autohide", fallback=False)) + self.set_mouseover_timer(config.getint("autohide_timer", fallback=1)) + + self.set_monitor(config.get("monitor", fallback="Any")) + + if font: + self.set_font(font) diff --git a/discover_overlay/userbox.py b/discover_overlay/userbox.py new file mode 100644 index 0000000..309d3a2 --- /dev/null +++ b/discover_overlay/userbox.py @@ -0,0 +1,330 @@ +# 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 logging +import gi +from enum import Enum +from .image_getter import get_surface + +gi.require_version("Gtk", "4.0") + + +from gi.repository import Gtk, GLib, Gdk + +log = logging.getLogger(__name__) + + +class UserBoxDirection(Enum): + LTR = 0 + RTL = 1 + TTB = 2 + BTT = 3 + + +class UserBoxLayout(Gtk.LayoutManager): + + def do_allocate(self, widget, width, height, _baseline): + direction = UserBoxDirection(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 == UserBoxDirection.LTR: + img_alloc.x = img_alloc.y = lbl_alloc.y = 0 + lbl_alloc.x = asize + lbl_alloc.height = img_alloc.height = height + lbl_alloc.width = width - asize + elif direction == UserBoxDirection.RTL: + lbl_alloc.x = img_alloc.y = lbl_alloc.y = 0 + lbl_alloc.height = img_alloc.height = height + lbl_alloc.width = img_alloc.x = width - asize + elif direction == UserBoxDirection.TTB: + img_alloc.x = img_alloc.y = lbl_alloc.x = 0 + lbl_alloc.y = asize + lbl_alloc.width = img_alloc.width = width + lbl_alloc.height = height - asize + else: + img_alloc.y = lbl_alloc.height = height - asize + img_alloc.x = lbl_alloc.x = lbl_alloc.y = 0 + lbl_alloc.width = img_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) + + def do_measure(self, widget, orientation, for_size): + direction = UserBoxDirection(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 == UserBoxDirection.TTB or direction == UserBoxDirection.BTT) + ) or ( + orientation == Gtk.Orientation.HORIZONTAL + and (direction == UserBoxDirection.LTR or direction == UserBoxDirection.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) + + +class UserBox(Gtk.Box): + def __init__(self, overlay, userid): + super().__init__() + self.overlay = overlay + self.userid = userid + + self.add_css_class("user") + + self.image = Gtk.Image() + self.label = Gtk.Label() + + self.image.add_css_class("usericon") + self.label.add_css_class("userlabel") + + self.image.set_halign(Gtk.Align.CENTER) + self.image.set_valign(Gtk.Align.CENTER) + + self.append(self.image) + self.append(self.label) + + self.pixbuf = None + self.pixbuf_requested = False + self.name = "" + + self.grace_timeout = None + + self.set_layout_manager(UserBoxLayout()) + + def update_label(self, user): + if self.overlay.icon_only: + self.label.hide() + return + self.label.show() + + if len(user["friendlyname"]) < self.overlay.nick_length: + self.label.set_text(user["friendlyname"]) + else: + self.label.set_text( + user["friendlyname"][: (self.overlay.nick_length - 1)] + "\u2026" + ) + + def update_image(self, user): + if not self.overlay.show_avatar: + self.image.hide() + return + self.image.show() + # Ensure pixbuf for avatar + if ( + self.pixbuf is None + and not self.pixbuf_requested + and self.overlay.avatar_size > 0 + and user["avatar"] + ): + url = ( + f"https://cdn.discordapp.com/avatars/{user['id']}/{user['avatar']}.png" + ) + get_surface(self.recv_avatar, url, user["id"], self.get_display()) + self.pixbuf_requested = True + + 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): + self.pixbuf = pix + self.pixbuf_requested = False + self.image.set_from_pixbuf(self.pixbuf) + + def set_talking(self, talking): + if self.grace_timeout: + GLib.source_remove(self.grace_timeout) + if talking: + self.show() + 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) + else: + self.hide() + + def grace_cb(self): + self.hide() + + +class UserBoxConnection(UserBox): + def __init__(self, overlay): + super().__init__(overlay, None) + self.show_always = False + self.show_disconnected = True + self.last = "None" + self.pix_none = "network-cellular-signal-none" + self.pix_ok = "network-cellular-signal-ok" + self.pix_good = "network-cellular-signal-good" + self.pix_excellent = "network-cellular-signal-excellent" + + def set_show_always(self, show): + self.show_always = show + + def set_show_only_disconnected(self, show): + self.show_disconnected = show + + def get_image_name(self): + level = self.last + if ( + level == "DISCONNECTED" + or level == "NO_ROUTE" + or level == "VOICE_DISCONNECTED" + ): + return self.pix_none + elif ( + level == "ICE_CHECKING" + or level == "AWAITING_ENDPOINT" + or level == "AUTHENTICATING" + ): + return self.pix_ok + elif level == "CONNECTING" or level == "VOICE_CONNECTING": + return self.pix_good + elif level == "CONNECTED" or level == "VOICE_CONNECTED": + return self.pix_excellent + else: + return "" + + def set_connection(self, level): + if level == self.last: + return + + self.last = level + if self.should_show(): + self.show() + else: + self.hide() + self.image.set_from_icon_name(self.get_image_name()) + self.update_label(None) + + def should_show(self): + """Returns True if this should show in overlay, False otherwise""" + if self.show_always: + return True + elif self.show_disconnected and ( + self.last != "CONNECTED" and self.last != "VOICE_CONNECTED" + ): + return True + return False + + def update_image(self, user): + """Updates the image, assuming there is changed config or info""" + if not self.overlay.show_avatar: + self.image.hide() + return + self.image.show() + + def update_label(self, user): + """Updates the label, assuming there is changed config or info""" + if self.should_show(): + self.show() + else: + self.hide() + if self.overlay.icon_only: + self.label.hide() + return + self.label.show() + 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 blank(self): + self.pixbuf = None + self.hide() + + +class UserBoxTitle(UserBox): + def __init__(self, overlay): + super().__init__(overlay, None) + self.show_title = False + self.last = "" + + def set_label(self, label): + if self.show_title: + self.show() + if self.overlay.icon_only: + self.label.hide() + else: + self.label.show() + self.label.set_text(label) + self.last = label + + def set_image(self, 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): + self.pixbuf = None + self.hide() + + def set_show(self, show): + self.show_title = show + if show: + self.show() + else: + self.hide() + + def should_show(self): + return self.show_title + + def update_image(self, user): + if not self.overlay.show_avatar: + self.image.hide() + return + self.image.show() + self.image.set_from_pixbuf(self.pixbuf) + + def update_label(self, user): + if self.should_show(): + self.show() + else: + self.hide() + if self.overlay.icon_only: + self.label.hide() + return + self.label.show() + 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") diff --git a/discover_overlay/voice_overlay.py b/discover_overlay/voice_overlay.py index b84e517..728a829 100644 --- a/discover_overlay/voice_overlay.py +++ b/discover_overlay/voice_overlay.py @@ -14,25 +14,30 @@ import random import gettext import logging -import math -import sys import locale +import json +import importlib_resources 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 +from .overlay import OverlayWindow, HorzAlign, VertAlign +from .image_getter import get_surface +from .font_helper import font_string_to_css_font_string +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") + +from gi.repository import Gtk, GLib -t = gettext.translation( - 'default', pkg_resources.resource_filename('discover_overlay', 'locales'), fallback=True) -_ = t.gettext +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(OverlayWindow): @@ -40,28 +45,39 @@ class VoiceOverlayWindow(OverlayWindow): def __init__(self, discover, piggyback=None): OverlayWindow.__init__(self, discover, piggyback) - - self.avatars = {} - self.avatar_masks = {} - + self.box = Gtk.Box() + self.connection = UserBoxConnection(self) + self.title = UserBoxTitle(self) + self.box.append(self.title) + self.box.append(self.connection) + self.box.add_css_class("container") + self.set_child(self.box) 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 = '' + scream = "" if random.randint(0, 20) == 2: - scream = random.randint(8, 15)*'a' + 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, - }) + 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, + } + ) + self.text_x_align = "middle" + self.text_y_align = "middle" self.show_avatar = True self.avatar_size = 48 self.nick_length = 32 @@ -71,39 +87,35 @@ def __init__(self, discover, piggyback=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.mute_avatar = None + self.deaf_avatar = 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.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.square_avatar = False 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] @@ -120,15 +132,158 @@ def __init__(self, discover, piggyback=None): self.horizontal = False self.guild_ids = tuple() self.force_location() - get_surface(self.recv_avatar, - "discover-overlay-default", - 'def', self.avatar_size) + get_surface( + self.recv_avatar, "discover-overlay-default", "def", self.get_display() + ) + get_surface( + self.recv_avatar, "microphone-sensitivity-muted", "mute", self.get_display() + ) + get_surface(self.recv_avatar, "audio-volume-muted", "deaf", self.get_display()) self.set_title("Discover Voice") - self.redraw() + self.populate() + + def all_users(self, func): + child = self.box.get_first_child() + while child: + user = self.get_user(child.userid) + func(user, child) + child = child.get_next_sibling() + + def get_user(self, userid): + for user in self.dummy_data if self.use_dummy else self.userlist: + if user["id"] == userid: + return user + return None + + def get_user_widget(self, userid): + child = self.box.get_first_child() + while child: + if userid == child.userid: + return child + child = child.get_next_sibling() + return None + + def set_align_x(self, align): + OverlayWindow.set_align_x(self, align) + if align == HorzAlign.LEFT: + self.box.set_halign(Gtk.Align.START) + elif align == HorzAlign.MIDDLE: + self.box.set_halign(Gtk.Align.CENTER) + else: + self.box.set_halign(Gtk.Align.END) + + def set_align_y(self, align): + OverlayWindow.set_align_y(self, align) + if align == VertAlign.TOP: + self.box.set_valign(Gtk.Align.START) + elif align == VertAlign.MIDDLE: + self.box.set_valign(Gtk.Align.CENTER) + else: + self.box.set_valign(Gtk.Align.END) + + def populate(self): + child = self.box.get_last_child() + self.queue_resize() + self.box.queue_resize() + while child: + child.queue_resize() + n_child = child.get_prev_sibling() + if isinstance(child, UserBoxTitle) or isinstance(child, UserBoxConnection): + child = n_child + continue + if child.userid not in self.userlist: + self.box.remove(child) + child.hide() + child = n_child + connection = self.discover.connection + self_user_id = None + if connection and connection.user and "id" in connection.user: + self_user_id = connection.user["id"] + + # Gather which users to draw + 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.copy()[0 : self.dummy_count]) + userlist = self.dummy_data.copy() + else: + users_to_draw = self.userlist.copy() + userlist = self.userlist.copy() + + now = perf_counter() + for user in userlist: + # 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_id == user["id"]: + 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.show_connection: + connectionlabel = Gtk.Label() + connectionlabel.set_text(_(self.connection_status)) + + self.title.update_image(None) + self.title.update_label(None) + self.connection.update_image(None) + self.connection.update_label(None) + for user in users_to_draw: + userbox = self.get_user_widget(user["id"]) + if userbox: + userbox.show() + continue + + userbox = UserBox(self, user["id"]) + userbox.update_image(user) + userbox.update_label(user) + + self.box.append(userbox) + userbox.show() + self.box.show() + + def set_talking(self, userid, talking): + log.info("Talking %s %s", userid, talking) + widget = self.get_user_widget(userid) + user = self.get_user(userid) + if user: + user["talking"] = talking + if widget: + widget.set_talking(talking) + else: + log.warning("Set talking on missing user") + + def set_mute(self, userid, muted): + log.info("Mute %s %s", userid, muted) + + def set_deaf(self, userid, deafened): + log.info("Deaf %s %s", userid, deafened) 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,49 +296,40 @@ 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() + self.populate() # 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 = [] @@ -191,12 +337,16 @@ def set_blank(self): self.channel_icon_url = None self.channel_title = None self.connection_status = "DISCONNECTED" - self.set_needs_redraw() + self.populate() 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 @@ -204,242 +354,57 @@ def set_fade_out_inactive(self, enabled, fade_time, fade_duration, fade_to): self.reset_action_timer() 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) + self.populate() + + def set_borders(self): + width = self.border_width + col = self.col_to_css(self.border_col) + talk_col = self.col_to_css(self.talk_col) + self.set_css( + "talking-border", + f""" + .talking .userlabel, .talking .usericon + {{ + filter: drop-shadow({width}px {width}px 0 {talk_col}) + drop-shadow(-{width}px -{width}px 0 {talk_col}) + drop-shadow(-{width}px {width}px 0 {talk_col}) + drop-shadow({width}px -{width}px 0 {talk_col}); + }} + .userlabel, .usericon + {{ + filter: drop-shadow({width}px {width}px 0 {col}) + drop-shadow(-{width}px -{width}px 0 {col}) + drop-shadow(-{width}px {width}px 0 {col}) + drop-shadow({width}px -{width}px 0 {col}); + }} + .usericon + {{ + margin: {width}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 + get_surface(self.recv_avatar, url, "channel", self.get_display()) def set_user_list(self, userlist, alt): """Set the users in list to draw""" @@ -452,13 +417,11 @@ def set_user_list(self, userlist, alt): self.sort_list(self.userlist) if alt: self.reset_action_timer() - self.set_needs_redraw() + self.populate() 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["state"]) def sort_list(self, in_list): """Take a userlist and sort it according to config option""" @@ -468,7 +431,7 @@ 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): @@ -481,275 +444,21 @@ def has_content(self): 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() - - 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.all_users(lambda user, widget: widget.update_image(user)) + return + elif identifier == "mute": + self.mute_avatar = pix + self.all_users(lambda user, widget: widget.update_image(user)) + elif identifier == "deaf": + self.deaf_avatar = pix + self.all_users(lambda user, widget: widget.update_image(user)) + 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 + self.title.set_image(pix) def unused_fn_needed_translations(self): """ @@ -768,395 +477,148 @@ def unused_fn_needed_translations(self): _("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) + def set_config(self, config): + OverlayWindow.set_config(self, config) + + horizontal = config.getboolean("horizontal", fallback=False) + + self.mute_col = json.loads(config.get("mt_col", fallback="[0.6,0.0,0.0,1.0]")) + + # Text colour + self.set_css( + "foreground-color", + "* { color: " + + self.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: " + + self.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: " + + self.col_to_css(config.get("bg_col", fallback="[0.0,0.0,0.0,0.2]")) + + ";}", + ) + # Mute/deaf background colour + self.set_css( + "mute-background", + ".mute .userlabel, .mute .usericon { background-color: " + + self.col_to_css(config.get("mt_bg_col", fallback=[0.0, 0.0, 0.0, 0.5])) + + ";}", + ) + self.set_css( + "talking-background", + ".talking .userlabel, .talking .usericon { background-color: " + + self.col_to_css(config.get("hi_col", fallback="[0.0,0.0,0.0,0.5]")) + + ";}", + ) + + 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: " + + self.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 { -gtk-icon-size:%spx; }" % (self.avatar_size), + ) + + self.nick_length = config.getint("nick_length", fallback=32) + + self.box.set_spacing(config.getint("icon_spacing", fallback=8)) + + self.set_css( + "text_padding", + ".userlabel { padding: %spx; }" + % (config.getint("text_padding", fallback=6)), + ) + + self.square_avatar = config.getboolean("square_avatar", fallback=True) + + 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_hide_on_mouseover(config.getboolean("autohide", fallback=False)) + self.set_mouseover_timer(config.getint("autohide_timer", fallback=1)) + + self.box.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=False) + ) + 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) + + self.set_enabled(True) + + 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.set_font(font) + if title_font: + self.set_title_font(title_font) - 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.text_x_align = config.get("text_x_align", fallback="middle") + self.text_y_align = config.get("text_y_align", fallback="middle") - self.col(tx_col) - context.move_to( - pos_x + self.text_pad + avatar_size- ink_rect.x, - pos_y + text_y_offset - ) - 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() + 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.populate() diff --git a/setup.py b/setup.py index 5bf7a82..cd2d8cd 100644 --- a/setup.py +++ b/setup.py @@ -1,58 +1,71 @@ 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.8', - 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", ], 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/*'] - }, - keywords='discord overlay voice linux', - license='GPLv3+', + package_data={"discover_overlay": ["locales/*/LC_MESSAGES/*.mo", "glade/*"]}, + keywords="discord overlay voice linux", + license="GPLv3+", ) From 3235842706fb4eb6d6eb29205569d2bc4bb3160e Mon Sep 17 00:00:00 2001 From: trigg Date: Sat, 6 Sep 2025 15:47:53 +0100 Subject: [PATCH 02/10] - Attempt to identify problem desktops in advance and account for them - Reach out to XLib to set window position & above-hint for X11 desktops - Use PIL after all, to allow Webp and potentially other image formats - Brought Notifications back to working state - Implemented text align for notifications - Fixed voice overlay blanking when leaving a channel --- .pylintrc | 5 +- README.md | 17 +- discover_overlay/autostart.py | 1 - discover_overlay/discover_overlay.py | 37 +- discover_overlay/font_helper.py | 11 +- discover_overlay/glade/settings.xml | 415 +++++++++-------------- discover_overlay/image_getter.py | 17 +- discover_overlay/notification.py | 189 +++++++++++ discover_overlay/notification_overlay.py | 178 +++++----- discover_overlay/overlay.py | 188 ++++++---- discover_overlay/settings_window.py | 257 ++++---------- discover_overlay/userbox.py | 44 +-- discover_overlay/voice_overlay.py | 23 +- setup.py | 3 + 14 files changed, 701 insertions(+), 684 deletions(-) create mode 100644 discover_overlay/notification.py diff --git a/.pylintrc b/.pylintrc index 663022b..94ca7f4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -302,9 +302,6 @@ max-locals=15 # Maximum number of parents for a class (see R0901). max-parents=7 -# Maximum number of positional arguments for function / method. -max-positional-arguments=5 - # Maximum number of public methods for a class (see R0904). max-public-methods=20 @@ -568,7 +565,7 @@ 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=cairo.* +generated-members= # Tells whether to warn about missing members when the owner of the attribute # is inferred to be None. diff --git a/README.md b/README.md index ac59d35..6261955 100644 --- a/README.md +++ b/README.md @@ -140,29 +140,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/discover_overlay/autostart.py b/discover_overlay/autostart.py index 2b5589f..e56c7d2 100644 --- a/discover_overlay/autostart.py +++ b/discover_overlay/autostart.py @@ -13,7 +13,6 @@ """A class to assist auto-start""" import os import logging -import shutil try: from xdg.BaseDirectory import xdg_config_home, xdg_data_home diff --git a/discover_overlay/discover_overlay.py b/discover_overlay/discover_overlay.py index 646d787..fa432e0 100755 --- a/discover_overlay/discover_overlay.py +++ b/discover_overlay/discover_overlay.py @@ -18,7 +18,6 @@ import re import traceback import logging -import json import signal import importlib_resources from configparser import ConfigParser, RawConfigParser @@ -35,13 +34,6 @@ from .notification_overlay import NotificationOverlayWindow from .discord_connector import DiscordConnector from .audio_assist import DiscoverAudioAssist -from .overlay import get_h_align, get_v_align, HorzAlign, VertAlign - -try: - gi.require_version("Gtk4LayerShell", "1.0") - from gi.repository import Gtk4LayerShell -except (ImportError, ValueError): - pass gi.require_version("Gtk", "4.0") from gi.repository import Gtk, GLib, Gio @@ -67,6 +59,12 @@ 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 @@ -81,6 +79,7 @@ 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: @@ -194,16 +193,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: config.write(file) + self.skip_config_read = False def config(self): """Read config from disk""" @@ -224,6 +226,9 @@ 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() @@ -239,6 +244,10 @@ def config_changed(self, _a=None, _b=None, _c=None, _d=None): self.text_overlay.set_config(text_section) # Set Notification overlay options + notification_section = RawConfigParser("") + if config.has_section("notification"): + notification_section = config["notification"] + self.notification_overlay.set_config(notification_section) hidden = config.getboolean("general", "hideoverlay", fallback=False) self.voice_overlay.set_hidden(hidden) @@ -300,16 +309,6 @@ def close(self, _a=None, _b=None, _c=None): """ sys.exit() - 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) - def set_mute_async(self, mute): """Set mute status from another thread""" if mute is not None: diff --git a/discover_overlay/font_helper.py b/discover_overlay/font_helper.py index d4d8afa..de4f41c 100644 --- a/discover_overlay/font_helper.py +++ b/discover_overlay/font_helper.py @@ -20,18 +20,21 @@ # https://toshiocp.github.io/Gtk4-tutorial/sec23.html # TODO Weights, Italics def desc_to_css_font(desc): - size = "" + """Formats a font description into a CSS rule""" if desc.get_size_is_absolute(): - size = "%dpx" % (desc.get_size() / Pango.SCALE) + size = f"{desc.get_size() / Pango.SCALE}px" else: - size = "%dpt" % (desc.get_size() / Pango.SCALE) + size = f"{desc.get_size() / Pango.SCALE}pt" mods = "" family = desc.get_family() - font = '%s %s "%s"' % (mods, size, 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... diff --git a/discover_overlay/glade/settings.xml b/discover_overlay/glade/settings.xml index aedd4f4..0bb2bbf 100644 --- a/discover_overlay/glade/settings.xml +++ b/discover_overlay/glade/settings.xml @@ -1,6 +1,6 @@ - + 0.10 1 @@ -65,7 +65,7 @@ - + @@ -81,7 +81,7 @@ - + @@ -123,7 +123,7 @@ - + @@ -242,7 +242,7 @@ overview_close_button 1 1 - + 0 3 @@ -251,19 +251,19 @@ - + - + - + - + - + @@ -307,7 +307,7 @@ voice_display_horizontally 1 - + 1 5 @@ -404,7 +404,7 @@ voice_talking_foreground 1 1 - + 3 1 @@ -416,7 +416,7 @@ voice_talking_background 1 1 - + 4 1 @@ -428,7 +428,7 @@ voice_talking_border 1 1 - + 5 1 @@ -440,7 +440,7 @@ voice_idle_foreground 1 1 - + 3 2 @@ -452,7 +452,7 @@ voice_idle_background 1 1 - + 4 2 @@ -464,7 +464,7 @@ voice_idle_border 1 1 - + 5 2 @@ -476,7 +476,7 @@ voice_mute_foreground 1 1 - + 3 3 @@ -488,7 +488,7 @@ voice_mute_background 1 1 - + 4 3 @@ -500,7 +500,7 @@ voice_avatar_background 1 1 - + 4 4 @@ -522,7 +522,7 @@ voice_hide_mouseover 1 - + 7 1 @@ -547,7 +547,8 @@ 1 voice_show_mouseover_adj 1 - + 7 2 @@ -568,7 +569,7 @@ voice_monitor - + 0 1 @@ -585,7 +586,7 @@ Middle Right - + 0 2 @@ -602,7 +603,7 @@ Middle Bottom - + 0 3 @@ -610,78 +611,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -743,7 +672,7 @@ voice_display_icon_only 1 - + 3 0 @@ -769,7 +698,7 @@ Sans 12 en-us - + 3 1 @@ -793,14 +722,14 @@ 1 0 text_padding_adj - + 3 2 - + voice_show_title_label @@ -816,7 +745,7 @@ voice_show_title 1 - + 1 0 @@ -838,7 +767,8 @@ voice_show_connection_status 1 - + 1 2 @@ -853,7 +783,7 @@ Sans 12 en-us - + 1 1 @@ -877,7 +807,8 @@ 1 0 voice_vertical_padding_adj - + 1 4 @@ -901,7 +832,8 @@ 1 0 voice_horizontal_padding_adj - + 1 5 @@ -923,7 +855,7 @@ voice_show_test_content 1 - + 1 7 @@ -948,7 +880,7 @@ 50 voice_dummy_count_adj 50 - + 1 8 @@ -970,7 +902,7 @@ voice_show_disconnected 1 - + 1 3 @@ -1006,7 +938,7 @@ 32 voice_nick_lenght_adj 32 - + 3 4 @@ -1028,7 +960,7 @@ voice_show_avatar 1 - + 3 5 @@ -1053,7 +985,7 @@ 48 avatar_size_adj 48 - + 3 8 @@ -1078,7 +1010,7 @@ 1 ava_opacity_adj 3 - + 3 9 @@ -1100,7 +1032,7 @@ voice_display_speakers_only 1 - + 5 0 @@ -1124,7 +1056,8 @@ 1 0 voice_display_speakers_grace_period_adj - + 5 1 @@ -1150,7 +1083,7 @@ ID Last Spoken - + 5 3 @@ -1172,7 +1105,7 @@ voice_highlight_self 1 - + 5 4 @@ -1197,7 +1130,7 @@ 1 border_width_adj 1 - + 5 5 @@ -1221,7 +1154,7 @@ 1 0 icon_spacing_adj - + 5 6 @@ -1234,7 +1167,7 @@ voice_reset_all 1 1 - + 5 12 @@ -1289,7 +1222,7 @@ voice_inactive_fade 1 - + 5 7 @@ -1303,7 +1236,8 @@ 1 voice_inactive_fade_opacity 1 - + 5 8 @@ -1316,7 +1250,7 @@ 1 0 voice_inactive_time_range - + 5 9 @@ -1329,7 +1263,7 @@ 1 0 voice_inactive_fade_time_range - + 5 10 @@ -1374,7 +1308,7 @@ text_enable 1 - + 1 0 @@ -1396,7 +1330,7 @@ text_popup_style 1 - + 1 1 @@ -1421,7 +1355,7 @@ 10 text_line_limit_adj 10 - + 1 12 @@ -1443,7 +1377,7 @@ text_show_attachments 1 - + 1 11 @@ -1461,16 +1395,6 @@ - - - text_monitor - - - 1 - 9 - - - text_background_colour_label @@ -1487,7 +1411,7 @@ text_background_colour 1 1 - + 1 8 @@ -1510,7 +1434,7 @@ text_colour 1 1 - + 1 7 @@ -1525,7 +1449,7 @@ Sans 12 en-us - + 1 6 @@ -1569,7 +1493,7 @@ text_refresh_server_button 1 1 - + 1 4 @@ -1612,7 +1536,7 @@ text_popup_time 1 text_popup_time_adj - + 1 2 @@ -1647,10 +1571,10 @@ text_reset_all 1 1 - + 1 - 15 + 20 @@ -1658,7 +1582,7 @@ text_hide_mouseover 1 - + 1 13 @@ -1672,7 +1596,8 @@ 1 text_show_mouseover_adj 1 - + 1 14 @@ -1680,13 +1605,46 @@ - + + text_monitor + + + 1 + 15 + + - + + text_align_1 + 0 + + Left + Middle + Right + + + + 1 + 16 + + - + + text_align_2 + 0 + + Top + Middle + Bottom + + + + 1 + 17 + + @@ -1713,23 +1671,38 @@ 1 - notification_enable_label - Enable + notification_text_justify_label + Text Justification 0 0 - 0 + 16 + + + + + + notification_text_justify + + Left + Middle + Right + + + + 1 + 16 - notification_reverse_order_label - Reverse Order + notification_enable_label + Enable 0 0 - 1 + 0 @@ -1814,30 +1787,20 @@ notification_enable 1 - + 1 0 - - - notification_reverse_order - 1 - - - 1 - 1 - - - notification_popup_timer 1 notification_popup_timer_adj - + 1 2 @@ -1849,7 +1812,8 @@ notification_limit_popup_width 1 notification_limit_width_adj - + 1 3 @@ -1864,7 +1828,7 @@ Sans 12 en-us - + 1 4 @@ -1876,7 +1840,7 @@ notification_text_colour 1 1 - + 1 5 @@ -1888,7 +1852,8 @@ notification_background_colour 1 1 - + 1 6 @@ -1899,7 +1864,7 @@ notification_show_icon 1 - + 1 10 @@ -1907,24 +1872,15 @@ - + + Show Test Content notification_show_test_content 1 - + 1 - 16 - - - - - - notification_show_test_content_label - Show Test Content - 0 - - 0 - 16 + 17 @@ -1977,7 +1933,8 @@ notification_icon_padding 1 notification_icon_padding_adj - + 1 12 @@ -1989,7 +1946,8 @@ notification_icon_size 1 notification_icon_size_adj - + 1 13 @@ -2001,7 +1959,8 @@ notification_padding_between 1 notification_padding_adj - + 1 14 @@ -2013,7 +1972,8 @@ notification_border_radius 1 notification_border_radius_adj - + 1 15 @@ -2027,7 +1987,7 @@ Unknown - + 1 7 @@ -2043,7 +2003,7 @@ Middle Right - + 1 8 @@ -2059,7 +2019,7 @@ Middle Bottom - + 1 9 @@ -2071,10 +2031,9 @@ notification_icon_position Left - Middle Right - + 1 11 @@ -2087,15 +2046,15 @@ notification_reset_all 1 1 - + 1 - 17 + 18 - + @@ -2135,7 +2094,7 @@ core_run_on_startup 1 - + 1 0 @@ -2157,57 +2116,13 @@ core_run_conf_on_startup 1 - + 1 1 - - - core_show_tray_icon_label - Show Tray Icon - 0 - - 0 - 3 - - - - - - core_show_tray_icon - 1 - - - 1 - 3 - - - - - - core_settings_min_label - Start Settings Minimized - 0 - - 0 - 4 - - - - - - core_settings_min - 1 - - - 1 - 4 - - - core_hide_overlay @@ -2235,7 +2150,7 @@ core_reset_all 1 1 - + 1 7 @@ -2257,7 +2172,7 @@ core_audio_assist 1 - + 1 6 @@ -2265,7 +2180,7 @@ - + @@ -2280,4 +2195,4 @@ - + \ No newline at end of file diff --git a/discover_overlay/image_getter.py b/discover_overlay/image_getter.py index bdca13b..8565975 100644 --- a/discover_overlay/image_getter.py +++ b/discover_overlay/image_getter.py @@ -16,6 +16,8 @@ import os import gi import requests +import PIL.Image as Image +import io gi.require_version("GdkPixbuf", "2.0") gi.require_version("Gtk", "4.0") @@ -26,7 +28,7 @@ class SurfaceGetter: - """Download and decode image using PIL and store as a cairo surface""" + """Download and decode image to Pixbuf""" def __init__(self, func, url, identifier, display): self.func = func @@ -57,13 +59,18 @@ def get_url(self): except requests.Timeout: log.error("Unable to open %s - Timeout", self.url) return - except requests.ConnectionError: - log.error("Unable to open %s - Connection error", self.url) + except requests.ConnectionError as e: + log.error("Unable to open %s - Connection error %s", self.url, e) return + pimage = Image.open(resp.raw) + img_byte_arr = io.BytesIO() + pimage.save(img_byte_arr, format="PNG") + content = GLib.Bytes(img_byte_arr.getvalue()) + loader = GdkPixbuf.PixbufLoader() try: - loader.write(resp.content) + loader.write_bytes(content) loader.close() except ValueError as e: log.error("Unable to open %s - Value error %s", self.url, e) @@ -129,7 +136,7 @@ def get_file(self): def get_surface(func, identifier, ava, display): - """Download to cairo surface""" + """Download to Pixbuf""" image_getter = SurfaceGetter(func, identifier, ava, display) if identifier.startswith("http"): thread = threading.Thread(target=image_getter.get_url) diff --git a/discover_overlay/notification.py b/discover_overlay/notification.py new file mode 100644 index 0000000..6bb099e --- /dev/null +++ b/discover_overlay/notification.py @@ -0,0 +1,189 @@ +# 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 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, Gdk, GLib, Pango + +log = logging.getLogger(__name__) + + +class NotificationLayout(Gtk.LayoutManager): + + 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) + + 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 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_text(title) + self.message.set_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 = "%s%s" % (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) + + 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.box.remove(self) + + 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 0d61930..dc93a7e 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -12,11 +12,10 @@ # along with this program. If not, see . """Notification window for text""" import logging -import time import json import gi -from .image_getter import get_surface -from .overlay import OverlayWindow +from .overlay import OverlayWindow, get_h_align +from .notification import Notification gi.require_version("Gtk", "4.0") from gi.repository import Gtk @@ -29,11 +28,12 @@ class NotificationOverlayWindow(OverlayWindow): def __init__(self, discover, piggyback=None): OverlayWindow.__init__(self, discover, piggyback) + self.box = Gtk.Box() + self.box.set_orientation(Gtk.Orientation.VERTICAL) self.text_spacing = 4 - self.content = [] self.test_content = [ { - "icon": ( + "icon_url": ( "https://cdn.discordapp.com/" "icons/951077080769114172/991abffc0d2a5c040444be4d1a4085f4.webp?size=96" ), @@ -41,7 +41,7 @@ def __init__(self, discover, piggyback=None): }, {"title": "Title2", "body": "Body", "icon": None}, { - "icon": ( + "icon_url": ( "https://cdn.discordapp.com/" "icons/951077080769114172/991abffc0d2a5c040444be4d1a4085f4.webp?size=96" ), @@ -57,7 +57,7 @@ def __init__(self, discover, piggyback=None): ), }, { - "icon": None, + "icon_url": None, "title": "Title 3", "body": ( "Lorem ipsum dolor sit amet, consectetur adipiscing elit, " @@ -71,7 +71,7 @@ def __init__(self, discover, piggyback=None): ), }, { - "icon": ( + "icon_url": ( "https://cdn.discordapp.com/" "avatars/147077941317206016/6a6935192076489fa6dc1eb5dafbf6e7.webp?size=128" ), @@ -79,14 +79,13 @@ def __init__(self, discover, piggyback=None): "body": "Birdy test", }, ] + self.text_font = None self.text_size = 13 self.text_time = None self.show_icon = None 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 @@ -99,76 +98,51 @@ def __init__(self, discover, piggyback=None): self.image_list = {} self.warned_filetypes = [] + self.text_align = "left" self.set_title("Discover Notifications") + self.set_child(self.box) def set_blank(self): """Set to no data and redraw""" - self.content = [] - - 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): - pass + pass 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.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, + ) + self.box.append(n_not) + n_not.show() + # 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.box.set_spacing(padding) + + def set_icon_padding(self, padding): + self.icon_pad = padding + self.update_all() def set_border_radius(self, radius): + self.padding = radius """Config option: Radius of the border, in window-space pixels""" - if self.border_radius != radius: - self.border_radius = 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 def set_icon_left(self, left): """Config option: Icon on left or right of text""" @@ -182,19 +156,11 @@ def set_text_time(self, 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 - - 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.get_display()) + self.set_default_size(limit, -1) + child = self.box.get_first_child() + while child: + child.set_size_request(limit, -1) + child = child.get_next_sibling() def recv_icon(self, identifier, pix): """Callback from image_getter for icons""" @@ -202,19 +168,26 @@ def recv_icon(self, identifier, pix): 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_css( + "text-col", + ".notification .message, .notification .title { color: %s; }" + % (self.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_css( + "background", + ".notification { background-color: %s; }" % (self.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.get_all_images() + self.show_icon = icon + child = self.box.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""" @@ -223,8 +196,8 @@ def set_reverse_order(self, rev): def set_font(self, font): """Config option: Font used to render text""" - if self.text_font != font: - self.text_font = font + OverlayWindow.set_font(self, font) + self.update_all() def recv_attach(self, identifier, pix): """Callback from image_getter for attachments""" @@ -236,9 +209,9 @@ def has_content(self): return False if self.hidden: return False - if self.testing: - return self.test_content - return self.content + if self.box.get_first_child() is not None: + return True + return False def sanitize_string(self, string): """Sanitize a text message so that it doesn't intefere with Pango's XML format""" @@ -249,15 +222,24 @@ def sanitize_string(self, string): string = string.replace('"', """) return string - def set_testing(self, testing): - """Toggle placeholder images for testing""" - self.testing = testing - self.get_all_images() + 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): + log.error("ALIGN CHANGE %s", text_align) + self.text_align = text_align + self.update_all() + + def update_all(self): + child = self.box.get_first_child() + while child: + child.update() + child = child.get_next_sibling() def set_config(self, config): OverlayWindow.set_config(self, config) - self.set_enabled(config.getboolean("enabled", fallback=False)) - font = config.get("font", fallback=None) 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]"))) @@ -266,13 +248,19 @@ def set_config(self, config): self.set_reverse_order(config.getboolean("rev", fallback=False)) self.set_limit_width(config.getint("limit_width", fallback=400)) self.set_icon_left(config.getboolean("icon_left", fallback=True)) - self.set_icon_pad(config.getint("icon_padding", fallback=8)) 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_testing(config.getboolean("show_dummy", fallback=False)) + 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) self.set_monitor(config.get("monitor", fallback="Any")) + self.set_enabled(config.getboolean("enabled", fallback=False)) diff --git a/discover_overlay/overlay.py b/discover_overlay/overlay.py index 84bbf59..5372a76 100644 --- a/discover_overlay/overlay.py +++ b/discover_overlay/overlay.py @@ -24,30 +24,44 @@ import cairo from Xlib.display import Display from Xlib import X, Xatom -from .font_helper import desc_to_css_font, font_string_to_css_font_string +from ewmh import EWMH +from .font_helper import font_string_to_css_font_string gi.require_version("Gtk", "4.0") +gi.require_version("GdkWayland", "4.0") gi.require_version("Gtk4LayerShell", "1.0") -from gi.repository import Gtk, Gdk, GLib, Gtk4LayerShell +from gi.repository import Gtk, Gdk, GLib, GdkX11, GdkWayland, Gtk4LayerShell log = logging.getLogger(__name__) +class Direction(Enum): + 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 @@ -60,6 +74,7 @@ def get_h_align(in_str): 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 @@ -79,13 +94,6 @@ class OverlayWindow(Gtk.Window): def __init__(self, discover, piggyback=None): Gtk.Window.__init__(self) - - window = Gtk.Window() - display = window.get_display() - screen_type = f"{display}" - self.is_wayland = False - if "Wayland" in screen_type: - self.is_wayland = True self.css_prov = {} self.set_css( @@ -109,16 +117,8 @@ def __init__(self, discover, piggyback=None): log.info("Input shapes not available. Quitting") self.discover.exit() - # 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_can_focus(False) self.horzalign = HorzAlign.LEFT self.vertalign = VertAlign.TOP - self.set_wayland_state() self.piggyback = None self.piggyback_parent = None if not piggyback: @@ -136,11 +136,11 @@ def __init__(self, discover, piggyback=None): if piggyback: self.set_piggyback(piggyback) - # TODO Find compositor-change for GTK4 + self.get_display().connect("setting-changed", self.screen_changed) + # TODO Find compositor-change for GTK4. Currently only checks once at start # self.connect("composited-changed", self.check_composite) - # TODO Find monitor hook for GTK4 - # self.get_display().connect("monitors-changed", self.screen_changed) + self.get_display().get_monitors().connect("items-changed", self.screen_changed) # TODO Find monitor resize hook for GTK4 # self.get_display().connect("size-changed", self.screen_changed) @@ -161,11 +161,13 @@ def __init__(self, discover, piggyback=None): self.connect("destroy", self.window_exited) 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 @@ -184,6 +186,26 @@ def window_exited(self, _window=None): """Window closed. Exit app""" self.discover.exit() + def set_x11_window_location(self, x, y): + """Set Window location using X11""" + if self.piggyback_parent: + return + + if isinstance(self.get_surface(), GdkX11.X11Surface): + display = Display() + topw = display.create_resource_object( + "window", self.get_surface().get_xid() + ) + + topw.configure(x=x, y=y) + 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_xatom(self, enabled): """Set Gamescope XAtom to identify self as an overlay candidate""" if self.piggyback_parent: @@ -195,9 +217,9 @@ def set_gamescope_xatom(self, enabled): display = Display() atom = display.intern_atom("GAMESCOPE_EXTERNAL_OVERLAY") # pylint: disable=E1101 - if self.get_toplevel().get_window(): + 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) @@ -210,29 +232,30 @@ def set_wayland_state(self): """ If wayland is in use then attempt to set up a Gtk4LayerShell """ - if self.is_wayland: - # pylint: disable=E1120 - if not Gtk4LayerShell.is_supported(): - 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 - ) + # 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 set_piggyback(self, other_overlay): """Sets as piggybacking off the given (other) overlay""" @@ -249,15 +272,32 @@ def set_font(self, font): """ self.set_css("font", "* { font: %s; }" % (font_string_to_css_font_string(font))) + def css_changed(self, change): + Gtk.Window.css_changed(self, change) + self.set_untouchable() + def set_untouchable( - self, a=None, b=None, c=None + 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 self.get_surface(): - self.get_surface().set_input_region(cairo.Region()) + surface = self.get_surface() + display = self.get_display() + if surface: + + bb_region = cairo.Region() + if not display.is_composited() or self.hide_on_mouseover: + # TODO Add bounding boxes of all labels and images in widget tree + # bb_region = bb_region.union() + pass + + surface.set_input_region(bb_region) + if not display.is_composited(): + # TODO Maybe XLib + XShape + log.error("Unable to set XShape - exiting") + self.discover.exit() def set_hide_on_mouseover(self, hide): """Set if the overlay should hide when mouse moves over it""" @@ -279,24 +319,31 @@ def force_location(self): On Gamescope enforce size of display but only if it's the primary overlay """ (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords() - + self.set_decorated(True) + 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 self.is_wayland: + if isinstance(surface, GdkWayland.WaylandSurface): self.set_wayland_state() - else: + elif isinstance(surface, GdkX11.X11Surface): + # if not self.get_display().is_composited(): + # log.error("Unable to function without compositor") + # self.discover.exit() + surface.set_skip_pager_hint(True) + surface.set_skip_taskbar_hint(True) self.set_decorated(False) - self.set_keep_above(True) - self.resize(screen_width, screen_height) - self.move(screen_x, screen_y) + self.set_size_request(screen_width, screen_height) + self.set_x11_window_location(screen_x, screen_y) + else: + log.error("Unknown windowing system. Exiting") + 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_monitors()[0] @@ -318,7 +365,7 @@ 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: Gtk4LayerShell.set_monitor(self, monitor) @@ -335,17 +382,13 @@ def get_monitor_from_plug(self): 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 + # 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_x(self, align: HorzAlign): @@ -370,12 +413,6 @@ def set_align_y(self, align: VertAlign): self.vertalign = align self.force_location() - 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) - def set_enabled(self, enabled): """ Set if this overlay should be visible @@ -394,6 +431,9 @@ def set_enabled(self, enabled): def screen_changed(self, _screen=None): """Callback to set monitor to display on""" + if not self.get_display().is_composited(): + log.error("Unable to function without compositor") + self.discover.exit() self.set_monitor(self.monitor) def mouseover(self, _a=None, _b=None): diff --git a/discover_overlay/settings_window.py b/discover_overlay/settings_window.py index f9f7ecc..c1618c4 100644 --- a/discover_overlay/settings_window.py +++ b/discover_overlay/settings_window.py @@ -21,7 +21,7 @@ from configparser import ConfigParser import gi from .autostart import Autostart -from .font_helper import desc_to_css_font +from .overlay import get_h_align, get_v_align from _version import __version__ gi.require_version("Gtk", "4.0") @@ -59,8 +59,6 @@ def __init__(self, a, b, config_file, rpc_file, channel_file, args): self.steamos = False self.voice_placement_window = None self.text_placement_window = None - self.tray = None # Systemtray as fallback - self.ind = None # AppIndicator self.autostart_helper = Autostart("discover_overlay") self.autostart_helper_conf = Autostart("discover_overlay_configure") self.ind = None @@ -74,15 +72,11 @@ def __init__(self, a, b, config_file, rpc_file, channel_file, args): self.is_wayland = False self.window = None self.super_focus = None - self.start_minimized = False self.server_handler = None self.channel_handler = None self.hidden_overlay_handler = None - def start(self, x): - - self.menu = self.make_menu() - self.make_sys_tray_icon(self.menu) + def start(self, _x): builder = Gtk.Builder(self) with importlib_resources.as_file( @@ -153,7 +147,7 @@ def start(self, x): # 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() # pylint: disable=E1120 @@ -179,7 +173,9 @@ def start(self, x): # Fill monitor & guild menus self.populate_monitor_menus() # TODO Monitor callback - # window.get_display().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(self.channel_file) monitor_channel = channel_file.monitor_file(0, None) @@ -202,30 +198,25 @@ def start(self, x): # TODO Re-fix gamepad support # window.connect('key-press-event', self.keypress_in_settings) - if "--minimized" in self.args: - self.start_minimized = True - if not self.start_minimized or not self.show_sys_tray_icon: - 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.window.show() + 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""" @@ -381,20 +372,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_display() - 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) @@ -402,16 +387,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: - - 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""" @@ -432,10 +408,10 @@ def read_config(self): # Read Voice section self.widget["voice_align_1"].set_active( - config.getboolean("main", "rightalign", fallback=False) + get_h_align(config.get("main", "x_align", fallback="left")).value ) self.widget["voice_align_2"].set_active( - config.getint("main", "topalign", fallback=1) + get_v_align(config.get("main", "y_align", fallback="middle")).value ) self.widget["voice_monitor"].set_active( @@ -596,6 +572,18 @@ def read_config(self): ) # 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_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) ) @@ -650,10 +638,6 @@ def read_config(self): config.getboolean("notification", "enabled", fallback=False) ) - self.widget["notification_reverse_order"].set_active( - config.getboolean("notification", "rev", fallback=False) - ) - self.widget["notification_popup_timer"].set_value( config.getint("notification", "text_time", fallback=10) ) @@ -684,11 +668,10 @@ def read_config(self): ) self.widget["notification_align_1"].set_active( - config.getboolean("notification", "rightalign", fallback=True) + get_h_align(config.get("notification", "x_align", fallback="left")).value ) - self.widget["notification_align_2"].set_active( - config.getint("notification", "topalign", fallback=2) + get_v_align(config.get("notification", "y_align", fallback="middle")).value ) self.widget["notification_show_icon"].set_active( @@ -696,7 +679,7 @@ def read_config(self): ) self.widget["notification_icon_position"].set_active( - config.getboolean("notification", "icon_left", fallback=True) + 0 if config.getboolean("notification", "icon_left", fallback=True) else 1 ) self.widget["notification_icon_padding"].set_value( @@ -715,8 +698,8 @@ def read_config(self): 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 @@ -731,23 +714,11 @@ def read_config(self): self.widget["core_run_on_startup"].set_sensitive(False) self.widget["core_run_conf_on_startup"].set_sensitive(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.hidden_overlay = config.getboolean( "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) ) @@ -768,91 +739,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_display() - 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") - - 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("No AppIndicator : %s", exception) - - def show_menu(self, obj, button, time): - """Show menu when System Tray icon is clicked""" - return # TODO Menu - 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: - - from gi.repository import AppIndicator4 - - self.ind.set_status( - AppIndicator4.IndicatorStatus.ACTIVE - if visible - else AppIndicator4.IndicatorStatus.PASSIVE - ) - elif self.tray is not None: - self.tray.set_visible(visible) - - def make_menu(self): - """Create System Menu""" - return None # TODO 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""" @@ -914,13 +819,10 @@ def config_remove_section(self, context): config.write(file) def voice_monitor_changed(self, button): - screen = self.window.get_display() 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): @@ -944,12 +846,10 @@ def voice_align_2_changed(self, button): self.config_set("main", "y_align", f"{value}") def voice_font_changed(self, button): - desc = button.get_font_description() - self.config_set("main", "font", desc_to_css_font(desc)) + self.config_set("main", "font", button.get_font()) def voice_title_font_changed(self, button): - desc = button.get_font_description() - self.config_set("main", "title_font", desc_to_css_font(desc)) + self.config_set("main", "title_font", button.get_font()) def voice_icon_spacing_changed(self, button): self.config_set("main", "icon_spacing", f"{int(button.get_value())}") @@ -1115,8 +1015,7 @@ def text_channel_changed(self, button): self.config_set("text", "channel", channel) def text_font_changed(self, button): - desc = button.get_font_description() - self.config_set("text", "font", desc_to_css_font(desc)) + self.config_set("text", "font", button.get_font()) def text_colour_changed(self, button): colour = button.get_rgba() @@ -1129,13 +1028,10 @@ 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_display() + 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): @@ -1167,9 +1063,6 @@ 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())}") @@ -1177,8 +1070,7 @@ def notification_limit_popup_width_changed(self, button): self.config_set("notification", "limit_width", f"{int(button.get_value())}") def notification_font_changed(self, button): - desc = button.get_font_description() - self.config_set("notification", "font", desc_to_css_font(desc)) + self.config_set("notification", "font", button.get_font()) def notification_text_colour_changed(self, button): colour = button.get_rgba() @@ -1191,13 +1083,10 @@ 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_display() + 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): @@ -1238,8 +1127,9 @@ def notification_padding_between_changed(self, button): def notification_border_radius_changed(self, button): 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): + log.error("FUCK SAKE") + self.config_set("notification", "show_dummy", "True") def core_run_on_startup_changed(self, button): self.autostart_helper.set_autostart(button.get_active()) @@ -1247,11 +1137,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_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() @@ -1303,3 +1188,11 @@ def core_audio_assist_changed(self, button): 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/userbox.py b/discover_overlay/userbox.py index 309d3a2..1297cf9 100644 --- a/discover_overlay/userbox.py +++ b/discover_overlay/userbox.py @@ -13,8 +13,8 @@ """A Gtk Box with direction""" import logging import gi -from enum import Enum from .image_getter import get_surface +from .overlay import Direction gi.require_version("Gtk", "4.0") @@ -24,32 +24,25 @@ log = logging.getLogger(__name__) -class UserBoxDirection(Enum): - LTR = 0 - RTL = 1 - TTB = 2 - BTT = 3 - - class UserBoxLayout(Gtk.LayoutManager): def do_allocate(self, widget, width, height, _baseline): - direction = UserBoxDirection(widget.overlay.text_side) + 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 == UserBoxDirection.LTR: + if direction == Direction.LTR: img_alloc.x = img_alloc.y = lbl_alloc.y = 0 lbl_alloc.x = asize lbl_alloc.height = img_alloc.height = height lbl_alloc.width = width - asize - elif direction == UserBoxDirection.RTL: + elif direction == Direction.RTL: lbl_alloc.x = img_alloc.y = lbl_alloc.y = 0 lbl_alloc.height = img_alloc.height = height lbl_alloc.width = img_alloc.x = width - asize - elif direction == UserBoxDirection.TTB: + elif direction == Direction.TTB: img_alloc.x = img_alloc.y = lbl_alloc.x = 0 lbl_alloc.y = asize lbl_alloc.width = img_alloc.width = width @@ -78,17 +71,17 @@ def do_allocate(self, widget, width, height, _baseline): widget.label.size_allocate(lbl_alloc, -1) def do_measure(self, widget, orientation, for_size): - direction = UserBoxDirection(widget.overlay.text_side) + 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 == UserBoxDirection.TTB or direction == UserBoxDirection.BTT) + and (direction == Direction.TTB or direction == Direction.BTT) ) or ( orientation == Gtk.Orientation.HORIZONTAL - and (direction == UserBoxDirection.LTR or direction == UserBoxDirection.RTL) + and (direction == Direction.LTR or direction == Direction.RTL) ): return (im_m[0] + lb_m[0], im_m[1] + lb_m[1], -1, -1) else: @@ -222,6 +215,9 @@ def get_image_name(self): return "" def set_connection(self, level): + if not level: + self.hide() + return if level == self.last: return @@ -277,6 +273,10 @@ def __init__(self, overlay): self.last = "" def set_label(self, label): + self.last = label + if not label: + self.hide() + return if self.show_title: self.show() if self.overlay.icon_only: @@ -284,7 +284,6 @@ def set_label(self, label): else: self.label.show() self.label.set_text(label) - self.last = label def set_image(self, image): if self.show_title: @@ -306,7 +305,7 @@ def set_show(self, show): self.hide() def should_show(self): - return self.show_title + return self.show_title and self.last def update_image(self, user): if not self.overlay.show_avatar: @@ -324,7 +323,10 @@ def update_label(self, user): self.label.hide() return self.label.show() - 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") + 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" + ) diff --git a/discover_overlay/voice_overlay.py b/discover_overlay/voice_overlay.py index 728a829..42b271e 100644 --- a/discover_overlay/voice_overlay.py +++ b/discover_overlay/voice_overlay.py @@ -93,8 +93,6 @@ def __init__(self, discover, piggyback=None): self.def_avatar = None self.mute_avatar = None self.deaf_avatar = None - self.channel_icon = None - self.channel_icon_url = None self.overflow = None self.use_dummy = False self.dummy_count = 10 @@ -115,7 +113,6 @@ def __init__(self, discover, piggyback=None): self.inactive_timeout = None self.fadeout_timeout = None - self.square_avatar = False 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] @@ -128,7 +125,6 @@ def __init__(self, discover, piggyback=None): 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() @@ -140,6 +136,10 @@ def __init__(self, discover, piggyback=None): ) get_surface(self.recv_avatar, "audio-volume-muted", "deaf", self.get_display()) self.set_title("Discover Voice") + self.title.set_label(None) + self.connection.set_connection(None) + self.title.update_label(None) + self.connection.update_image(None) self.populate() def all_users(self, func): @@ -242,10 +242,6 @@ def populate(self): elif user in users_to_draw: users_to_draw.remove(user) - if self.show_connection: - connectionlabel = Gtk.Label() - connectionlabel.set_text(_(self.connection_status)) - self.title.update_image(None) self.title.update_label(None) self.connection.update_image(None) @@ -333,10 +329,10 @@ def overlay_fadeout(self): 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.title.set_label(None) + self.connection.set_connection(None) + self.title.update_label(None) + self.connection.update_image(None) self.populate() def set_fade_out_inactive(self, enabled, fade_time, fade_duration, fade_to): @@ -457,7 +453,6 @@ def recv_avatar(self, identifier, pix): self.deaf_avatar = pix self.all_users(lambda user, widget: widget.update_image(user)) elif identifier == "channel": - self.channel_icon = pix self.title.set_image(pix) def unused_fn_needed_translations(self): @@ -544,8 +539,6 @@ def set_config(self, config): % (config.getint("text_padding", fallback=6)), ) - self.square_avatar = config.getboolean("square_avatar", fallback=True) - self.only_speaking = config.getboolean("only_speaking", fallback=False) self.only_speaking_grace_period = config.getint( diff --git a/setup.py b/setup.py index cd2d8cd..27561ea 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,9 @@ def readme(): "setuptools", "pulsectl-asyncio", "importlib_resources", + "cairo", + "ewmh", + "pillow", ], entry_points={ "console_scripts": [ From 6b56556fb9efeadcbe654370c354290330b1e15c Mon Sep 17 00:00:00 2001 From: trigg Date: Sat, 6 Sep 2025 23:27:53 +0100 Subject: [PATCH 03/10] - Altered ratelimit logic in connector - Stopped connector caching text chat, just pass on messages as they happen - reimplemented text overlay - text chat hides when empty --- discover_overlay/discord_connector.py | 72 ++++++-------- discover_overlay/message.py | 131 ++++++++++++++++++++++++++ discover_overlay/overlay.py | 3 +- discover_overlay/settings_window.py | 14 ++- discover_overlay/text_overlay.py | 128 +++++++++---------------- 5 files changed, 216 insertions(+), 132 deletions(-) create mode 100644 discover_overlay/message.py diff --git a/discover_overlay/discord_connector.py b/discover_overlay/discord_connector.py index 0bf0822..84dcdff 100644 --- a/discover_overlay/discord_connector.py +++ b/discover_overlay/discord_connector.py @@ -58,7 +58,6 @@ def __init__(self, discover): 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 @@ -70,6 +69,8 @@ def __init__(self, discover): self.rate_limited_channels = [] self.reconnect_cb = None + self.rate_limit = None + def get_access_token_stage1(self): """ First stage of getting an access token. Request authorization from Discord client @@ -186,7 +187,7 @@ def add_text(self, message): if "author_color" in message: colour = message["author_color"] - self.text.append( + self.discover.text_overlay.new_line( { "id": message["id"], "content": self.get_message_from_message(message), @@ -196,35 +197,18 @@ def add_text(self, message): "attach": self.get_attachment_from_message(message), } ) - self.text_altered = True 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): """ @@ -469,7 +453,7 @@ def on_message(self, message): 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) @@ -701,6 +685,23 @@ def change_text_room(self, room_id): if self.websocket: self.websocket.send(json.dumps(cmd)) + def channel_rate_limit(self): + """Called regularly to pull in any required channels""" + if 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 update_overlays_from_data(self): """Send new data out to overlay windows""" if self.websocket is None: @@ -715,25 +716,6 @@ def update_overlays_from_data(self): 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 start_listening_text(self, channel): """ @@ -757,7 +739,13 @@ 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""" diff --git a/discover_overlay/message.py b/discover_overlay/message.py new file mode 100644 index 0000000..3c2ab5b --- /dev/null +++ b/discover_overlay/message.py @@ -0,0 +1,131 @@ +# 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 +from .image_getter import get_surface +from .overlay import HorzAlign + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, Gdk, GLib, Pango + +log = logging.getLogger(__name__) + + +class MessageBoxLayout(Gtk.LayoutManager): + + 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() + + def do_measure(self, widget, orientation, _for_size): + if orientation == Gtk.Orientation.VERTICAL: + return (widget.overlay.height_limit, widget.overlay.height_limit, -1, -1) + else: + return (widget.overlay.width_limit, widget.overlay.width_limit, -1, -1) + + +class MessageBox(Gtk.Box): + def __init__(self, overlay): + Gtk.Box.__init__(self) + self.overlay = overlay + self.set_layout_manager(MessageBoxLayout()) + self.set_overflow(Gtk.Overflow.HIDDEN) + self.set_orientation(Gtk.Orientation.VERTICAL) + self.add_css_class("messagebox") + + +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_use_markup(True) + self.label.set_wrap(True) + self.label.set_markup( + "%s:%s" % (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 exit(self): + self.overlay.box.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/overlay.py b/discover_overlay/overlay.py index 5372a76..56a3b15 100644 --- a/discover_overlay/overlay.py +++ b/discover_overlay/overlay.py @@ -372,7 +372,8 @@ def set_monitor(self, idx=None): else: self.hide() self.set_wayland_state() - self.show() + if self.has_content(): + self.show() self.set_untouchable() self.force_location() diff --git a/discover_overlay/settings_window.py b/discover_overlay/settings_window.py index c1618c4..967e964 100644 --- a/discover_overlay/settings_window.py +++ b/discover_overlay/settings_window.py @@ -170,9 +170,7 @@ def start(self, _x): ) ) - # Fill monitor & guild menus self.populate_monitor_menus() - # TODO Monitor callback self.window.get_display().get_monitors().connect( "items-changed", self.populate_monitor_menus ) @@ -319,9 +317,10 @@ def request_channels_from_guild(self, guild_id): 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, _a=None, _b=None, _c=None, _d=None, _e=None): """Read guild data and repopulate widget. Disable signal handling meanwhile to avoid recursive logic""" + log.info("Populating guild and channel") g = self.widget["text_server"] c = self.widget["text_channel"] g.handler_block(self.server_handler) @@ -340,10 +339,15 @@ def populate_guild_menu(self, _a=None, _b=None, _c=None, _d=None): self.guild_ids.append(guild["id"]) if guild["id"] == self.current_guild and "channels" in guild: for channel in guild["channels"]: + log.info( + "Channel : %s Guild : %s", + channel["name"], + guild["name"], + ) 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)) @@ -354,7 +358,7 @@ 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"] diff --git a/discover_overlay/text_overlay.py b/discover_overlay/text_overlay.py index cc3d5a9..b1521f1 100644 --- a/discover_overlay/text_overlay.py +++ b/discover_overlay/text_overlay.py @@ -16,10 +16,11 @@ import json import gi from .overlay import OverlayWindow +from .message import Message, MessageBox gi.require_version("Gtk", "4.0") -from gi.repository import Pango +from gi.repository import Pango, Gtk log = logging.getLogger(__name__) @@ -29,8 +30,8 @@ class TextOverlayWindow(OverlayWindow): def __init__(self, discover, piggyback=None): OverlayWindow.__init__(self, discover, piggyback) + self.box = MessageBox(self) self.text_spacing = 4 - self.content = [] self.text_font = None self.text_size = 13 self.text_time = None @@ -53,35 +54,33 @@ def __init__(self, discover, piggyback=None): self.set_title("Discover Text") self.width_limit = 500 self.height_limit = 300 + self.set_child(self.box) + if self.popup_style and not self.has_content(): + self.hide() def set_blank(self): """Set contents blank and redraw""" - self.content = [] - - 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] + child = self.box.get_first_child() + while child: + n_child = child.get_next_sibling() + self.box.remove(child) + child = n_child + if self.popup_style and not self.has_content(): + self.hide() + + 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.box.append(message) + if self.has_content(): + self.show() 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 - - def set_text_list(self, tlist, altered): - """Change contents of overlay""" - self.content = tlist[-self.line_limit :] - if altered: - pass + self.set_blank() def set_fg(self, fg_col): """Config option: Sets the text colour""" @@ -102,6 +101,7 @@ 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""" @@ -118,55 +118,6 @@ def set_line_limit(self, limit): 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): """Callback from image_getter""" self.attachment[identifier] = pix @@ -179,16 +130,12 @@ def has_content(self): return False if self.hidden: return False - return self.content + return self.box.get_first_child() is not None - 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 update(self): + """Call when removing a message automatically, allows hiding of overlay when empty""" + if not self.has_content(): + self.hide() def set_config(self, config): OverlayWindow.set_config(self, config) @@ -199,8 +146,17 @@ def set_config(self, config): self.discover.connection.set_text_channel(channel, guild) font = config.get("font", fallback=None) - 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_css( + "background", + ".messagebox { background-color: %s; }" + % (self.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; }" + % (self.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)) @@ -208,6 +164,10 @@ def set_config(self, config): self.set_hide_on_mouseover(config.getboolean("autohide", fallback=False)) self.set_mouseover_timer(config.getint("autohide_timer", fallback=1)) + 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.box.set_size_request(self.width_limit, self.height_limit) self.set_monitor(config.get("monitor", fallback="Any")) if font: From 70b93ae9b897e87f4f9b7427e2a8f7bf033830d5 Mon Sep 17 00:00:00 2001 From: trigg Date: Sat, 6 Sep 2025 23:40:01 +0100 Subject: [PATCH 04/10] - Remove unused text overlay values and functions - Added stub for update message --- discover_overlay/message.py | 5 +++ discover_overlay/text_overlay.py | 59 ++++++-------------------------- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/discover_overlay/message.py b/discover_overlay/message.py index 3c2ab5b..fed94a5 100644 --- a/discover_overlay/message.py +++ b/discover_overlay/message.py @@ -83,7 +83,12 @@ def __init__(self, overlay, message): 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.box.remove(self) self.overlay.update() diff --git a/discover_overlay/text_overlay.py b/discover_overlay/text_overlay.py index b1521f1..62cac6d 100644 --- a/discover_overlay/text_overlay.py +++ b/discover_overlay/text_overlay.py @@ -31,26 +31,11 @@ class TextOverlayWindow(OverlayWindow): def __init__(self, discover, piggyback=None): OverlayWindow.__init__(self, discover, piggyback) self.box = MessageBox(self) - self.text_spacing = 4 - self.text_font = None - self.text_size = 13 + 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.width_limit = 500 self.height_limit = 300 @@ -82,20 +67,11 @@ def set_text_time(self, timer): self.text_time = timer self.set_blank() - def set_fg(self, fg_col): - """Config option: Sets the text colour""" - if self.fg_col != fg_col: - self.fg_col = fg_col - - def set_bg(self, bg_col): - """Config option: Set the background colour""" - if self.bg_col != bg_col: - self.bg_col = bg_col - 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.update_all() def set_popup_style(self, boolean): """Config option: Messages should disappear after being shown for some time""" @@ -103,25 +79,6 @@ def set_popup_style(self, 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 - - def set_line_limit(self, limit): - """Config option: Limit number of lines rendered""" - if self.line_limit != limit: - self.line_limit = limit - - def recv_attach(self, identifier, pix): - """Callback from image_getter""" - self.attachment[identifier] = pix - def has_content(self): """Returns true if overlay has meaningful content to render""" if self.piggyback and self.piggyback.has_content(): @@ -137,6 +94,13 @@ def update(self): if not self.has_content(): self.hide() + def update_all(self): + """Tell all messages we've had something changed""" + child = self.box.get_first_child() + while child: + child.update() + child = child.get_next_sibling() + def set_config(self, config): OverlayWindow.set_config(self, config) self.set_enabled(config.getboolean("enabled", fallback=False)) @@ -160,7 +124,6 @@ def set_config(self, config): 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.set_line_limit(config.getint("line_limit", fallback=20)) self.set_hide_on_mouseover(config.getboolean("autohide", fallback=False)) self.set_mouseover_timer(config.getint("autohide_timer", fallback=1)) From 8a6ff31d0d65b22d17e0455092a730ff7ffd15f9 Mon Sep 17 00:00:00 2001 From: trigg Date: Sat, 6 Sep 2025 23:40:30 +0100 Subject: [PATCH 05/10] - remove unused debug log --- discover_overlay/notification_overlay.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discover_overlay/notification_overlay.py b/discover_overlay/notification_overlay.py index dc93a7e..fb3fe29 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -228,7 +228,6 @@ def show_testing(self): self.add_notification_message(test) def set_text_align(self, text_align): - log.error("ALIGN CHANGE %s", text_align) self.text_align = text_align self.update_all() From bf25843be193e35a28069b098b5e10d2c8785176 Mon Sep 17 00:00:00 2001 From: trigg Date: Sun, 7 Sep 2025 00:14:13 +0100 Subject: [PATCH 06/10] - remove unused vars --- discover_overlay/notification_overlay.py | 24 +----------------------- discover_overlay/voice_overlay.py | 16 ---------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/discover_overlay/notification_overlay.py b/discover_overlay/notification_overlay.py index fb3fe29..5901c30 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -30,7 +30,6 @@ def __init__(self, discover, piggyback=None): OverlayWindow.__init__(self, discover, piggyback) self.box = Gtk.Box() self.box.set_orientation(Gtk.Orientation.VERTICAL) - self.text_spacing = 4 self.test_content = [ { "icon_url": ( @@ -79,25 +78,14 @@ def __init__(self, discover, piggyback=None): "body": "Birdy test", }, ] - - self.text_font = None - self.text_size = 13 self.text_time = None self.show_icon = None - - self.connected = True - 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.text_align = "left" self.set_title("Discover Notifications") self.set_child(self.box) @@ -133,8 +121,8 @@ def set_icon_padding(self, padding): self.update_all() def set_border_radius(self, radius): - self.padding = radius """Config option: Radius of the border, in window-space pixels""" + self.padding = radius self.set_css( "border-radius", ".notification { border-radius: %spx; }" % (radius) ) @@ -162,10 +150,6 @@ def set_limit_width(self, limit): child.set_size_request(limit, -1) child = child.get_next_sibling() - def recv_icon(self, identifier, pix): - """Callback from image_getter for icons""" - self.image_list[identifier] = pix - def set_fg(self, fg_col): """Config option: Set default text colour""" self.set_css( @@ -189,11 +173,6 @@ def set_show_icon(self, icon): 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 - def set_font(self, font): """Config option: Font used to render text""" OverlayWindow.set_font(self, font) @@ -244,7 +223,6 @@ def set_config(self, config): 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_reverse_order(config.getboolean("rev", fallback=False)) 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)) diff --git a/discover_overlay/voice_overlay.py b/discover_overlay/voice_overlay.py index 42b271e..51ded8e 100644 --- a/discover_overlay/voice_overlay.py +++ b/discover_overlay/voice_overlay.py @@ -81,12 +81,6 @@ def __init__(self, discover, piggyback=None): 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.only_speaking = None self.highlight_self = None self.order = None @@ -96,11 +90,7 @@ def __init__(self, discover, piggyback=None): self.overflow = None self.use_dummy = False self.dummy_count = 10 - self.show_connection = True - self.show_disconnected = True self.border_width = 2 - self.icon_transparency = 0.0 - self.fancy_border = False self.only_speaking_grace_period = 0 self.text_side = 3 @@ -116,17 +106,11 @@ def __init__(self, discover, piggyback=None): 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_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.horizontal = False - self.guild_ids = tuple() self.force_location() get_surface( self.recv_avatar, "discover-overlay-default", "def", self.get_display() From e61a601006ac687d0e14d603ee04965e701ffac1 Mon Sep 17 00:00:00 2001 From: trigg Date: Sun, 7 Sep 2025 14:36:32 +0100 Subject: [PATCH 07/10] - implemented mute and deaf in voice overlay - implemented recolour of loaded pixbuf - made svg files for mute and deaf - Fixed section and config reset crash - reimplemented circle avatars after all - Improved talking border clarity at larger size - We could probably let user choose mute & deaf pix. But not happening right now - --- discover_overlay/discover_overlay.py | 18 +-- discover_overlay/glade/settings.xml | 22 +++ discover_overlay/image_getter.py | 146 ++++++++++++------ .../img/discover-overlay-deaf.png | Bin 0 -> 4268 bytes .../img/discover-overlay-deaf.svg | 44 ++++++ .../img/discover-overlay-default.png | Bin .../img/discover-overlay-default.svg | 0 .../img/discover-overlay-mute.png | Bin 0 -> 2682 bytes .../img/discover-overlay-mute.svg | 41 +++++ discover_overlay/notification_overlay.py | 6 +- discover_overlay/settings_window.py | 4 +- discover_overlay/userbox.py | 64 ++++++-- discover_overlay/voice_overlay.py | 111 +++++++++---- setup.py | 4 +- 14 files changed, 354 insertions(+), 106 deletions(-) create mode 100644 discover_overlay/img/discover-overlay-deaf.png create mode 100644 discover_overlay/img/discover-overlay-deaf.svg rename discover-overlay-default.png => discover_overlay/img/discover-overlay-default.png (100%) rename discover-overlay-default.svg => discover_overlay/img/discover-overlay-default.svg (100%) create mode 100644 discover_overlay/img/discover-overlay-mute.png create mode 100644 discover_overlay/img/discover-overlay-mute.svg diff --git a/discover_overlay/discover_overlay.py b/discover_overlay/discover_overlay.py index fa432e0..078858b 100755 --- a/discover_overlay/discover_overlay.py +++ b/discover_overlay/discover_overlay.py @@ -232,21 +232,21 @@ def config_changed(self, _a=None, _b=None, _c=None, _d=None): # Read new config config = self.config() - voice_section = RawConfigParser("") - if config.has_section("main"): - voice_section = config["main"] + if not config.has_section("main"): + config["main"] = {} + voice_section = config["main"] self.voice_overlay.set_config(voice_section) # Set Text overlay options - text_section = RawConfigParser("") - if config.has_section("text"): - text_section = config["text"] + if not config.has_section("text"): + config["text"] = {} + text_section = config["text"] self.text_overlay.set_config(text_section) # Set Notification overlay options - notification_section = RawConfigParser("") - if config.has_section("notification"): - notification_section = config["notification"] + if not config.has_section("notification"): + config["notification"] = {} + notification_section = config["notification"] self.notification_overlay.set_config(notification_section) hidden = config.getboolean("general", "hideoverlay", fallback=False) diff --git a/discover_overlay/glade/settings.xml b/discover_overlay/glade/settings.xml index 0bb2bbf..94a2761 100644 --- a/discover_overlay/glade/settings.xml +++ b/discover_overlay/glade/settings.xml @@ -967,6 +967,28 @@ + + + voice_avatar_circle_label + Circle Avatar + 0 + + 2 + 6 + + + + + + voice_avatar_circle + 1 + + + 3 + 6 + + + voice_avatar_size_label diff --git a/discover_overlay/image_getter.py b/discover_overlay/image_getter.py index 8565975..6b0fbe4 100644 --- a/discover_overlay/image_getter.py +++ b/discover_overlay/image_getter.py @@ -30,11 +30,37 @@ class SurfaceGetter: """Download and decode image to Pixbuf""" - def __init__(self, func, url, identifier, display): + def __init__(self, func, url, identifier, display, recolor): self.func = func self.identifier = identifier self.url = url 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""" @@ -63,10 +89,7 @@ def get_url(self): log.error("Unable to open %s - Connection error %s", self.url, e) return - pimage = Image.open(resp.raw) - img_byte_arr = io.BytesIO() - pimage.save(img_byte_arr, format="PNG") - content = GLib.Bytes(img_byte_arr.getvalue()) + content = self.pil_recolor(Image.open(resp.raw)) loader = GdkPixbuf.PixbufLoader() try: @@ -87,57 +110,82 @@ def get_url(self): def get_file(self): """Attempt to load the file""" errors = [] - # Grab icon from icon theme - 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, - ) + icon = None + content = None + log.info("Opening local file : %s", self.url) + if "/" in self.url or "." in self.url or "~" in self.url: # URL is a filename + # 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 = GdkPixbuf.Pixbuf.new_from_file(icon.get_file().get_path()) + self.func(self.identifier, image) + return + 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}") - if icon: - try: - image = GdkPixbuf.Pixbuf.new_from_file(icon.get_file().get_path()) - self.func(self.identifier, image) - return - 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}") - else: errors.append("Not an icon : 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", - ) - if not os.path.isfile(mixpath): - errors.append(f"File not found: {mixpath}") - continue - image = None - try: - image = GdkPixbuf.Pixbuf.new_from_file(mixpath) - except ValueError: - errors.append(f"Value Error - Unable to read {mixpath}") - except TypeError: - errors.append(f"Type Error - Unable to read {mixpath}") - except FileNotFoundError: - errors.append(f"File not found: {mixpath}") - if image: - self.func(self.identifier, image) - return + return + + 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, display): +def get_surface(func, identifier, ava, display, recolor=[1.0, 1.0, 1.0, 1.0]): """Download to Pixbuf""" - image_getter = SurfaceGetter(func, identifier, ava, display) + image_getter = SurfaceGetter(func, identifier, ava, display, recolor) if identifier.startswith("http"): thread = threading.Thread(target=image_getter.get_url) thread.start() diff --git a/discover_overlay/img/discover-overlay-deaf.png b/discover_overlay/img/discover-overlay-deaf.png new file mode 100644 index 0000000000000000000000000000000000000000..811193e5cb670e054ff58c8b8782d8519375aa7b GIT binary patch literal 4268 zcmcIni$9b7`=7`m$55e$%1DYFLX0^ih7fuZvN91XL=It_N<1Y~b0&ET$&*7wSXfzY z&zxIw)*7K1HqS6@w%^_B_xirRf5G>9z3$iNzOUSln47e2oNlOAlV9cvfV2}v2KWh&HHDt>QuI&76jrDeR5F4W&!vYW5NEh$3 zAW)Jn2$Y%z0&M}R)L$S_+zAk9;W7whRR{v@i+)Nt2L%i=(JmKbK_J7!zb~=GN?1Ig zl)42!>mj(&dV==00% zdXIdQzKs5EnLX2$kG+(g$juRxG_2szLx($ePh0v2csOL9h_R?> z@Sn|{emCFLVSb4}&6-W>9GU8vD!Pp!Y1NdkedfIzSX^H0=N)#67@AyM=0b^K&*qDY z!XEuj#{X0fB&ynn^@P6t?x<*U>(Ye_Bj1YLL(7d{Qn>A}vy8W&&DWZe3y(Z>B^SD? zI_*TdYljc}2S5`!KN8JLvW_@efpkWPhK9)U`^^`-Lrkop1MfeoaV3$x?-pPI&;eHM zeCs*YO>&_zve%7V$R!ubo>QHLpplW0%e@~ye1KMZN~P*%pl8TgEy>SXCb;dF$c0nn z!u`+zRK%q-=Ty~^L1yF>n};t<&#BtLtgZQSr8|_$0}V&^NPO%Y9?yW3=K4NRcJ)3I z?n1kBLXO=#&qZe$YSDQKE>=3snu_4HvbT7J%}(OIy=m{DJpm}JCrt%ES8fgo52DGS zmiR_N?X3sW~~U{4V2X#Qj>$()Ia~Y)Hko& z!i`kc3?#Y8ECuXZYO}EDvT(r7a{(T;zM$z_mEP^bdRLT zL=EQxtlqAtJLiumz4Pyck z^3#+tCZnUsU3cQ{72<;zznI+l0c_eInSK)X(eEo{Wm1&cJK@;@C|=r?L{BA&mR#4$ zS|Ew2AQp8t=2E41I0{e#w(REk>e8&{2Y6S6XxdtS2BRdSpVt@K5utQfLZVZf z39D&Vl(gscudJ-B^&QQ4@ZiDgyV|Xv{rS7$h`?nAq$?YD!rc6w2d!amhOh*`zSBOM z59B_|yE`-_q{2dul;Ae5#|%i*0$+`cjLfMB3sBu+!?#-5e2Zqc+rYw;{P}?OMtz_m zg1%_IrE^|q+eb-)z4*xlBDhvyI(8O+rbJC31DD#ncQ2;Krze+FejJGNb;X$OP7wsP zlySz{nQz_ZE(05f2rMj+8Jch(0M#XJcgu!3`!YYXu8sM9+mT;E)$LtT?CtOGG?c$O zzQA$ob}ie_D!p%KRvLksA5!g_ntV<&C3=1g(FP;ch? zEBJVhQ7wZQl9<@)O#5n#DG=!zgLULc$-&nT%Ipg zugpy>Eejva$0o;EX)l zp+Gj17eMgebf?v};2LGc#p5m;6BYt;dn1FT=JPT4i0ZBq$fj{GU-&1dC1C?Kd{1bZ zPWLVfxh}doqyCgJ zRGGw1!lgk9fpYuoMKuMa{-B^#1=yR4fbXhNWD#gO9Zth1uhS{rFb3i!`oJTG#WEzE zTKnT5rTctaAm%WG-TphMO7BgX5uTmPmImDIFNf#RwK^=nHcu5{zZ|v?KHW4E)f_&l zcxa`jnMZ!J!IMfO#*z>>^mvp(4@|}-0wq0g;nQbW7q+dasA!}$QsWRaR>#o( zZ=TT8>W>BEF8Ubo&ljIY3zm$yp^T8@%k7Z#D^ut+BA0|Hm9(U*`&Ii!>fEMQ57Ogm z2V>e&Y#408|L42&V7dx93g7-yj%hnP`ikhZ@pZSb*qjTdG+GYI%ns@gShC}R%#|t& zQFU!|`U6~y;WEZ*}~oLp~$OUb4Yg^0DT`Y!RRQ$4i%rX_FU>E_p)bM-~$ z-&uaTITiJebFq1@vwdkW8-@j0jn1Rb`J2ni%1*dk&b)tRgNu_-=`3wYJ|tNCz0cxz z;oeJhlt{e89O3bCSpw{E;@s##=HrUD_=ottI$17T?alF?)xJsMjHuCT>?G8ZCcLax zUlhEC)%q8WrD*Ki;c4vwrhD!Ppo2wOb>@^C8xjVp;iieI0NX*2B7VvQx0 zRdl=Zm~K{t@e4`tq}~ScQO6wt?zFH21}jE?s0)2i9dF;9E4AB2!d}lJGKM0o#NZbx zjaKfzU8j6$5a0N4)ON|E2r`y8ECC=q8(AdZoQ$zzoYAf-u<%DA{Fg=S<>gK~x1G=p zbjfFcz%mU4;qR$cRaNt11KgyAV|@371m3p6Q>bB68C+LbeORS-5AO)a4IWm+4S0`>gBGhYAv)NHr}<4gem}s(2`1te#e6Lk=O>c9QFDH zs8H>2>TvABeS2G~S)7OO zZcNM(gy&v}U+Xwk01Ik~oWk$F1E6n_107&S+M<^BNn@w3@@`jsCCi}W%!IYo)%JzR0ijSB0PblQ{LJwTI^pt3efoYYg4>k;WM`N# zeT1EfW1}ZA_mLR<2sN(%^~b=LP(t(f#%jZjn{!deMw}b#SG@*r;3z3L1#SJ>!w)^I zHK(h{Wb*LwXBtYv{zcH=iqcDwH%lA?fxgJJ%2gn7Ix=5^q5`i-7hWBuH|c*+JWvHH znEIlg&X}r;`@t6mz=Is*6cj>r`Yh8Iv{2a>kLJBInfG*;;xxJy?Ez@aUNE~1%)0z{ zDMD|gI_oD4Y!H_e67n*)B6`vNa;jH<|lWW~RSQ3e3Qx}z!zpDDcO%1hF zZtO0;Ot{saVoP`4Q28omIVEid@B$xGfoSIl;mxqE!?d!l^xYDq^w<|Cd83eiy1$Bw z%7s(;m)^Yc$R?i<(Vc(^C4EVCnNIlheJArXpKb=c1{fRL50jI*aBFlWaZ*(FuylNIY6>0P#)-}GoVoq1g}S2TlPR=edl|u?7{SJc zQ{QxxHKIQ;t~dP6>xi9;(nKvegL}AIdz-UKS)&LgYLV&inicz7yGJpnWtPy7uy~99 z>#VcbYvz;;)@>nXaHV)#ye~q_qr4&S_hcnuegdcp9JRc)7TTn!^H#-@ zFd6NM&%2e&(IbfeV_i2+EdR13PDQ9#{`YKTJRg;*@PSD{SNo3B{#n1HaArg0i3;3Ymfj@~s zu)$CL0H~c+NZmv^)6t`gKB%ugvh-xpNw15}@knK@UrUto3T}q2_?Q7Ru7K^HaKzok z)YirI7eNXt811Q8cejl@tGFFFC5(3ELhJI=!veecxE+BBl71>b2&Vtjv+#eMyR?YJ Y%N0K>z>% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..187e94ee142f5ea81237d8515a97b939fc737b73 GIT binary patch literal 2682 zcmb_eX;hQP8lEJ?uo{#o`zDJ)3Wy+}rUsNP>|hQ1l0wCTA_yYHLZW~uB7{T=C>X9r z6ag0m1EPe6TcU!bLV+4Yh=3GnAjpzX0=XaOp8Kyq?(MmA&YXGYd7k$>@60Mk&%!aY-*6lA=tZDUs0fm zQLJZ>F92k70I0bD{0TwS-vKzY8-UL-0PHUZKr^}WSs)G~l#)F|Qvt9x+32v0JNR@6 zM5PgY-B8m?C>33kgorsyNcIcC)#aNEfoR0PP&Zz`Yd$mbT+X;_L#11j8;iwOM7mit z;C;LT`rMvI%uUiVk5tz;SSd-kWRLvj6$mT`+% zSAi{94o~#=y#AxT<@ArNB>5Ck&hdp1*T5zYu}<3@H4$?AEjB!{*!$V1t@N}vVdtK| zJ$|jilxCNrXIPTff0D+!0a>J5&VGIpv#M*RWm`v5=-qImanm({+GSS1kJd|UqR7@U zZcq^Gqw}&n>LEKk=UHW+gH5NG-*$rlQYp znTUQIBKx4Yyb@`SE)03{uHG}@MUUH<63GX=mwF5V6E>4C4?lUD@W>*8dn5SgpMU|<&ttHk* zu_1>mVW1j-006K6U;uy)84^(8!1=G?*)11^284LiH(s9?DaXvJlsc2q8r+an6Nmew zZBY+Tm822rk>j_!8(2@4$XP0uNK8jLrb8x^IS}lW+B?U#ngQh)Rpn~K^`TAD9gL|^ zt7>pM0_0qj=XAqnm1OuN_zulaQ&a4tbpuzr%5BsdU)a_Je6^kV9UTL3D)Pw9cD~AB zU*8D_%YlR?e~Vna;)NJZjXQvLTkP-W*B)26?^>Tc$Y&|a&7EqC7jcHw%)7OLOqHUp z@Y8oS-ms2N@qsA}Z(hZx98RS?l)0!YOqrXTufMrwOYEAw>a3f|;AcHoI)0H2&w;-@ z4zs`UrhwIbSXn-!qM|Y!YEcdLgn(Q_aT8F)k9`kQ&+h1l8_B45kzY0~1xVaq& zQ8+8WMQ4OTLalx{Z7~&^{7+v0_2>T|Li#r*GH5kW#Om4q?s1n`PK4hdM7;=moha44 z>b_5SkX)}`cn;RY$o)1mbLH*x_Ah7mSLJKW#^2o5*s5F@({DyDwJh~IFt=02U@&a7 zV>jQ)deqzMm^9kH)>EW^KtNzym3j^2-jJ0|y1)wCTwV5bo#4kxS9$9(T;HTeI4m{g ztfdQ}1b($W`c;Cop&1`~7aSzOfaC+Byq!NI4EyonirIkaT6`5xw(C{)kR>KYPkHQD zr(BNwx8T9d_jQD2ecpUUNiPXlz&I!wYO>nqTy4OJ7mdi^n8h}O5B4l6tGba{= zVhQ^|n_wK;jnE)Vp<2i7dR1u<;7=}<$`K^3+3@j$JhX;^$FE83vyb+ScN@DLc zd$G5})Fh!w~lGb#!ExZZN@8n|xAB z**3@wl$u3;(#>*~dg!euu~#h!6u&=9A`k}rHhQm^WZ+C|kSw!@Mf!t++w@)ndFRiG zs~iH^x@i2cot+(8T#s;BmXrAI#l{7<))p2PhBIo;SLWfb!pA~4oz}@#24W}ZMIK9$ zT7Zsm@ex4f3GV_>-02Sk2Kh`lz-`7OL53bA)N+0iHM-;bW1d3FNxRW7Zx~P_khAr; zkWvY%7#?)}e8@ip9sPFE_~&ouLt}l6C)L~ATUw+&6B84wK3W*(r~2kHDrZREo%wv2 zZw8zYo)&f%$WtRWpB1pwKWgu$3FP)d6q1H7YyE`6HJ9KKMW6k%-XlPe{Y%~RjanN~ zOqh36vEk8P>gyHt*{7|MKs5>;KlDtG7H3w~Ff)ko;{X|%ag0c_Kf?Olx}VL zAWBCEz$4%jhgu`nt%UybM_-rox+lL}IdbGkeQ+;mw1TSaouFw$pB-6H)}Xz@F~(Gl zo)rB2kzK*1ZBP7%*tfUE{trr!Hby>Dt>{(WU(N}`edH-3Qr$|adChtUtXvG3QsL~ZZ{ MH$T@0hhi@L3zG_v{Qv*} literal 0 HcmV?d00001 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/notification_overlay.py b/discover_overlay/notification_overlay.py index 5901c30..5f9cc69 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -92,7 +92,7 @@ def __init__(self, discover, piggyback=None): def set_blank(self): """Set to no data and redraw""" - pass + # TODO Consider losing this as everything should time out correctly def add_notification_message(self, data): """Add new message to dataset""" @@ -178,10 +178,6 @@ def set_font(self, font): OverlayWindow.set_font(self, font) self.update_all() - def recv_attach(self, identifier, pix): - """Callback from image_getter for attachments""" - self.icons[identifier] = pix - def has_content(self): """Return true if this overlay has meaningful content to show""" if not self.enabled: diff --git a/discover_overlay/settings_window.py b/discover_overlay/settings_window.py index 967e964..7f1d634 100644 --- a/discover_overlay/settings_window.py +++ b/discover_overlay/settings_window.py @@ -952,6 +952,9 @@ 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_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()}") @@ -1132,7 +1135,6 @@ def notification_border_radius_changed(self, button): self.config_set("notification", "border_radius", f"{int(button.get_value())}") def notification_show_test_content_changed(self, _button): - log.error("FUCK SAKE") self.config_set("notification", "show_dummy", "True") def core_run_on_startup_changed(self, button): diff --git a/discover_overlay/userbox.py b/discover_overlay/userbox.py index 1297cf9..ed0d005 100644 --- a/discover_overlay/userbox.py +++ b/discover_overlay/userbox.py @@ -34,23 +34,27 @@ def do_allocate(self, widget, width, height, _baseline): img_alloc.width = img_alloc.height = asize if direction == Direction.LTR: - img_alloc.x = img_alloc.y = lbl_alloc.y = 0 + img_alloc.y = height / 2 - int(asize / 2) + img_alloc.x = lbl_alloc.y = 0 lbl_alloc.x = asize - lbl_alloc.height = img_alloc.height = height + lbl_alloc.height = height lbl_alloc.width = width - asize elif direction == Direction.RTL: - lbl_alloc.x = img_alloc.y = lbl_alloc.y = 0 - lbl_alloc.height = img_alloc.height = height + 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 = img_alloc.y = lbl_alloc.x = 0 + img_alloc.x = width / 2 - int(asize / 2) + img_alloc.y = lbl_alloc.x = 0 lbl_alloc.y = asize - lbl_alloc.width = img_alloc.width = width + 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 - img_alloc.x = lbl_alloc.x = lbl_alloc.y = 0 - lbl_alloc.width = img_alloc.width = width + lbl_alloc.x = lbl_alloc.y = 0 + lbl_alloc.width = width tx = widget.overlay.text_x_align if tx == "left": @@ -69,6 +73,8 @@ def do_allocate(self, widget, width, height, _baseline): 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) def do_measure(self, widget, orientation, for_size): direction = Direction(widget.overlay.text_side) @@ -98,15 +104,30 @@ def __init__(self, overlay, userid): 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.image) 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 @@ -130,6 +151,11 @@ def update_label(self, user): ) def update_image(self, user): + 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 @@ -153,11 +179,31 @@ def update_image(self, user): 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""" if self.grace_timeout: GLib.source_remove(self.grace_timeout) if talking: diff --git a/discover_overlay/voice_overlay.py b/discover_overlay/voice_overlay.py index 51ded8e..ef028a9 100644 --- a/discover_overlay/voice_overlay.py +++ b/discover_overlay/voice_overlay.py @@ -85,14 +85,15 @@ def __init__(self, discover, piggyback=None): self.highlight_self = None self.order = None self.def_avatar = None - self.mute_avatar = None - self.deaf_avatar = None + self.mutepix = None + self.deafpix = None self.overflow = None self.use_dummy = False self.dummy_count = 10 self.border_width = 2 self.only_speaking_grace_period = 0 self.text_side = 3 + self.rounded_avatar = True self.fade_out_inactive = True self.fade_out_limit = 0.1 @@ -112,13 +113,16 @@ def __init__(self, discover, piggyback=None): self.avatar_bg_col = [0.0, 0.0, 1.0, 1.0] self.userlist = [] self.force_location() - get_surface( - self.recv_avatar, "discover-overlay-default", "def", self.get_display() - ) - get_surface( - self.recv_avatar, "microphone-sensitivity-muted", "mute", self.get_display() - ) - get_surface(self.recv_avatar, "audio-volume-muted", "deaf", self.get_display()) + 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(), + ) self.set_title("Discover Voice") self.title.set_label(None) self.connection.set_connection(None) @@ -126,6 +130,30 @@ def __init__(self, discover, piggyback=None): self.connection.update_image(None) self.populate() + def recolour_icons(self): + 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 all_users(self, func): child = self.box.get_first_child() while child: @@ -185,9 +213,7 @@ def populate(self): self_user_id = connection.user["id"] # Gather which users to draw - if ( - self.use_dummy - ): # Sorting every frame is an awful idea. Maybe put this off elsewhere? + if self.use_dummy: users_to_draw = self.sort_list(self.dummy_data.copy()[0 : self.dummy_count]) userlist = self.dummy_data.copy() else: @@ -256,9 +282,21 @@ def set_talking(self, userid, talking): log.warning("Set talking on missing user") def set_mute(self, userid, muted): + widget = self.get_user_widget(userid) + user = self.get_user(userid) + if user: + user["mute"] = muted + if widget: + widget.set_mute(muted) log.info("Mute %s %s", userid, muted) def set_deaf(self, userid, deafened): + widget = self.get_user_widget(userid) + user = self.get_user(userid) + if user: + user["deaf"] = deafened + if widget: + widget.set_deaf(deafened) log.info("Deaf %s %s", userid, deafened) def reset_action_timer(self): @@ -351,26 +389,29 @@ def set_borders(self): width = self.border_width col = self.col_to_css(self.border_col) talk_col = self.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 .userlabel, .talking .usericon + .talking.user {{ - filter: drop-shadow({width}px {width}px 0 {talk_col}) - drop-shadow(-{width}px -{width}px 0 {talk_col}) - drop-shadow(-{width}px {width}px 0 {talk_col}) - drop-shadow({width}px -{width}px 0 {talk_col}); + filter: {drop_shadow_talking}; }} - .userlabel, .usericon + .user {{ - filter: drop-shadow({width}px {width}px 0 {col}) - drop-shadow(-{width}px -{width}px 0 {col}) - drop-shadow(-{width}px {width}px 0 {col}) - drop-shadow({width}px -{width}px 0 {col}); + filter: {drop_shadow_normal}; }} - .usericon + .usericon, .usermute, .userdeaf {{ - margin: {width}px; + {rounded} }} """, ) @@ -429,12 +470,11 @@ def recv_avatar(self, identifier, pix): if identifier == "def": self.def_avatar = pix self.all_users(lambda user, widget: widget.update_image(user)) - return elif identifier == "mute": - self.mute_avatar = pix + self.mutepix = pix self.all_users(lambda user, widget: widget.update_image(user)) elif identifier == "deaf": - self.deaf_avatar = pix + self.deafpix = pix self.all_users(lambda user, widget: widget.update_image(user)) elif identifier == "channel": self.title.set_image(pix) @@ -461,7 +501,10 @@ def set_config(self, config): horizontal = config.getboolean("horizontal", fallback=False) - self.mute_col = json.loads(config.get("mt_col", fallback="[0.6,0.0,0.0,1.0]")) + 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( @@ -486,11 +529,12 @@ def set_config(self, config): + ";}", ) # Mute/deaf background colour + m_bg_col = self.col_to_css( + config.get("mt_bg_col", fallback=[0.0, 0.0, 0.0, 0.5]) + ) self.set_css( "mute-background", - ".mute .userlabel, .mute .usericon { background-color: " - + self.col_to_css(config.get("mt_bg_col", fallback=[0.0, 0.0, 0.0, 0.5])) - + ";}", + 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", @@ -499,6 +543,8 @@ def set_config(self, config): + ";}", ) + 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", @@ -510,7 +556,8 @@ def set_config(self, config): self.avatar_size = config.getint("avatar_size", fallback=48) self.set_css( "avatar_size", - ".usericon { -gtk-icon-size:%spx; }" % (self.avatar_size), + ".usericon, .usermute, .userdeaf { -gtk-icon-size:%spx; }" + % (self.avatar_size), ) self.nick_length = config.getint("nick_length", fallback=32) diff --git a/setup.py b/setup.py index 27561ea..93bc0d8 100644 --- a/setup.py +++ b/setup.py @@ -68,7 +68,9 @@ def readme(): "Topic :: Communications :: Chat", "Topic :: Communications :: Conferencing", ], - package_data={"discover_overlay": ["locales/*/LC_MESSAGES/*.mo", "glade/*"]}, + package_data={ + "discover_overlay": ["locales/*/LC_MESSAGES/*.mo", "glade/*", "img/*"] + }, keywords="discord overlay voice linux", license="GPLv3+", ) From b06575ff7d967fef873fff25f1ab29c2632168e7 Mon Sep 17 00:00:00 2001 From: trigg Date: Sun, 7 Sep 2025 19:24:47 +0100 Subject: [PATCH 08/10] - renamed helper - make data handlers into a gtk box, not window itself - non-compositor code path should quit for now until we make any kind of working XShape impl - Fix channel rpc by keeping the reference... --- .../{font_helper.py => css_helper.py} | 13 ++ discover_overlay/discord_connector.py | 4 +- discover_overlay/discover_overlay.py | 51 ++++--- discover_overlay/message.py | 16 +- discover_overlay/notification.py | 2 +- discover_overlay/notification_overlay.py | 61 ++++---- discover_overlay/overlay.py | 143 ++++++++++++------ discover_overlay/settings_window.py | 19 ++- discover_overlay/text_overlay.py | 75 ++++----- discover_overlay/voice_overlay.py | 104 ++++++------- 10 files changed, 287 insertions(+), 201 deletions(-) rename discover_overlay/{font_helper.py => css_helper.py} (82%) diff --git a/discover_overlay/font_helper.py b/discover_overlay/css_helper.py similarity index 82% rename from discover_overlay/font_helper.py rename to discover_overlay/css_helper.py index de4f41c..a75871d 100644 --- a/discover_overlay/font_helper.py +++ b/discover_overlay/css_helper.py @@ -12,6 +12,7 @@ # 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 @@ -41,3 +42,15 @@ def font_string_to_css_font_string(string_in): 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 84dcdff..9099580 100644 --- a/discover_overlay/discord_connector.py +++ b/discover_overlay/discord_connector.py @@ -772,7 +772,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) @@ -782,6 +782,8 @@ def connect(self): GLib.IOCondition.HUP | GLib.IOCondition.IN | GLib.IOCondition.ERR, self.socket_glib, ) + except websocket._exceptions.WebSocketTimeoutException: + self.schedule_reconnect() except ConnectionError as _error: self.schedule_reconnect() diff --git a/discover_overlay/discover_overlay.py b/discover_overlay/discover_overlay.py index 078858b..04e3144 100755 --- a/discover_overlay/discover_overlay.py +++ b/discover_overlay/discover_overlay.py @@ -28,6 +28,7 @@ import gi +from .overlay import OverlayWindow from .settings_window import Settings from .voice_overlay import VoiceOverlayWindow from .text_overlay import TextOverlayWindow @@ -232,27 +233,36 @@ def config_changed(self, _a=None, _b=None, _c=None, _d=None): # Read new config config = self.config() + hidden = config.getboolean("general", "hideoverlay", fallback=False) + 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 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 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) - 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) + if self.one_window: + self.one_window.set_hidden(hidden) self.audio_assist.set_enabled( config.getboolean("general", "audio_assist", fallback=False) @@ -271,16 +281,30 @@ 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: app = Settings( @@ -293,16 +317,6 @@ def create_gui(self): ) app.connect("activate", app.start) - 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) - def close(self, _a=None, _b=None, _c=None): """ End of the program @@ -360,6 +374,7 @@ def entrypoint(): # Hedge against the bet gamescope ships with some WAYLAND_DISPLAY # Compatibility and we're not ready yet if "GAMESCOPE_WAYLAND_DISPLAY" in os.environ: + os.environ["GDK_BACKEND"] = "x11" os.unsetenv("WAYLAND_DISPLAY") # Catch any errors and log them diff --git a/discover_overlay/message.py b/discover_overlay/message.py index fed94a5..44a876f 100644 --- a/discover_overlay/message.py +++ b/discover_overlay/message.py @@ -41,19 +41,9 @@ def do_allocate(self, widget, width, height, _baseline): def do_measure(self, widget, orientation, _for_size): if orientation == Gtk.Orientation.VERTICAL: - return (widget.overlay.height_limit, widget.overlay.height_limit, -1, -1) + return (widget.height_limit, widget.height_limit, -1, -1) else: - return (widget.overlay.width_limit, widget.overlay.width_limit, -1, -1) - - -class MessageBox(Gtk.Box): - def __init__(self, overlay): - Gtk.Box.__init__(self) - self.overlay = overlay - self.set_layout_manager(MessageBoxLayout()) - self.set_overflow(Gtk.Overflow.HIDDEN) - self.set_orientation(Gtk.Orientation.VERTICAL) - self.add_css_class("messagebox") + return (widget.width_limit, widget.width_limit, -1, -1) class Message(Gtk.Box): @@ -89,7 +79,7 @@ def update(self): def exit(self): """Remove self from overlay""" - self.overlay.box.remove(self) + self.overlay.remove(self) self.overlay.update() def make_line(self, message): diff --git a/discover_overlay/notification.py b/discover_overlay/notification.py index 6bb099e..f06dab0 100644 --- a/discover_overlay/notification.py +++ b/discover_overlay/notification.py @@ -170,7 +170,7 @@ def recv_avatar(self, _ident, pix): def exit(self): """Remove self from visible notifications""" - self.overlay.box.remove(self) + self.overlay.remove(self) def update(self): """Change child properties based on config of overlay""" diff --git a/discover_overlay/notification_overlay.py b/discover_overlay/notification_overlay.py index 5f9cc69..a2d32e1 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -14,8 +14,9 @@ import logging import json import gi -from .overlay import OverlayWindow, get_h_align +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 @@ -23,13 +24,14 @@ 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.box = Gtk.Box() - self.box.set_orientation(Gtk.Orientation.VERTICAL) + Gtk.Box.__init__(self) + self.discover = discover + + self.set_orientation(Gtk.Orientation.VERTICAL) self.test_content = [ { "icon_url": ( @@ -87,8 +89,9 @@ def __init__(self, discover, piggyback=None): self.icon_pad = 16 self.icon_left = True self.text_align = "left" - self.set_title("Discover Notifications") - self.set_child(self.box) + self.align_x = HorzAlign.RIGHT + self.align_y = VertAlign.TOP + self.show() def set_blank(self): """Set to no data and redraw""" @@ -106,7 +109,7 @@ def add_notification_message(self, data): data["body"] if "body" in data else "", self.text_time, ) - self.box.append(n_not) + self.append(n_not) n_not.show() # n_not.set_reveal_child(True) else: @@ -114,7 +117,13 @@ def add_notification_message(self, data): def set_padding(self, padding): """Config option: Padding between notifications, in window-space pixels""" - self.box.set_spacing(padding) + 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): self.icon_pad = padding @@ -144,8 +153,7 @@ def set_text_time(self, timer): def set_limit_width(self, limit): """Config option: Word wrap limit, in window-space pixels""" - self.set_default_size(limit, -1) - child = self.box.get_first_child() + child = self.get_first_child() while child: child.set_size_request(limit, -1) child = child.get_next_sibling() @@ -155,36 +163,27 @@ def set_fg(self, fg_col): self.set_css( "text-col", ".notification .message, .notification .title { color: %s; }" - % (self.col_to_css(fg_col)), + % (col_to_css(fg_col)), ) def set_bg(self, bg_col): """Config option: Set background colour""" self.set_css( "background", - ".notification { background-color: %s; }" % (self.col_to_css(bg_col)), + ".notification { background-color: %s; }" % (col_to_css(bg_col)), ) def set_show_icon(self, icon): """Config option: Set if icons should be shown inline""" self.show_icon = icon - child = self.box.get_first_child() + child = self.get_first_child() while child: child.queue_allocate() child = child.get_next_sibling() - def set_font(self, font): - """Config option: Font used to render text""" - OverlayWindow.set_font(self, font) - self.update_all() - - 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.box.get_first_child() is not None: + if self.get_first_child() is not None: return True return False @@ -207,14 +206,15 @@ def set_text_align(self, text_align): self.update_all() def update_all(self): - child = self.box.get_first_child() + child = self.get_first_child() while child: child.update() child = child.get_next_sibling() def set_config(self, config): - OverlayWindow.set_config(self, config) 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)) @@ -235,5 +235,8 @@ def set_config(self, config): if font: self.set_font(font) - self.set_monitor(config.get("monitor", fallback="Any")) - self.set_enabled(config.getboolean("enabled", fallback=False)) + def set_css(self, id, rule): + self.get_native().set_css(id, rule) + + def get_align(self): + return (self.align_x, self.align_y) diff --git a/discover_overlay/overlay.py b/discover_overlay/overlay.py index 56a3b15..3f4baac 100644 --- a/discover_overlay/overlay.py +++ b/discover_overlay/overlay.py @@ -19,19 +19,17 @@ CDLL("libgtk4-layer-shell.so") from enum import Enum import logging -import json import gi import cairo from Xlib.display import Display from Xlib import X, Xatom from ewmh import EWMH -from .font_helper import font_string_to_css_font_string +from .css_helper import font_string_to_css_font_string gi.require_version("Gtk", "4.0") gi.require_version("GdkWayland", "4.0") gi.require_version("Gtk4LayerShell", "1.0") - from gi.repository import Gtk, Gdk, GLib, GdkX11, GdkWayland, Gtk4LayerShell log = logging.getLogger(__name__) @@ -86,13 +84,55 @@ def get_v_align(in_str): return None +class AmalgamationLayout(Gtk.LayoutManager): + + 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() + + 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 OverlayWindow(Gtk.Window): """ Overlay parent class. Helpful if we need more overlay types without copy-and-pasting too much code """ - def __init__(self, discover, piggyback=None): + def __init__(self, discover): Gtk.Window.__init__(self) self.css_prov = {} @@ -101,9 +141,10 @@ def __init__(self, discover, piggyback=None): ) self.is_xatom_set = False + self.widget = None + self.amalgamation = None + self.discover = discover - self.text_font = None - self.text_size = None self.pos_x = None self.pos_y = None self.width = None @@ -119,12 +160,6 @@ def __init__(self, discover, piggyback=None): self.horzalign = HorzAlign.LEFT self.vertalign = VertAlign.TOP - self.piggyback = None - self.piggyback_parent = None - if not piggyback: - self.show() - if discover.steamos: - self.set_gamescope_xatom(1) self.monitor = "Any" self.context = None @@ -133,8 +168,6 @@ def __init__(self, discover, piggyback=None): self.timeout_mouse_over = 1 self.timer_after_draw = None - if piggyback: - self.set_piggyback(piggyback) self.get_display().connect("setting-changed", self.screen_changed) # TODO Find compositor-change for GTK4. Currently only checks once at start @@ -188,9 +221,6 @@ def window_exited(self, _window=None): def set_x11_window_location(self, x, y): """Set Window location using X11""" - if self.piggyback_parent: - return - if isinstance(self.get_surface(), GdkX11.X11Surface): display = Display() topw = display.create_resource_object( @@ -208,8 +238,6 @@ def set_x11_window_location(self, x, y): def set_gamescope_xatom(self, enabled): """Set Gamescope XAtom to identify self as an overlay candidate""" - if self.piggyback_parent: - return if enabled == self.is_xatom_set: return @@ -257,11 +285,6 @@ def set_wayland_state(self): self, Gtk4LayerShell.Edge.TOP, self.vertalign == VertAlign.TOP ) - def set_piggyback(self, other_overlay): - """Sets as piggybacking off the given (other) overlay""" - other_overlay.piggyback = self - self.piggyback_parent = other_overlay - def has_content(self): """Return true if overlay has meaningful content""" return False @@ -318,6 +341,7 @@ def force_location(self): On Wayland just store for later On Gamescope enforce size of display but only if it's the primary overlay """ + self.show() (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords() self.set_decorated(True) self.set_can_focus(False) @@ -325,20 +349,19 @@ def force_location(self): # 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 isinstance(surface, GdkWayland.WaylandSurface): self.set_wayland_state() elif isinstance(surface, GdkX11.X11Surface): - # if not self.get_display().is_composited(): - # log.error("Unable to function without compositor") - # self.discover.exit() + if not self.get_display().is_composited(): + log.error("Unable to function without compositor") + self.discover.exit() surface.set_skip_pager_hint(True) surface.set_skip_taskbar_hint(True) self.set_decorated(False) self.set_size_request(screen_width, screen_height) self.set_x11_window_location(screen_x, screen_y) else: - log.error("Unknown windowing system. Exiting") + log.error("Unknown windowing system. %s, Exiting", surface) self.discover.exit() self.set_untouchable() @@ -419,18 +442,15 @@ def set_enabled(self, enabled): Set if this overlay should be visible """ self.enabled = enabled - if self.piggyback_parent or self.piggyback: - - if not self.piggyback_parent: - self.set_gamescope_xatom(1 if enabled else 0) - return + if self.discover.steamos: + self.set_gamescope_xatom(1 if enabled else 0) if enabled and not self.hidden: self.present() self.show() else: self.hide() - 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""" if not self.get_display().is_composited(): log.error("Unable to function without compositor") @@ -453,17 +473,6 @@ def mouseout_timed(self, _a=None, _b=None): self.show() return False - def col_to_css(self, 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})" - def set_config(self, config): """Set the configuration of this overlay from the given config section""" # Set Voice overlay options @@ -488,3 +497,45 @@ def set_config(self, config): 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=1)) + self.set_enabled(config.getboolean("enabled", fallback=True)) + + self.set_visibility() + + def overlay(self, widget): + self.widget = widget + self.set_child(widget) + + def merged_overlay(self, widget_list): + 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): + if self.should_show(): + self.show() + 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 7f1d634..0041157 100644 --- a/discover_overlay/settings_window.py +++ b/discover_overlay/settings_window.py @@ -28,6 +28,7 @@ from gi.repository import Gtk, Gdk, Gio +logging.basicConfig(stream=sys.stdout) log = logging.getLogger(__name__) with importlib_resources.as_file( importlib_resources.files("discover_overlay") / "locales" @@ -75,6 +76,7 @@ def __init__(self, a, b, config_file, rpc_file, channel_file, args): self.server_handler = None self.channel_handler = None self.hidden_overlay_handler = None + self.monitor_channel = None def start(self, _x): @@ -175,9 +177,10 @@ def start(self, _x): "items-changed", self.populate_monitor_menus ) + log.info(self.channel_file) channel_file = Gio.File.new_for_path(self.channel_file) - monitor_channel = channel_file.monitor_file(0, None) - monitor_channel.connect("changed", self.populate_guild_menu) + 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 @@ -191,8 +194,6 @@ def start(self, _x): self.read_config() - self.populate_guild_menu() - # TODO Re-fix gamepad support # window.connect('key-press-event', self.keypress_in_settings) @@ -204,6 +205,8 @@ def start(self, _x): self.window.show() + self.populate_guild_menu() + def set_steamos_window_size(self): """Set window based on steamos usage""" # Huge bunch of assumptions. @@ -317,9 +320,12 @@ def request_channels_from_guild(self, guild_id): 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, _e=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""" + log.info(_o_file) log.info("Populating guild and channel") g = self.widget["text_server"] c = self.widget["text_channel"] @@ -1010,7 +1016,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: diff --git a/discover_overlay/text_overlay.py b/discover_overlay/text_overlay.py index 62cac6d..d6330a9 100644 --- a/discover_overlay/text_overlay.py +++ b/discover_overlay/text_overlay.py @@ -15,8 +15,9 @@ import re import json import gi -from .overlay import OverlayWindow -from .message import Message, MessageBox +from .css_helper import col_to_css, font_string_to_css_font_string +from .message import Message, MessageBoxLayout +from .overlay import HorzAlign, VertAlign, get_h_align, get_v_align gi.require_version("Gtk", "4.0") @@ -25,41 +26,42 @@ 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.box = MessageBox(self) + 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.set_title("Discover Text") self.width_limit = 500 self.height_limit = 300 - self.set_child(self.box) - if self.popup_style and not self.has_content(): - self.hide() + self.align_x = HorzAlign.RIGHT + self.align_y = VertAlign.BOTTOM + self.show() def set_blank(self): """Set contents blank and redraw""" - child = self.box.get_first_child() + child = self.get_first_child() while child: n_child = child.get_next_sibling() - self.box.remove(child) + self.remove(child) child = n_child - if self.popup_style and not self.has_content(): - self.hide() + self.get_native().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.box.append(message) - if self.has_content(): - self.show() + self.append(message) + self.get_native().set_visibility() def set_text_time(self, timer): """Config option: Time before messages disappear from overlay""" @@ -73,37 +75,34 @@ def set_show_attach(self, attachment): self.show_attach = attachment 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 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.box.get_first_child() is not None + return self.get_first_child() is not None def update(self): """Call when removing a message automatically, allows hiding of overlay when empty""" - if not self.has_content(): - self.hide() + self.get_native().set_visibility() def update_all(self): """Tell all messages we've had something changed""" - child = self.box.get_first_child() + child = self.get_first_child() while child: child.update() child = child.get_next_sibling() def set_config(self, config): - OverlayWindow.set_config(self, config) - self.set_enabled(config.getboolean("enabled", fallback=False)) channel = config.get("channel", fallback="0") guild = config.get("guild", fallback="0") @@ -114,24 +113,28 @@ def set_config(self, config): self.set_css( "background", ".messagebox { background-color: %s; }" - % (self.col_to_css(config.get("bg_col", fallback="[0.0,0.0,0.0,0.5]"))), + % (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; }" - % (self.col_to_css(config.get("fg_col", fallback="[1.0,1.0,1.0,1.0]"))), + % (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.set_hide_on_mouseover(config.getboolean("autohide", fallback=False)) - self.set_mouseover_timer(config.getint("autohide_timer", fallback=1)) 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.box.set_size_request(self.width_limit, self.height_limit) - self.set_monitor(config.get("monitor", fallback="Any")) + 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, id, rule): + self.get_native().set_css(id, rule) + + def get_align(self): + return (self.align_x, self.align_y) diff --git a/discover_overlay/voice_overlay.py b/discover_overlay/voice_overlay.py index ef028a9..80a7b22 100644 --- a/discover_overlay/voice_overlay.py +++ b/discover_overlay/voice_overlay.py @@ -18,9 +18,9 @@ import json import importlib_resources from time import perf_counter -from .overlay import OverlayWindow, HorzAlign, VertAlign +from .overlay import HorzAlign, VertAlign from .image_getter import get_surface -from .font_helper import font_string_to_css_font_string +from .css_helper import font_string_to_css_font_string, col_to_css from .userbox import UserBox, UserBoxConnection, UserBoxTitle import gi @@ -40,18 +40,17 @@ _ = t.gettext -class VoiceOverlayWindow(OverlayWindow): +class VoiceOverlayWindow(Gtk.Box): """Overlay window for voice""" - def __init__(self, discover, piggyback=None): - OverlayWindow.__init__(self, discover, piggyback) - self.box = Gtk.Box() + def __init__(self, discover): + Gtk.Box.__init__(self) self.connection = UserBoxConnection(self) + self.discover = discover self.title = UserBoxTitle(self) - self.box.append(self.title) - self.box.append(self.connection) - self.box.add_css_class("container") - self.set_child(self.box) + 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): @@ -94,6 +93,8 @@ def __init__(self, discover, piggyback=None): 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 @@ -112,7 +113,6 @@ def __init__(self, discover, piggyback=None): 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.force_location() with importlib_resources.as_file( importlib_resources.files("discover_overlay") / "img/discover-overlay-default.png" @@ -123,12 +123,12 @@ def __init__(self, discover, piggyback=None): "def", self.get_display(), ) - self.set_title("Discover Voice") self.title.set_label(None) self.connection.set_connection(None) self.title.update_label(None) self.connection.update_image(None) self.populate() + self.show() def recolour_icons(self): with importlib_resources.as_file( @@ -155,7 +155,7 @@ def recolour_icons(self): ) def all_users(self, func): - child = self.box.get_first_child() + child = self.get_first_child() while child: user = self.get_user(child.userid) func(user, child) @@ -168,7 +168,7 @@ def get_user(self, userid): return None def get_user_widget(self, userid): - child = self.box.get_first_child() + child = self.get_first_child() while child: if userid == child.userid: return child @@ -176,27 +176,30 @@ def get_user_widget(self, userid): return None def set_align_x(self, align): - OverlayWindow.set_align_x(self, align) + self.align_x = align if align == HorzAlign.LEFT: - self.box.set_halign(Gtk.Align.START) + self.set_halign(Gtk.Align.START) elif align == HorzAlign.MIDDLE: - self.box.set_halign(Gtk.Align.CENTER) + self.set_halign(Gtk.Align.CENTER) else: - self.box.set_halign(Gtk.Align.END) + self.set_halign(Gtk.Align.END) def set_align_y(self, align): - OverlayWindow.set_align_y(self, align) + self.align_y = align if align == VertAlign.TOP: - self.box.set_valign(Gtk.Align.START) + self.set_valign(Gtk.Align.START) elif align == VertAlign.MIDDLE: - self.box.set_valign(Gtk.Align.CENTER) + self.set_valign(Gtk.Align.CENTER) else: - self.box.set_valign(Gtk.Align.END) + self.set_valign(Gtk.Align.END) + + def get_align(self): + return (self.align_x, self.align_y) def populate(self): - child = self.box.get_last_child() + child = self.get_last_child() + self.queue_resize() self.queue_resize() - self.box.queue_resize() while child: child.queue_resize() n_child = child.get_prev_sibling() @@ -204,7 +207,7 @@ def populate(self): child = n_child continue if child.userid not in self.userlist: - self.box.remove(child) + self.remove(child) child.hide() child = n_child connection = self.discover.connection @@ -266,9 +269,9 @@ def populate(self): userbox.update_image(user) userbox.update_label(user) - self.box.append(userbox) + self.append(userbox) userbox.show() - self.box.show() + self.show() def set_talking(self, userid, talking): log.info("Talking %s %s", userid, talking) @@ -371,6 +374,12 @@ def set_fade_out_inactive(self, enabled, fade_time, fade_duration, fade_to): 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): """ Set the font used by the overlay @@ -387,8 +396,8 @@ def set_overflow_style(self, overflow): def set_borders(self): width = self.border_width - col = self.col_to_css(self.border_col) - talk_col = self.col_to_css(self.talk_col) + 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 = "" @@ -413,6 +422,7 @@ def set_borders(self): {{ {rounded} }} + .container {{ padding: {width*2}px; }} """, ) @@ -455,15 +465,11 @@ def sort_list(self, in_list): 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.use_dummy: return True - return self.userlist + return len(self.userlist) > 0 def recv_avatar(self, identifier, pix): """Called when image_getter has downloaded an image""" @@ -497,8 +503,6 @@ def unused_fn_needed_translations(self): _("VOICE_CONNECTED") def set_config(self, config): - OverlayWindow.set_config(self, config) - horizontal = config.getboolean("horizontal", fallback=False) mute_col = json.loads(config.get("mt_col", fallback="[0.6,0.0,0.0,1.0]")) @@ -510,14 +514,14 @@ def set_config(self, config): self.set_css( "foreground-color", "* { color: " - + self.col_to_css(config.get("fg_col", fallback="[1.0,1.0,1.0,1.0]")) + + 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: " - + self.col_to_css(config.get("fg_hi_col", fallback="[1.0,1.0,1.0,1.0]")) + + 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]")) @@ -525,13 +529,11 @@ def set_config(self, config): self.set_css( "background-color", ".usericon, .userlabel { background-color: " - + self.col_to_css(config.get("bg_col", fallback="[0.0,0.0,0.0,0.2]")) + + col_to_css(config.get("bg_col", fallback="[0.0,0.0,0.0,0.2]")) + ";}", ) # Mute/deaf background colour - m_bg_col = self.col_to_css( - config.get("mt_bg_col", fallback=[0.0, 0.0, 0.0, 0.5]) - ) + 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});}}", @@ -539,7 +541,7 @@ def set_config(self, config): self.set_css( "talking-background", ".talking .userlabel, .talking .usericon { background-color: " - + self.col_to_css(config.get("hi_col", fallback="[0.0,0.0,0.0,0.5]")) + + col_to_css(config.get("hi_col", fallback="[0.0,0.0,0.0,0.5]")) + ";}", ) @@ -549,7 +551,7 @@ def set_config(self, config): self.set_css( "avatar-bg-color", ".usericon { background-color: " - + self.col_to_css(config.get("avatar_bg_col", fallback="[0.0,0.0,0.0,0.0]")) + + col_to_css(config.get("avatar_bg_col", fallback="[0.0,0.0,0.0,0.0]")) + ";}", ) @@ -562,7 +564,7 @@ def set_config(self, config): self.nick_length = config.getint("nick_length", fallback=32) - self.box.set_spacing(config.getint("icon_spacing", fallback=8)) + self.set_spacing(config.getint("icon_spacing", fallback=8)) self.set_css( "text_padding", @@ -592,10 +594,7 @@ def set_config(self, config): ) self.order = config.getint("order", fallback=0) - self.set_hide_on_mouseover(config.getboolean("autohide", fallback=False)) - self.set_mouseover_timer(config.getint("autohide_timer", fallback=1)) - - self.box.set_orientation( + self.set_orientation( Gtk.Orientation.HORIZONTAL if horizontal else Gtk.Orientation.VERTICAL ) @@ -624,8 +623,6 @@ def set_config(self, config): self.dummy_count = config.getint("dummy_count", fallback=10) - self.set_enabled(True) - font = config.get("font", fallback=None) title_font = config.get("title_font", fallback=None) if font: @@ -646,3 +643,6 @@ def set_config(self, config): ) self.populate() + + def set_css(self, id, rule): + self.get_native().set_css(id, rule) From 1483afc67b789f56ef09cce7016e20bfcd922c31 Mon Sep 17 00:00:00 2001 From: trigg Date: Mon, 8 Sep 2025 23:55:50 +0100 Subject: [PATCH 09/10] - Split connection status logic away from what discord reports - - Added 'No discord present' - - Added 'Discord version invalid' - - Flattened all intermediary states to 'Voice chat joining' - - Flattened both connected to one state - Version invalid ALWAYS shows on voice overlay - 'No discord' is on by default - Retry connection starts at 5 seconds and scales up another 5 seconds every consecutive fail - Strip voice cache and text cache from connector - Unset cached 'authed' on reconnect - Detect invalid discord versions - Moved much more logic for voice overlay into UserBox - Request new user avatar image only when details actually changed - Fixed only_talking and grace - Fixed X11 with compositor - Fixed Gamescope compat - XWayland needs testing --- discover_overlay/connection_state.py | 13 + discover_overlay/discord_connector.py | 200 +++++++------- discover_overlay/discover_overlay.py | 4 +- discover_overlay/image_getter.py | 7 +- discover_overlay/layout.py | 312 +++++++++++++++++++++ discover_overlay/message.py | 32 +-- discover_overlay/notification.py | 92 +------ discover_overlay/notification_overlay.py | 32 ++- discover_overlay/overlay.py | 229 +++++++--------- discover_overlay/settings_window.py | 10 +- discover_overlay/text_overlay.py | 25 +- discover_overlay/userbox.py | 322 +++++++++++----------- discover_overlay/voice_overlay.py | 329 ++++++++++------------- 13 files changed, 881 insertions(+), 726 deletions(-) create mode 100644 discover_overlay/connection_state.py create mode 100644 discover_overlay/layout.py 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/discord_connector.py b/discover_overlay/discord_connector.py index 9099580..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 @@ -51,14 +52,10 @@ def __init__(self, discover): 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 = [] self.authed = False self.last_rate_limit_send = 0 self.muted = False @@ -68,9 +65,12 @@ 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): """ First stage of getting an access token. Request authorization from Discord client @@ -99,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: @@ -119,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 @@ -153,17 +155,6 @@ 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 @@ -237,31 +228,6 @@ 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 @@ -278,7 +244,6 @@ 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 @@ -291,45 +256,47 @@ def on_message(self, message): j["data"]["voice_state"]["deaf"] or j["data"]["voice_state"]["self_deaf"] ) - if self.current_voice != "0": - 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.update_user(thisuser) - self.set_in_room(thisuser["id"], True) + 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"]: @@ -337,7 +304,19 @@ def on_message(self, message): 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"]) @@ -369,6 +348,8 @@ def on_message(self, message): 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() @@ -377,8 +358,7 @@ def on_message(self, message): 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 @@ -428,8 +408,6 @@ def on_message(self, message): ) else: self.discover.voice_overlay.set_channel_icon(None) - self.list_altered = True - self.in_room = [] for u in j["data"]["voice_states"]: thisuser = u["user"] nick = u["nick"] @@ -442,8 +420,7 @@ def on_message(self, message): 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": @@ -461,6 +438,7 @@ def on_message(self, message): elif j["cmd"] == "SELECT_VOICE_CHANNEL": return elif j["cmd"] == "SET_VOICE_SETTINGS": + # Keep this for toggling mute from RPC self.muted = j["data"]["mute"] self.deafened = j["data"]["deaf"] return @@ -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() @@ -687,7 +665,7 @@ def change_text_room(self, room_id): def channel_rate_limit(self): """Called regularly to pull in any required channels""" - if self.authed and len(self.rate_limited_channels) > 0: + 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 = { @@ -702,20 +680,12 @@ def channel_rate_limit(self): self.rate_limit = None return continue_rate_limit - 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 + 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): """ @@ -750,8 +720,13 @@ def request_text_rooms_for_guild(self, guild_id): 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") @@ -761,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?") @@ -782,8 +758,7 @@ def connect(self): GLib.IOCondition.HUP | GLib.IOCondition.IN | GLib.IOCondition.ERR, self.socket_glib, ) - except websocket._exceptions.WebSocketTimeoutException: - self.schedule_reconnect() + self.reconnect_time = 5 except ConnectionError as _error: self.schedule_reconnect() @@ -800,14 +775,33 @@ def socket_glib(self, _fd, condition): # Connection was closed in the meantime break recv, _w, _e = select.select((self.websocket.sock,), (), (), 0) - except ( - websocket.WebSocketConnectionClosedException, - json.decoder.JSONDecodeError, - ): + 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 04e3144..093894e 100755 --- a/discover_overlay/discover_overlay.py +++ b/discover_overlay/discover_overlay.py @@ -20,7 +20,7 @@ import logging import signal import importlib_resources -from configparser import ConfigParser, RawConfigParser +from configparser import ConfigParser from ctypes import CDLL from _version import __version__ @@ -316,6 +316,7 @@ def create_gui(self): sys.argv[1:], ) app.connect("activate", app.start) + app.run() def close(self, _a=None, _b=None, _c=None): """ @@ -390,6 +391,7 @@ def entrypoint(): else: if "-c" in sys.argv or "--configure" in sys.argv: # Show config window + # pylint: disable=E1101 app = Settings( "io.github.trigg.discover_overlay", Gio.ApplicationFlags.FLAGS_NONE, diff --git a/discover_overlay/image_getter.py b/discover_overlay/image_getter.py index 6b0fbe4..c0efa97 100644 --- a/discover_overlay/image_getter.py +++ b/discover_overlay/image_getter.py @@ -112,8 +112,7 @@ def get_file(self): errors = [] icon = None content = None - log.info("Opening local file : %s", self.url) - if "/" in self.url or "." in self.url or "~" in self.url: # URL is a filename + 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 @@ -183,8 +182,10 @@ def get_file(self): log.error(error) -def get_surface(func, identifier, ava, display, recolor=[1.0, 1.0, 1.0, 1.0]): +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) 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 index 44a876f..b11521b 100644 --- a/discover_overlay/message.py +++ b/discover_overlay/message.py @@ -14,38 +14,13 @@ import logging import gi import time -from .image_getter import get_surface -from .overlay import HorzAlign gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, Gdk, GLib, Pango +from gi.repository import Gtk, GLib log = logging.getLogger(__name__) -class MessageBoxLayout(Gtk.LayoutManager): - - 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() - - 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 Message(Gtk.Box): """Overlay window for notifications""" @@ -58,11 +33,8 @@ def __init__(self, overlay, message): self.image = None self.label = Gtk.Label() self.label.add_css_class("message") - # self.label.set_use_markup(True) self.label.set_wrap(True) - self.label.set_markup( - "%s:%s" % (message["nick"], self.make_line(message["content"])) - ) + self.label.set_markup(f"{message["nick"]}:{self.make_line(message["content"])}") self.append(self.label) if overlay.popup_style: hide_time = message["time"] + overlay.text_time diff --git a/discover_overlay/notification.py b/discover_overlay/notification.py index f06dab0..a7e0a8b 100644 --- a/discover_overlay/notification.py +++ b/discover_overlay/notification.py @@ -15,100 +15,14 @@ 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, Gdk, GLib, Pango +from gi.repository import Gtk, GLib, Pango log = logging.getLogger(__name__) -class NotificationLayout(Gtk.LayoutManager): - - 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) - - 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 Notification(Gtk.Box): """Overlay window for notifications""" @@ -145,7 +59,7 @@ def __init__(self, overlay, image, title, message, timeout): self.message.show() if image: if not isinstance(image, str): - image = "%s%s" % (image[0], image[1]) + image = f"{image[0]}{image[1]}" log.info(image) get_surface(self.recv_avatar, image, "channel", self.get_display()) diff --git a/discover_overlay/notification_overlay.py b/discover_overlay/notification_overlay.py index a2d32e1..cfc4112 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -13,6 +13,7 @@ """Notification window for text""" import logging import json +import cairo import gi from .overlay import get_h_align, get_v_align, HorzAlign, VertAlign from .notification import Notification @@ -27,7 +28,7 @@ class NotificationOverlayWindow(Gtk.Box): """Overlay window for notifications""" - def __init__(self, discover, piggyback=None): + def __init__(self, discover): Gtk.Box.__init__(self) self.discover = discover @@ -93,10 +94,6 @@ def __init__(self, discover, piggyback=None): self.align_y = VertAlign.TOP self.show() - def set_blank(self): - """Set to no data and redraw""" - # TODO Consider losing this as everything should time out correctly - def add_notification_message(self, data): """Add new message to dataset""" if "data" in data: @@ -126,6 +123,7 @@ def set_font(self, font): 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() @@ -149,7 +147,6 @@ def set_icon_left(self, left): 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""" @@ -202,16 +199,19 @@ def show_testing(self): 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")) @@ -235,8 +235,24 @@ def set_config(self, config): if font: self.set_font(font) - def set_css(self, id, rule): - self.get_native().set_css(id, rule) + def set_css(self, css_id, rule): + """Add or replace custom css rules""" + self.get_native().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 3f4baac..d12b95b 100644 --- a/discover_overlay/overlay.py +++ b/discover_overlay/overlay.py @@ -17,7 +17,6 @@ from ctypes import CDLL CDLL("libgtk4-layer-shell.so") -from enum import Enum import logging import gi import cairo @@ -25,107 +24,17 @@ from Xlib import X, Xatom 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, Gdk, GLib, GdkX11, GdkWayland, Gtk4LayerShell +from gi.repository import Gtk, GLib, GdkX11, GdkWayland, Gtk4LayerShell log = logging.getLogger(__name__) -class Direction(Enum): - 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 - 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 - log.error("Unknown V Align : %s", in_str) - return None - - -class AmalgamationLayout(Gtk.LayoutManager): - - 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() - - 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 OverlayWindow(Gtk.Window): """ Overlay parent class. Helpful if we need more overlay @@ -164,34 +73,33 @@ def __init__(self, discover): self.context = None self.redraw_id = None - self.draw_blank = False self.timeout_mouse_over = 1 self.timer_after_draw = None self.get_display().connect("setting-changed", self.screen_changed) - # TODO Find compositor-change for GTK4. Currently only checks once at start - # self.connect("composited-changed", self.check_composite) self.get_display().get_monitors().connect("items-changed", self.screen_changed) - # TODO Find monitor resize hook for GTK4 - # self.get_display().connect("size-changed", self.screen_changed) - - # TODO GTK4 mouse events - # 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.motion_gesture = Gtk.EventControllerMotion() + self.motion_gesture.connect("enter", self.mouseover) + self.motion_gesture.connect("leave", self.mouseout) + self.add_controller(self.motion_gesture) - # TODO Find size-allocate for GTK4 - # self.connect("size-allocate", self.set_untouchable) 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("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() def remove_css(self, cssid): """Removes a CSS Rule by id""" @@ -219,15 +127,50 @@ def window_exited(self, _window=None): """Window closed. Exit app""" self.discover.exit() - def set_x11_window_location(self, x, y): + 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() ) - - topw.configure(x=x, y=y) + 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 + log.info(data) + 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 + + log.info( + "Window x11 ... x %s y %s w %s h %s", + int(x + align_x), + int(y + align_y), + int(window_w), + int(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") @@ -236,7 +179,7 @@ def set_x11_window_location(self, x, y): else: log.warning("Unable to set X11 location") - def set_gamescope_xatom(self, enabled): + def set_gamescope_state(self, enabled): """Set Gamescope XAtom to identify self as an overlay candidate""" if enabled == self.is_xatom_set: @@ -295,26 +238,24 @@ def set_font(self, font): """ self.set_css("font", "* { font: %s; }" % (font_string_to_css_font_string(font))) - def css_changed(self, change): - Gtk.Window.css_changed(self, change) - self.set_untouchable() - 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 """ 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: - # TODO Add bounding boxes of all labels and images in widget tree - # bb_region = bb_region.union() - pass + boxes = self.get_boxes() + bb_region = cairo.Region(boxes) surface.set_input_region(bb_region) if not display.is_composited(): @@ -322,14 +263,22 @@ def set_untouchable( log.error("Unable to set XShape - exiting") self.discover.exit() + 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: - pass - else: - self.set_untouchable() + self.set_untouchable() def set_mouseover_timer(self, time): """Set the time until the overlay reappears after mouse over""" @@ -341,15 +290,16 @@ def force_location(self): On Wayland just store for later On Gamescope enforce size of display but only if it's the primary overlay """ - self.show() (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords() - self.set_decorated(True) + 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 isinstance(surface, GdkWayland.WaylandSurface): + if not surface: + return + elif isinstance(surface, GdkWayland.WaylandSurface): self.set_wayland_state() elif isinstance(surface, GdkX11.X11Surface): if not self.get_display().is_composited(): @@ -357,9 +307,9 @@ def force_location(self): self.discover.exit() surface.set_skip_pager_hint(True) surface.set_skip_taskbar_hint(True) - self.set_decorated(False) - self.set_size_request(screen_width, screen_height) - self.set_x11_window_location(screen_x, screen_y) + 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() @@ -443,28 +393,27 @@ def set_enabled(self, enabled): """ self.enabled = enabled if self.discover.steamos: - self.set_gamescope_xatom(1 if enabled else 0) + self.set_gamescope_state(1 if enabled else 0) if enabled and not self.hidden: self.present() self.show() else: self.hide() - def screen_changed(self, _screen=None, a_=None, _b=None, _c=None): + def screen_changed(self, _screen=None, _a=None, _b=None, _c=None): """Callback to set monitor to display on""" if not self.get_display().is_composited(): log.error("Unable to function without compositor") self.discover.exit() 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.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 @@ -499,16 +448,22 @@ def set_config(self, config): 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=1)) + 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: @@ -521,8 +476,12 @@ def merged_overlay(self, widget_list): 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() diff --git a/discover_overlay/settings_window.py b/discover_overlay/settings_window.py index 0041157..18dc5be 100644 --- a/discover_overlay/settings_window.py +++ b/discover_overlay/settings_window.py @@ -10,6 +10,7 @@ # # 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""" import gettext @@ -79,6 +80,7 @@ def __init__(self, a, b, config_file, rpc_file, channel_file, args): self.monitor_channel = None def start(self, _x): + """Start the Settings Window""" builder = Gtk.Builder(self) with importlib_resources.as_file( @@ -551,7 +553,7 @@ def read_config(self): ) self.widget["voice_show_disconnected"].set_active( - config.getboolean("main", "show_disconnected", fallback=False) + config.getboolean("main", "show_disconnected", fallback=True) ) self.widget["voice_dummy_count"].set_value( @@ -782,11 +784,6 @@ def update_toggle_overlay(self, _a=None, _b=None): self.widget["core_hide_overlay"].set_active(self.hidden_overlay) self.widget["core_hide_overlay"].handler_unblock(self.hidden_overlay_handler) - ## TODO Fix menu - # if self.hidden_overlay: - # self.toggle_opt.set_label(_("Show Overlay")) - # else: - # self.toggle_opt.set_label(_("Hide Overlay")) def close_overlay(self, _a=None, _b=None): """Send RPC to tell the overlay to close""" @@ -829,6 +826,7 @@ def config_remove_section(self, context): config.write(file) def voice_monitor_changed(self, button): + idx = button.get_active() plug = "Any" if idx > 0: diff --git a/discover_overlay/text_overlay.py b/discover_overlay/text_overlay.py index d6330a9..92e2f4f 100644 --- a/discover_overlay/text_overlay.py +++ b/discover_overlay/text_overlay.py @@ -12,16 +12,16 @@ # along with this program. If not, see . """Overlay window for text""" import logging -import re -import json import gi +import cairo from .css_helper import col_to_css, font_string_to_css_font_string -from .message import Message, MessageBoxLayout +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 Pango, Gtk +from gi.repository import Gtk log = logging.getLogger(__name__) @@ -103,7 +103,7 @@ def update_all(self): 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) @@ -133,8 +133,19 @@ def set_config(self, config): if font: self.set_font(font) - def set_css(self, id, rule): - self.get_native().set_css(id, rule) + def set_css(self, css_id, rule): + """Set a CSS Rule on window""" + self.get_native().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 index ed0d005..1fe88e9 100644 --- a/discover_overlay/userbox.py +++ b/discover_overlay/userbox.py @@ -11,90 +11,34 @@ # 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 .overlay import Direction +from .layout import UserBoxLayout +from .connection_state import ConnectionState gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, GLib, Gdk +from gi.repository import Gtk, GLib log = logging.getLogger(__name__) - - -class UserBoxLayout(Gtk.LayoutManager): - - 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) - - 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) +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 @@ -102,6 +46,9 @@ def __init__(self, overlay, 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() @@ -131,26 +78,49 @@ def __init__(self, overlay, userid): 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, user): + 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(user["friendlyname"]) < self.overlay.nick_length: - self.label.set_text(user["friendlyname"]) + if len(self.name) < self.overlay.nick_length: + self.label.set_text(self.name) else: - self.label.set_text( - user["friendlyname"][: (self.overlay.nick_length - 1)] + "\u2026" - ) + self.label.set_text(self.name[: (self.overlay.nick_length - 1)] + "\u2026") - def update_image(self, user): + 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: @@ -160,18 +130,6 @@ def update_image(self, user): self.image.hide() return self.image.show() - # Ensure pixbuf for avatar - if ( - self.pixbuf is None - and not self.pixbuf_requested - and self.overlay.avatar_size > 0 - and user["avatar"] - ): - url = ( - f"https://cdn.discordapp.com/avatars/{user['id']}/{user['avatar']}.png" - ) - get_surface(self.recv_avatar, url, user["id"], self.get_display()) - self.pixbuf_requested = True if self.pixbuf: self.image.set_from_pixbuf(self.pixbuf) @@ -204,10 +162,11 @@ def set_deaf(self, deaf): 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.show() self.add_css_class("talking") else: self.remove_css_class("talking") @@ -215,123 +174,166 @@ def set_talking(self, talking): grace = self.overlay.only_speaking_grace_period if grace > 0: self.grace_timeout = GLib.timeout_add_seconds(grace, self.grace_cb) - else: - self.hide() + self.set_shown() - def grace_cb(self): + 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): - super().__init__(overlay, None) self.show_always = False self.show_disconnected = True - self.last = "None" - self.pix_none = "network-cellular-signal-none" - self.pix_ok = "network-cellular-signal-ok" - self.pix_good = "network-cellular-signal-good" - self.pix_excellent = "network-cellular-signal-excellent" + 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 == "DISCONNECTED" - or level == "NO_ROUTE" - or level == "VOICE_DISCONNECTED" - ): - return self.pix_none - elif ( - level == "ICE_CHECKING" - or level == "AWAITING_ENDPOINT" - or level == "AUTHENTICATING" - ): - return self.pix_ok - elif level == "CONNECTING" or level == "VOICE_CONNECTING": - return self.pix_good - elif level == "CONNECTED" or level == "VOICE_CONNECTED": - return self.pix_excellent + 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 set_connection(self, level): - if not level: - self.hide() - return - if level == self.last: - 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 - if self.should_show(): - self.show() - else: - self.hide() self.image.set_from_icon_name(self.get_image_name()) - self.update_label(None) + self.update_label() + self.update_image() + self.set_shown() + self.get_native().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 != "CONNECTED" and self.last != "VOICE_CONNECTED" + self.last == ConnectionState.NO_DISCORD + or self.last == ConnectionState.VOICE_CHAT_NOT_CONNECTED ): return True return False - def update_image(self, user): + 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, user): + def update_label(self): """Updates the label, assuming there is changed config or info""" - if self.should_show(): - self.show() - else: - self.hide() + self.set_shown() if self.overlay.icon_only: self.label.hide() return self.label.show() - if len(self.last) < self.overlay.nick_length: - self.label.set_text(self.last) + 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(self.last[: (self.overlay.nick_length - 1)] + "\u2026") + self.label.set_text(label_text[: (self.overlay.nick_length - 1)] + "\u2026") - def blank(self): - self.pixbuf = None - self.hide() + 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 = False + 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 not label: - self.hide() - return - if self.show_title: - self.show() if self.overlay.icon_only: self.label.hide() else: self.label.show() - self.label.set_text(label) + 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: @@ -340,31 +342,24 @@ def set_image(self, image): self.image.set_from_pixbuf(self.pixbuf) def blank(self): + """Blanks image and hides self""" self.pixbuf = None - self.hide() - - def set_show(self, show): - self.show_title = show - if show: - self.show() - else: - self.hide() + 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, user): + 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, user): - if self.should_show(): - self.show() - else: - self.hide() + def update_label(self): + self.set_shown() if self.overlay.icon_only: self.label.hide() return @@ -376,3 +371,6 @@ def update_label(self, user): 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 80a7b22..36c758d 100644 --- a/discover_overlay/voice_overlay.py +++ b/discover_overlay/voice_overlay.py @@ -15,10 +15,11 @@ import gettext import logging import locale +import cairo import json import importlib_resources from time import perf_counter -from .overlay import HorzAlign, VertAlign +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 @@ -45,8 +46,41 @@ class VoiceOverlayWindow(Gtk.Box): def __init__(self, discover): Gtk.Box.__init__(self) - self.connection = UserBoxConnection(self) self.discover = discover + + # 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.only_speaking = None + self.highlight_self = None + self.order = None + self.overflow = None + self.use_dummy = False + self.dummy_count = 10 + self.border_width = 2 + 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_start = 0 + 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.mute_col = None + self.mute_bg_col = [0.0, 0.0, 0.0, 0.5] + self.border_col = [0.0, 0.0, 0.0, 0.0] + self.avatar_bg_col = [0.0, 0.0, 1.0, 1.0] + + # Create necessary widgets + self.connection = UserBoxConnection(self) self.title = UserBoxTitle(self) self.append(self.title) self.append(self.connection) @@ -75,44 +109,16 @@ def __init__(self, discover): "friendlyname": name, } ) - self.text_x_align = "middle" - self.text_y_align = "middle" - self.show_avatar = True - self.avatar_size = 48 - self.nick_length = 32 - self.only_speaking = None - self.highlight_self = None - self.order = None + # Stored pixbufs for userdata self.def_avatar = None self.mutepix = None self.deafpix = None - self.overflow = None - self.use_dummy = False - self.dummy_count = 10 - self.border_width = 2 - 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_start = 0 + # Glib timeout references self.inactive_timeout = None self.fadeout_timeout = None - 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.mute_col = [0.7, 0.0, 0.0, 1.0] - self.mute_bg_col = [0.0, 0.0, 0.0, 0.5] - 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 = [] + # Start thread to grab default avatar pixbuf with importlib_resources.as_file( importlib_resources.files("discover_overlay") / "img/discover-overlay-default.png" @@ -123,14 +129,15 @@ def __init__(self, discover): "def", self.get_display(), ) + + # Set widget initial states + self.set_overflow(Gtk.Overflow.HIDDEN) self.title.set_label(None) - self.connection.set_connection(None) - self.title.update_label(None) - self.connection.update_image(None) - self.populate() + 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" @@ -154,20 +161,36 @@ def recolour_icons(self): self.mute_col, ) - def all_users(self, func): + def update_all(self): + """Update each child widget, assuming changed config""" child = self.get_first_child() while child: - user = self.get_user(child.userid) - func(user, child) + child.update_label() + child.update_image() child = child.get_next_sibling() - def get_user(self, userid): - for user in self.dummy_data if self.use_dummy else self.userlist: - if user["id"] == userid: - return user - return None + 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: @@ -176,6 +199,7 @@ def get_user_widget(self, userid): 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) @@ -185,6 +209,7 @@ def set_align_x(self, align): 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) @@ -194,113 +219,48 @@ def set_align_y(self, align): self.set_valign(Gtk.Align.END) def get_align(self): + """Get alignment requested""" return (self.align_x, self.align_y) - def populate(self): - child = self.get_last_child() - self.queue_resize() - self.queue_resize() - while child: - child.queue_resize() - n_child = child.get_prev_sibling() - if isinstance(child, UserBoxTitle) or isinstance(child, UserBoxConnection): - child = n_child - continue - if child.userid not in self.userlist: - self.remove(child) - child.hide() - child = n_child - connection = self.discover.connection - self_user_id = None - if connection and connection.user and "id" in connection.user: - self_user_id = connection.user["id"] - - # Gather which users to draw - if self.use_dummy: - users_to_draw = self.sort_list(self.dummy_data.copy()[0 : self.dummy_count]) - userlist = self.dummy_data.copy() - else: - users_to_draw = self.userlist.copy() - userlist = self.userlist.copy() - - now = perf_counter() - for user in userlist: - # 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_id == user["id"]: - 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) - - self.title.update_image(None) - self.title.update_label(None) - self.connection.update_image(None) - self.connection.update_label(None) - for user in users_to_draw: - userbox = self.get_user_widget(user["id"]) - if userbox: - userbox.show() - continue - - userbox = UserBox(self, user["id"]) - userbox.update_image(user) - userbox.update_label(user) - - self.append(userbox) - userbox.show() - self.show() + 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_native().set_visibility() + + def del_user(self, user): + """Hide user""" + widget = self.get_user_widget(user["id"]) + if widget: + widget.user_left() + self.get_native().set_visibility() def set_talking(self, userid, talking): - log.info("Talking %s %s", userid, talking) + """Set the user as talking or not""" widget = self.get_user_widget(userid) - user = self.get_user(userid) - if user: - user["talking"] = talking if widget: widget.set_talking(talking) else: - log.warning("Set talking on missing user") + 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) - user = self.get_user(userid) - if user: - user["mute"] = muted if widget: widget.set_mute(muted) - log.info("Mute %s %s", userid, 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) - user = self.get_user(userid) - if user: - user["deaf"] = deafened if widget: widget.set_deaf(deafened) - log.info("Deaf %s %s", userid, deafened) + else: + log.warning("Set deaf on missing user: %s", userid) def reset_action_timer(self): """Reset time since last voice activity""" @@ -332,7 +292,6 @@ def overlay_inactive(self): def overlay_fadeout(self): """Repeated callback after inactivity started""" - self.populate() # 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 @@ -353,12 +312,13 @@ def overlay_fadeout(self): def set_blank(self): """Set data to blank and redraw""" - self.userlist = [] + child = self.get_first_child() + while child: + child.user_left() + child = child.get_next_sibling() self.title.set_label(None) - self.connection.set_connection(None) - self.title.update_label(None) - self.connection.update_image(None) - self.populate() + self.title.update_label() + self.get_native().set_visibility() def set_fade_out_inactive(self, enabled, fade_time, fade_duration, fade_to): """Config option: fade out options""" @@ -392,9 +352,9 @@ def set_overflow_style(self, overflow): """Config option: Change handling of too many users to render""" if self.overflow != overflow: self.overflow = overflow - self.populate() 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) @@ -411,7 +371,7 @@ def set_borders(self): "talking-border", f""" .talking.user - {{ + {{ filter: {drop_shadow_talking}; }} .user @@ -437,22 +397,9 @@ def set_channel_icon(self, url): else: get_surface(self.recv_avatar, url, "channel", self.get_display()) - 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.populate() - def set_connection_status(self, connection): """Set if discord has a clean connection to server""" - self.connection.set_connection(connection["state"]) + self.connection.set_connection(connection) def sort_list(self, in_list): """Take a userlist and sort it according to config option""" @@ -467,44 +414,40 @@ def sort_list(self, in_list): def should_show(self): """Returns true if overlay has meaningful content to render""" + if self.connection.show_always: + return True + if self.connection.should_show(): + return True if self.use_dummy: return True - return len(self.userlist) > 0 + child = self.get_first_child() + while child: + if child.is_user_visible(): + return True + child = child.get_next_sibling() + return False def recv_avatar(self, identifier, pix): """Called when image_getter has downloaded an image""" if identifier == "def": self.def_avatar = pix - self.all_users(lambda user, widget: widget.update_image(user)) + self.update_all_images() elif identifier == "mute": self.mutepix = pix - self.all_users(lambda user, widget: widget.update_image(user)) + self.update_all_images() elif identifier == "deaf": self.deafpix = pix - self.all_users(lambda user, widget: widget.update_image(user)) + self.update_all_images() elif identifier == "channel": self.title.set_image(pix) - 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 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 @@ -536,7 +479,14 @@ def set_config(self, config): 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});}}", + 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", @@ -607,7 +557,7 @@ def set_config(self, config): self.text_side = config.getint("text_side", fallback=3) self.connection.set_show_only_disconnected( - config.getboolean("show_disconnected", fallback=False) + config.getboolean("show_disconnected", fallback=True) ) self.border_width = config.getint("border_width", fallback=2) @@ -642,7 +592,22 @@ def set_config(self, config): config.getfloat("fade_out_limit", fallback=0.3), ) - self.populate() + self.update_all() + + def set_css(self, css_id, rule): + """Add or replace CSS Rule""" + self.get_native().set_css(css_id, rule) - def set_css(self, id, rule): - self.get_native().set_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 + ) + boxes.append(region) + child = child.get_next_sibling() + return boxes From 0156d3f96132d1e7352a8e7e3fe4e8986ccf6f2f Mon Sep 17 00:00:00 2001 From: trigg Date: Tue, 9 Sep 2025 00:49:12 +0100 Subject: [PATCH 10/10] - Fixed X11 without compositing. Somewhat - Fixed edgecase crashes in notifications - Set notification and text to hide at start, before content --- discover_overlay/message.py | 2 +- discover_overlay/notification.py | 7 +++++-- discover_overlay/notification_overlay.py | 15 ++------------- discover_overlay/overlay.py | 20 ++------------------ discover_overlay/text_overlay.py | 8 ++++---- discover_overlay/userbox.py | 2 +- discover_overlay/voice_overlay.py | 8 ++++---- 7 files changed, 19 insertions(+), 43 deletions(-) diff --git a/discover_overlay/message.py b/discover_overlay/message.py index b11521b..f9268fa 100644 --- a/discover_overlay/message.py +++ b/discover_overlay/message.py @@ -34,7 +34,7 @@ def __init__(self, overlay, message): self.label = Gtk.Label() self.label.add_css_class("message") self.label.set_wrap(True) - self.label.set_markup(f"{message["nick"]}:{self.make_line(message["content"])}") + 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 diff --git a/discover_overlay/notification.py b/discover_overlay/notification.py index a7e0a8b..3908ccf 100644 --- a/discover_overlay/notification.py +++ b/discover_overlay/notification.py @@ -38,8 +38,8 @@ def __init__(self, overlay, image, title, message, timeout): self.append(self.title) self.append(self.message) - self.title.set_text(title) - self.message.set_text(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) @@ -65,6 +65,8 @@ def __init__(self, overlay, image, title, message, timeout): 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""" @@ -85,6 +87,7 @@ def recv_avatar(self, _ident, pix): 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""" diff --git a/discover_overlay/notification_overlay.py b/discover_overlay/notification_overlay.py index cfc4112..c92ae76 100644 --- a/discover_overlay/notification_overlay.py +++ b/discover_overlay/notification_overlay.py @@ -106,8 +106,6 @@ def add_notification_message(self, data): data["body"] if "body" in data else "", self.text_time, ) - self.append(n_not) - n_not.show() # n_not.set_reveal_child(True) else: log.error("Malformed message %s", data) @@ -152,7 +150,7 @@ def set_limit_width(self, limit): """Config option: Word wrap limit, in window-space pixels""" child = self.get_first_child() while child: - child.set_size_request(limit, -1) + child.set_size_request(1, 1) child = child.get_next_sibling() def set_fg(self, fg_col): @@ -184,15 +182,6 @@ def should_show(self): return True return False - 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 show_testing(self): """Pop up test notifications""" for test in self.test_content: @@ -237,7 +226,7 @@ def set_config(self, config): def set_css(self, css_id, rule): """Add or replace custom css rules""" - self.get_native().set_css(css_id, rule) + self.get_root().set_css(css_id, rule) def get_align(self): """Get the alignment of this overlay. Used by amalgamation mode""" diff --git a/discover_overlay/overlay.py b/discover_overlay/overlay.py index d12b95b..35b5576 100644 --- a/discover_overlay/overlay.py +++ b/discover_overlay/overlay.py @@ -100,6 +100,8 @@ def mapped(self, _a=None): # 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""" @@ -149,7 +151,6 @@ def set_x11_window_location(self, x, y, w, h): elif self.widget: (window_x, window_y) = self.widget.get_align() data = topw.get_geometry() # Use X11 sizes to account for render scale - log.info(data) window_w = data.width window_h = data.height if window_x == HorzAlign.MIDDLE: @@ -161,13 +162,6 @@ def set_x11_window_location(self, x, y, w, h): elif window_y == VertAlign.BOTTOM: align_y = h - window_h - log.info( - "Window x11 ... x %s y %s w %s h %s", - int(x + align_x), - int(y + align_y), - int(window_w), - int(window_h), - ) topw.configure( x=int(x + align_x), y=int(y + align_y), w=int(window_w), h=int(window_h) ) @@ -258,10 +252,6 @@ def set_untouchable( bb_region = cairo.Region(boxes) surface.set_input_region(bb_region) - if not display.is_composited(): - # TODO Maybe XLib + XShape - log.error("Unable to set XShape - exiting") - self.discover.exit() def get_boxes(self): """Get a collection of bounding boxes from widget(s)""" @@ -302,9 +292,6 @@ def force_location(self): elif isinstance(surface, GdkWayland.WaylandSurface): self.set_wayland_state() elif isinstance(surface, GdkX11.X11Surface): - if not self.get_display().is_composited(): - log.error("Unable to function without compositor") - self.discover.exit() surface.set_skip_pager_hint(True) surface.set_skip_taskbar_hint(True) self.set_x11_window_location( @@ -402,9 +389,6 @@ def set_enabled(self, enabled): def screen_changed(self, _screen=None, _a=None, _b=None, _c=None): """Callback to set monitor to display on""" - if not self.get_display().is_composited(): - log.error("Unable to function without compositor") - self.discover.exit() self.set_monitor(self.monitor) def mouseover(self, _a=None, _b=None, _c=None): diff --git a/discover_overlay/text_overlay.py b/discover_overlay/text_overlay.py index 92e2f4f..b874a98 100644 --- a/discover_overlay/text_overlay.py +++ b/discover_overlay/text_overlay.py @@ -54,14 +54,14 @@ def set_blank(self): n_child = child.get_next_sibling() self.remove(child) child = n_child - self.get_native().set_visibility() + 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_native().set_visibility() + self.get_root().set_visibility() def set_text_time(self, timer): """Config option: Time before messages disappear from overlay""" @@ -93,7 +93,7 @@ def should_show(self): def update(self): """Call when removing a message automatically, allows hiding of overlay when empty""" - self.get_native().set_visibility() + self.get_root().set_visibility() def update_all(self): """Tell all messages we've had something changed""" @@ -135,7 +135,7 @@ def set_config(self, config): def set_css(self, css_id, rule): """Set a CSS Rule on window""" - self.get_native().set_css(css_id, rule) + self.get_root().set_css(css_id, rule) def get_align(self): """Get alignment requested""" diff --git a/discover_overlay/userbox.py b/discover_overlay/userbox.py index 1fe88e9..9a6d211 100644 --- a/discover_overlay/userbox.py +++ b/discover_overlay/userbox.py @@ -270,7 +270,7 @@ def set_connection(self, level): self.update_label() self.update_image() self.set_shown() - self.get_native().set_visibility() + self.get_root().set_visibility() def should_show(self): """Returns True if this should show in overlay, False otherwise""" diff --git a/discover_overlay/voice_overlay.py b/discover_overlay/voice_overlay.py index 36c758d..25063de 100644 --- a/discover_overlay/voice_overlay.py +++ b/discover_overlay/voice_overlay.py @@ -229,14 +229,14 @@ def update_user(self, user): widget = self.create_user_widget(user) widget.update_user_data(user) widget.user_join() - self.get_native().set_visibility() + 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_native().set_visibility() + self.get_root().set_visibility() def set_talking(self, userid, talking): """Set the user as talking or not""" @@ -318,7 +318,7 @@ def set_blank(self): child = child.get_next_sibling() self.title.set_label(None) self.title.update_label() - self.get_native().set_visibility() + self.get_root().set_visibility() def set_fade_out_inactive(self, enabled, fade_time, fade_duration, fade_to): """Config option: fade out options""" @@ -596,7 +596,7 @@ def set_config(self, config): def set_css(self, css_id, rule): """Add or replace CSS Rule""" - self.get_native().set_css(css_id, 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"""