diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index ff526d0..0812c9d 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -40,9 +40,9 @@ jobs: run: | sudo apt install qt6-base-dev libsystemd-dev gcc - name: Install Poetry dependencies - if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + # if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: | - poetry install --no-interaction + poetry install --sync --no-interaction # Compile and build Yin-Yang - name: Compile ui, translations and resources run: poetry run ./scripts/build_ui.sh diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index aed09b2..0a3c15a 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -17,17 +17,20 @@ jobs: os: [ubuntu-22.04] runs-on: ${{matrix.os}} steps: + # Checkout repo and set up python - uses: actions/checkout@v4 - name: Install Python uses: actions/setup-python@v5 with: python-version: ${{matrix.python-version}} + # Install and configure poetry - name: Install Poetry uses: abatilo/actions-poetry@v2 - name: Set up local virtual environment run: | poetry config virtualenvs.create true --local poetry config virtualenvs.in-project true --local + # Load cached venv if it exists - name: Cache packages id: cached-poetry-dependencies uses: actions/cache@v4 @@ -35,14 +38,14 @@ jobs: # This path is specific to ubuntu path: ./.venv key: venv-${{ hashFiles('poetry.lock') }} - # Install dependencies + # Install dependencies of cache does not exist - name: Install system dependencies run: | sudo apt install qt6-base-dev libsystemd-dev gcc - name: Install Poetry dependencies - if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + # if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: | - poetry install --no-interaction + poetry install --sync --no-interaction # Build and test Yin-Yang - name: Compile ui, translations and resources run: poetry run ./scripts/build_ui.sh diff --git a/scripts/install.sh b/scripts/install.sh index ee62edf..c07d8ab 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -17,8 +17,8 @@ echo "Installing dependencies …" # Tell Poetry not to use a keyring export PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring # create virtual environment and install packages -poetry install --sync poetry env use python +poetry install --sync poetry build pip install ./dist/yin_yang-*-py3-none-any.whl diff --git a/yin_yang/NotificationHandler.py b/yin_yang/NotificationHandler.py index 0f184f9..4ce908e 100644 --- a/yin_yang/NotificationHandler.py +++ b/yin_yang/NotificationHandler.py @@ -1,14 +1,32 @@ -import logging -import subprocess from logging import Handler -logger = logging.getLogger() +from PySide6.QtDBus import QDBusConnection, QDBusMessage + + +def create_dbus_message(title: str, body: str): + message = QDBusMessage.createMethodCall( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Notification", + "AddNotification", + ) + + notification = { + "title": title, + "body": body, + "icon": "yin_yang", + "priority": "low", + } + + message.setArguments(["YingYang.ThemeChanged", notification]) + + return message + class NotificationHandler(Handler): """Shows logs as notifications""" + def emit(self, record): - try: - subprocess.call(['notify-send', record.levelname, str(record.msg), - '-a', 'Yin & Yang', '-u', 'low', '--icon', 'yin_yang']) - except FileNotFoundError: - logger.warn('notify-send not found. Notifications will not work!') + connection = QDBusConnection.sessionBus() + message = create_dbus_message(record.levelname, str(record.msg)) + connection.call(message) diff --git a/yin_yang/plugins/_plugin.py b/yin_yang/plugins/_plugin.py index fc77f86..384156a 100644 --- a/yin_yang/plugins/_plugin.py +++ b/yin_yang/plugins/_plugin.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from configparser import ConfigParser from pathlib import Path -from typing import Optional +from typing import Optional, List from PySide6.QtDBus import QDBusConnection, QDBusMessage from PySide6.QtGui import QColor, QRgba64 @@ -366,3 +366,20 @@ def flatpak_user(app_id: str) -> Path: def snap_path(app: str) -> Path: return Path(f'/var/lib/snapd/snap/{app}/current') + +def themes_from_theme_directories(type: str) -> List[Path]: + theme_directories = [ + Path('/usr/share/themes'), + Path('/usr/local/share/themes'), + Path.home() / '.themes', + Path.home() / '.local/share/themes', + ] + + themes = [] + for directory in theme_directories: + if not directory.is_dir(): + continue + + themes.extend(d.name for d in directory.iterdir() if d.is_dir() and (d / type).is_dir()) + + return themes diff --git a/yin_yang/plugins/gtk.py b/yin_yang/plugins/gtk.py index 7fe13db..b30b84d 100755 --- a/yin_yang/plugins/gtk.py +++ b/yin_yang/plugins/gtk.py @@ -1,23 +1,20 @@ import logging -from os import scandir, path +from os import path, scandir from pathlib import Path from PySide6.QtDBus import QDBusMessage from yin_yang import helpers -from ._plugin import PluginDesktopDependent, PluginCommandline, DBusPlugin -from .system import test_gnome_availability from ..meta import Desktop +from ._plugin import DBusPlugin, PluginCommandline, PluginDesktopDependent +from .system import test_gnome_availability logger = logging.getLogger(__name__) -theme_directories = [helpers.get_usr() + 'share/themes', f'{Path.home()}/.themes'] - - class Gtk(PluginDesktopDependent): - name = 'GTK' + name = "GTK" def __init__(self, desktop: Desktop): match desktop: @@ -26,8 +23,10 @@ def __init__(self, desktop: Desktop): case Desktop.GNOME: super().__init__(_Gnome()) if not self.strategy.available: - print('You need to install an extension for gnome to use it. \n' - 'You can get it from here: https://extensions.gnome.org/extension/19/user-themes/') + print( + "You need to install an extension for gnome to use it. \n" + "You can get it from here: https://extensions.gnome.org/extension/19/user-themes/" + ) case Desktop.MATE: super().__init__(_Mate()) case Desktop.XFCE: @@ -41,38 +40,34 @@ def __init__(self, desktop: Desktop): @property def available_themes(self) -> dict: - themes = [] - - for directory in theme_directories: - if not path.isdir(directory): - continue - - with scandir(directory) as entries: - themes.extend(d.name for d in entries if d.is_dir() and path.isdir(d.path + '/gtk-3.0')) - + themes = themes_from_theme_directories("gtk-3.0") return {t: t for t in themes} class _Gnome(PluginCommandline): - name = 'GTK' + name = "GTK" def __init__(self): - super().__init__(['gsettings', 'set', 'org.gnome.desktop.interface', 'gtk-theme', '{theme}']) - self.theme_light = 'Default' - self.theme_dark = 'Default' + super().__init__( + ["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "{theme}"] + ) + self.theme_light = "Default" + self.theme_dark = "Default" @property def available(self) -> bool: return test_gnome_availability(self.command) - - + + class _Budgie(PluginCommandline): - name = 'GTK' + name = "GTK" def __init__(self): - super().__init__(['gsettings', 'set', 'org.gnome.desktop.interface', 'gtk-theme', '{theme}']) - self.theme_light = 'Default' - self.theme_dark = 'Default' + super().__init__( + ["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "{theme}"] + ) + self.theme_light = "Default" + self.theme_dark = "Default" @property def available(self) -> bool: @@ -80,19 +75,16 @@ def available(self) -> bool: class _Kde(DBusPlugin): - name = 'GTK' + name = "GTK" def __init__(self): super().__init__() - self.theme_light = 'Breeze' - self.theme_dark = 'Breeze' + self.theme_light = "Breeze" + self.theme_dark = "Breeze" def create_message(self, theme: str) -> QDBusMessage: message = QDBusMessage.createMethodCall( - 'org.kde.GtkConfig', - '/GtkConfig', - 'org.kde.GtkConfig', - 'setGtkTheme' + "org.kde.GtkConfig", "/GtkConfig", "org.kde.GtkConfig", "setGtkTheme" ) message.setArguments([theme]) return message @@ -103,49 +95,61 @@ def set_theme(self, theme: str): if response.type() != QDBusMessage.MessageType.ErrorMessage: return - logger.warning('kde-gtk-config not available, trying xsettingsd') - xsettingsd_conf_path = Path.home() / '.config/xsettingsd/xsettingsd.conf' + logger.warning("kde-gtk-config not available, trying xsettingsd") + xsettingsd_conf_path = Path.home() / ".config/xsettingsd/xsettingsd.conf" if not xsettingsd_conf_path.exists(): - logger.warning('xsettingsd not available') + logger.warning("xsettingsd not available") return - with open(xsettingsd_conf_path, 'r') as f: + with open(xsettingsd_conf_path, "r") as f: lines = f.readlines() for i, line in enumerate(lines): - if line.startswith('Net/ThemeName'): + if line.startswith("Net/ThemeName"): lines[i] = f'Net/ThemeName "{theme}"\n' break - with open(xsettingsd_conf_path, 'w') as f: + with open(xsettingsd_conf_path, "w") as f: f.writelines(lines) # send signal to read new config - helpers.run(['killall', '-HUP', 'xsettingsd']) + helpers.run(["killall", "-HUP", "xsettingsd"]) class _Xfce(PluginCommandline): def __init__(self): - super(_Xfce, self).__init__(['xfconf-query', '-c', 'xsettings', '-p', '/Net/ThemeName', '-s', '{theme}']) - self.theme_light = 'Adwaita' - self.theme_dark = 'Adwaita-dark' + super(_Xfce, self).__init__( + ["xfconf-query", "-c", "xsettings", "-p", "/Net/ThemeName", "-s", "{theme}"] + ) + self.theme_light = "Adwaita" + self.theme_dark = "Adwaita-dark" class _Mate(PluginCommandline): def __init__(self): - super().__init__(['dconf', 'write', '/org/mate/desktop/interface/gtk-theme', '\'{theme}\'']) - self.theme_light = 'Yaru' - self.theme_dark = 'Yaru-dark' + super().__init__( + ["dconf", "write", "/org/mate/desktop/interface/gtk-theme", "'{theme}'"] + ) + self.theme_light = "Yaru" + self.theme_dark = "Yaru-dark" @property def available(self) -> bool: - return self.check_command(['dconf', 'help']) + return self.check_command(["dconf", "help"]) class _Cinnamon(PluginCommandline): def __init__(self): - super().__init__(['gsettings', 'set', 'org.cinnamon.desktop.interface', 'gtk-theme', '\"{theme}\"']) - self.theme_light = 'Adwaita' - self.theme_dark = 'Adwaita-dark' + super().__init__( + [ + "gsettings", + "set", + "org.cinnamon.desktop.interface", + "gtk-theme", + '"{theme}"', + ] + ) + self.theme_light = "Adwaita" + self.theme_dark = "Adwaita-dark" @property def available(self) -> bool: diff --git a/yin_yang/plugins/notify.py b/yin_yang/plugins/notify.py index 12707bb..6184eeb 100644 --- a/yin_yang/plugins/notify.py +++ b/yin_yang/plugins/notify.py @@ -1,9 +1,14 @@ -from ._plugin import PluginCommandline +from PySide6.QtDBus import QDBusMessage +from ..NotificationHandler import create_dbus_message +from ._plugin import DBusPlugin -class Notification(PluginCommandline): + +class Notification(DBusPlugin): def __init__(self): - super().__init__(['notify-send', 'Theme changed', 'Set the theme to {theme}', - '-a', 'Yin & Yang', '-u', 'low', '--icon', 'yin_yang']) + super().__init__() self.theme_light = 'Day' self.theme_dark = 'Night' + + def create_message(self, theme: str) -> QDBusMessage: + return create_dbus_message('Theme changed', f'Set the theme to {theme}') diff --git a/yin_yang/plugins/system.py b/yin_yang/plugins/system.py index 85f2f68..8e05ec2 100644 --- a/yin_yang/plugins/system.py +++ b/yin_yang/plugins/system.py @@ -1,23 +1,28 @@ import json import logging -import pwd import os +import pwd from configparser import ConfigParser from pathlib import Path from PySide6.QtCore import QLocale +from PySide6.QtDBus import QDBusMessage, QDBusVariant + from yin_yang import helpers from ..meta import Desktop -from ._plugin import PluginDesktopDependent, PluginCommandline +from ._plugin import ( + DBusPlugin, + PluginCommandline, + PluginDesktopDependent, + themes_from_theme_directories, +) logger = logging.getLogger(__name__) def test_gnome_availability(command) -> bool: - return PluginCommandline.check_command( - [command[0], 'get', command[2], command[3]] - ) + return PluginCommandline.check_command([command[0], "get", command[2], command[3]]) class System(PluginDesktopDependent): @@ -33,17 +38,27 @@ def __init__(self, desktop: Desktop): super().__init__(_Cinnamon()) case Desktop.BUDGIE: super().__init__(_Budgie()) + case Desktop.XFCE: + super().__init__(_Xfce()) case _: super().__init__(None) class _Gnome(PluginCommandline): - name = 'System' + name = "System" # TODO allow using the default themes, not only user themes def __init__(self): - super().__init__(['gsettings', 'set', 'org.gnome.shell.extensions.user-theme', 'name', '{theme}']) + super().__init__( + [ + "gsettings", + "set", + "org.gnome.shell.extensions.user-theme", + "name", + "{theme}", + ] + ) @property def available(self) -> bool: @@ -51,12 +66,20 @@ def available(self) -> bool: class _Budgie(PluginCommandline): - name = 'System' + name = "System" def __init__(self): - super().__init__(['gsettings', 'set', 'com.solus-project.budgie-panel', 'dark-theme', '{theme}']) - self.theme_light = 'light' - self.theme_dark = 'dark' + super().__init__( + [ + "gsettings", + "set", + "com.solus-project.budgie-panel", + "dark-theme", + "{theme}", + ] + ) + self.theme_light = "light" + self.theme_dark = "dark" @property def available(self) -> bool: @@ -66,10 +89,10 @@ def available(self) -> bool: def insert_theme(self, theme: str) -> list: command = self.command.copy() match theme.lower(): - case 'dark': - theme_bool = 'true' - case 'light': - theme_bool = 'false' + case "dark": + theme_bool = "true" + case "light": + theme_bool = "false" case _: raise NotImplementedError @@ -80,7 +103,7 @@ def insert_theme(self, theme: str) -> list: @property def available_themes(self) -> dict: - themes: dict[str, str] = {'dark': 'Dark', 'light': 'Light'} + themes: dict[str, str] = {"dark": "Dark", "light": "Light"} return themes @@ -89,37 +112,35 @@ def get_readable_kde_theme_name(file) -> str: """Searches for the long_name in the file and maps it to the found short name""" for line in file: - if 'Name=' in line: - name: str = '' + if "Name=" in line: + name: str = "" write: bool = False for letter in line: - if letter == '\n': + if letter == "\n": write = False if write: name += letter - if letter == '=': + if letter == "=": write = True return name def get_name_key(meta): locale = filter( - lambda name: name in meta['KPlugin'], - [f'Name[{QLocale().name()}]', - f'Name[{QLocale().language()}]', - 'Name'] + lambda name: name in meta["KPlugin"], + [f"Name[{QLocale().name()}]", f"Name[{QLocale().language()}]", "Name"], ) return next(locale) class _Kde(PluginCommandline): - name = 'System' + name = "System" translations = {} def __init__(self): - super().__init__(['lookandfeeltool', '-a', '{theme}']) - self.theme_light = 'org.kde.breeze.desktop' - self.theme_dark = 'org.kde.breezedark.desktop' + super().__init__(["lookandfeeltool", "-a", "{theme}"]) + self.theme_light = "org.kde.breeze.desktop" + self.theme_dark = "org.kde.breezedark.desktop" @property def available_themes(self) -> dict: @@ -132,7 +153,9 @@ def available_themes(self) -> dict: # asks the system what themes are available # noinspection SpellCheckingInspection - long_names = helpers.check_output(["lookandfeeltool", "-l"], universal_newlines=True) + long_names = helpers.check_output( + ["lookandfeeltool", "-l"], universal_newlines=True + ) long_names = long_names.splitlines() long_names.sort() @@ -141,23 +164,30 @@ def available_themes(self) -> dict: # trying to get the Desktop file try: # json in newer versions - with open(f'{helpers.get_usr()}share/plasma/look-and-feel/{long_name}/metadata.json', 'r') as file: + with open( + f"{helpers.get_usr()}share/plasma/look-and-feel/{long_name}/metadata.json", + "r", + ) as file: meta = json.load(file) key = get_name_key(meta) - self.translations[long_name] = meta['KPlugin'][key] + self.translations[long_name] = meta["KPlugin"][key] except OSError: try: # load the name from the metadata.desktop file - with open(f'{helpers.get_usr()}share/plasma/look-and-feel/{long_name}/metadata.desktop', - 'r') as file: + with open( + f"{helpers.get_usr()}share/plasma/look-and-feel/{long_name}/metadata.desktop", + "r", + ) as file: self.translations[long_name] = get_readable_kde_theme_name(file) except OSError: # check the next path if the themes exist there try: # load the name from the metadata.desktop file - with open(f'{path}{long_name}/metadata.desktop', 'r') as file: + with open(f"{path}{long_name}/metadata.desktop", "r") as file: # search for the name - self.translations[long_name] = get_readable_kde_theme_name(file) + self.translations[long_name] = get_readable_kde_theme_name( + file + ) except OSError: # if no file exist lets just use the long name self.translations[long_name] = long_name @@ -166,12 +196,17 @@ def available_themes(self) -> dict: class _Mate(PluginCommandline): - theme_directories = [Path(helpers.get_usr() + 'share/themes'), Path.home() / '.themes'] + theme_directories = [ + Path(helpers.get_usr() + "share/themes"), + Path.home() / ".themes", + ] def __init__(self): - super().__init__(['dconf', 'write', '/org/mate/marco/general/theme', '\'{theme}\'']) - self.theme_light = 'Yaru' - self.theme_dark = 'Yaru-dark' + super().__init__( + ["dconf", "write", "/org/mate/marco/general/theme", "'{theme}'"] + ) + self.theme_light = "Yaru" + self.theme_dark = "Yaru-dark" @property def available_themes(self) -> dict: @@ -182,14 +217,14 @@ def available_themes(self) -> dict: continue for d in directory.iterdir(): - index = d / 'index.theme' + index = d / "index.theme" if not index.is_file(): continue config = ConfigParser() config.read(index) try: - theme = config['X-GNOME-Metatheme']['MetacityTheme'] + theme = config["X-GNOME-Metatheme"]["MetacityTheme"] themes.append(theme) except KeyError: continue @@ -198,15 +233,33 @@ def available_themes(self) -> dict: @property def available(self): - return self.check_command(['dconf', 'help']) + return self.check_command(["dconf", "help"]) class _Cinnamon(PluginCommandline): def __init__(self): - super().__init__(['gsettings', 'set', 'org.cinnamon.theme', 'name', '\"{theme}\"']) - self.theme_light = 'Mint-X-Teal' - self.theme_dark = 'Mint-Y-Dark-Brown' + super().__init__( + ["gsettings", "set", "org.cinnamon.theme", "name", '"{theme}"'] + ) + self.theme_light = "Mint-X-Teal" + self.theme_dark = "Mint-Y-Dark-Brown" @property def available(self) -> bool: return test_gnome_availability(self.command) + + +class _Xfce(DBusPlugin): + def create_message(self, theme: str) -> QDBusMessage: + message = QDBusMessage.createMethodCall( + "org.xfce.Xfconf", "/org/xfce/Xfconf", "org.xfce.Xfconf", "SetProperty" + ) + theme_variant = QDBusVariant() + theme_variant.setVariant(theme) + message.setArguments(["xfwm4", "/general/theme", theme_variant]) + return message + + @property + def available_themes(self) -> dict: + themes = themes_from_theme_directories("xfwm4") + return {t: t for t in themes}