diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index ef44508..226deff 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -309,6 +309,10 @@ def main(): if not check_display_available(): sys.exit(1) + from .utils.gtk_color_scheme import apply_os_color_scheme + + apply_os_color_scheme() + if not check_appindicator_support(): logger.warning("No StatusNotifierWatcher found on D-Bus session bus.") logger.warning("The system tray icon may not appear.") diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index b5d45c8..994b985 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -20,7 +20,6 @@ UX Design Notes: import logging import os import re -import subprocess import threading import time from typing import TYPE_CHECKING, Any, NamedTuple, Optional @@ -514,16 +513,9 @@ def _about_surface_is_dark() -> bool: except Exception: pass try: - from ..utils.host_process import host_env + from ..utils.gtk_color_scheme import read_os_color_scheme - result = subprocess.run( - ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"], - capture_output=True, - text=True, - timeout=1, - env=host_env(), - ) - if result.returncode == 0 and "prefer-dark" in (result.stdout or "").lower(): + if read_os_color_scheme() == "prefer-dark": return True except Exception: pass diff --git a/src/vocalinux/utils/gtk_color_scheme.py b/src/vocalinux/utils/gtk_color_scheme.py new file mode 100644 index 0000000..2e361ab --- /dev/null +++ b/src/vocalinux/utils/gtk_color_scheme.py @@ -0,0 +1,148 @@ +"""Map the OS color-scheme preference onto GTK3. + +AppImage already does this in the linuxdeploy-plugin-gtk AppRun hook by +setting ``GTK_THEME=Adwaita:dark|light``. Native installs (AUR, install.sh) +use system GTK3, which does not map ``org.freedesktop.appearance +color-scheme=prefer-dark`` on its own. Setting +``gtk-application-prefer-dark-theme`` follows the same portal/gsettings +signals without forcing Adwaita, so custom themes still work. + +Portal ``Read`` is tried first. If it is unavailable or reports no preference, +``gsettings`` is used as a fallback. An unknown ``gsettings`` value or read +failure leaves GTK defaults unchanged. ``GTK_THEME`` already set (AppImage or +user override) skips the helper entirely. +""" + +import logging +import os +import subprocess +from typing import Any, Literal, Optional + +from .host_process import host_env + +logger = logging.getLogger(__name__) + +ColorScheme = Literal["prefer-dark", "prefer-light"] + +_PORTAL_BUS_NAME = "org.freedesktop.portal.Desktop" +_PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop" +_PORTAL_INTERFACE = "org.freedesktop.portal.Settings" +_APPEARANCE_NAMESPACE = "org.freedesktop.appearance" +_COLOR_SCHEME_KEY = "color-scheme" +_PORTAL_TIMEOUT_MS = 1000 +_GSETTINGS_TIMEOUT_S = 1 + + +def read_os_color_scheme() -> Optional[ColorScheme]: + """Return ``prefer-dark``, ``prefer-light``, or ``None`` if unknown.""" + scheme = _read_portal_color_scheme() + if scheme is not None: + return scheme + return _read_gsettings_color_scheme() + + +def apply_os_color_scheme() -> None: + """Sync OS prefer-dark/light into GTK, unless ``GTK_THEME`` already overrides.""" + try: + if os.environ.get("GTK_THEME"): + logger.debug("GTK_THEME is set; skipping OS color-scheme sync") + return + scheme = read_os_color_scheme() + if scheme is None: + return + from gi.repository import Gtk + + settings = Gtk.Settings.get_default() + if settings is None: + logger.debug("Gtk.Settings.get_default() returned None") + return + prefer_dark = scheme == "prefer-dark" + settings.set_property("gtk-application-prefer-dark-theme", prefer_dark) + logger.debug("Applied gtk-application-prefer-dark-theme=%s", prefer_dark) + except Exception as exc: + logger.debug("Could not apply OS color scheme: %s", exc) + + +def _read_portal_color_scheme() -> Optional[ColorScheme]: + try: + from gi.repository import Gio, GLib + + proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION, + None, + _PORTAL_BUS_NAME, + _PORTAL_OBJECT_PATH, + _PORTAL_INTERFACE, + None, + ) + result = proxy.call_sync( + "Read", + GLib.Variant("(ss)", (_APPEARANCE_NAMESPACE, _COLOR_SCHEME_KEY)), + Gio.DBusCallFlags.NONE, + _PORTAL_TIMEOUT_MS, + None, + ) + if result is None: + return None + value = _unwrap_portal_value(result) + return _scheme_from_portal_uint(value) + except Exception as exc: + logger.debug("Portal color-scheme read failed: %s", exc) + return None + + +def _read_gsettings_color_scheme() -> Optional[ColorScheme]: + try: + result = subprocess.run( + ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"], + capture_output=True, + text=True, + timeout=_GSETTINGS_TIMEOUT_S, + env=host_env(), + ) + if result.returncode != 0: + logger.debug("gsettings color-scheme exited %s", result.returncode) + return None + return _scheme_from_gsettings_text(result.stdout or "") + except Exception as exc: + logger.debug("gsettings color-scheme read failed: %s", exc) + return None + + +def _unwrap_portal_value(value: Any) -> Any: + """Peel nested D-Bus variants/1-tuples from portal Settings.Read.""" + for _ in range(8): + if isinstance(value, (int, str, bytes)) or value is None: + return value + unpack = getattr(value, "unpack", None) + if callable(unpack) and not isinstance(value, (list, tuple)): + try: + value = unpack() + continue + except Exception: + return value + if isinstance(value, (list, tuple)) and len(value) == 1: + value = value[0] + continue + return value + return value + + +def _scheme_from_portal_uint(value: Any) -> Optional[ColorScheme]: + if isinstance(value, bool) or not isinstance(value, int): + return None + if value == 1: + return "prefer-dark" + if value == 2: + return "prefer-light" + return None + + +def _scheme_from_gsettings_text(text: str) -> Optional[ColorScheme]: + normalized = text.strip().strip("'\"").lower() + if normalized == "prefer-dark": + return "prefer-dark" + if normalized == "prefer-light": + return "prefer-light" + return None diff --git a/tests/test_gtk_color_scheme.py b/tests/test_gtk_color_scheme.py new file mode 100644 index 0000000..70a81b1 --- /dev/null +++ b/tests/test_gtk_color_scheme.py @@ -0,0 +1,136 @@ +"""OS color-scheme mapping into gtk-application-prefer-dark-theme (#816).""" + +from unittest.mock import MagicMock, patch + +import pytest + +from vocalinux.utils import gtk_color_scheme as gcs + + +def _portal_proxy(unpacked): + proxy = MagicMock() + result = MagicMock() + result.unpack.return_value = unpacked + proxy.call_sync.return_value = result + return proxy + + +def _gsettings_ok(stdout: str) -> MagicMock: + result = MagicMock() + result.returncode = 0 + result.stdout = stdout + result.stderr = "" + return result + + +@pytest.fixture +def patched_gi(): + gtk = MagicMock() + gio = MagicMock() + glib = MagicMock() + settings = MagicMock() + gtk.Settings.get_default.return_value = settings + with ( + patch("gi.repository.Gtk", gtk), + patch("gi.repository.Gio", gio), + patch("gi.repository.GLib", glib), + patch("vocalinux.utils.gtk_color_scheme.subprocess.run") as run, + ): + yield { + "gtk": gtk, + "gio": gio, + "glib": glib, + "settings": settings, + "run": run, + } + + +def test_prefer_dark_from_portal_sets_property_true(monkeypatch, patched_gi): + monkeypatch.delenv("GTK_THEME", raising=False) + patched_gi["gio"].DBusProxy.new_for_bus_sync.return_value = _portal_proxy((1,)) + + gcs.apply_os_color_scheme() + + patched_gi["settings"].set_property.assert_called_once_with( + "gtk-application-prefer-dark-theme", True + ) + patched_gi["run"].assert_not_called() + + +def test_prefer_light_from_portal_sets_property_false(monkeypatch, patched_gi): + monkeypatch.delenv("GTK_THEME", raising=False) + patched_gi["gio"].DBusProxy.new_for_bus_sync.return_value = _portal_proxy((2,)) + + gcs.apply_os_color_scheme() + + patched_gi["settings"].set_property.assert_called_once_with( + "gtk-application-prefer-dark-theme", False + ) + patched_gi["run"].assert_not_called() + + +def test_portal_miss_gsettings_prefer_dark_sets_property_true(monkeypatch, patched_gi): + monkeypatch.delenv("GTK_THEME", raising=False) + patched_gi["gio"].DBusProxy.new_for_bus_sync.side_effect = RuntimeError("no portal") + patched_gi["run"].return_value = _gsettings_ok("'prefer-dark'\n") + + gcs.apply_os_color_scheme() + + patched_gi["settings"].set_property.assert_called_once_with( + "gtk-application-prefer-dark-theme", True + ) + patched_gi["run"].assert_called_once() + assert patched_gi["run"].call_args.kwargs.get("env") is not None + + +def test_gtk_theme_env_skips_settings(monkeypatch, patched_gi): + monkeypatch.setenv("GTK_THEME", "Adwaita:dark") + + gcs.apply_os_color_scheme() + + patched_gi["gtk"].Settings.get_default.assert_not_called() + patched_gi["gio"].DBusProxy.new_for_bus_sync.assert_not_called() + patched_gi["run"].assert_not_called() + patched_gi["settings"].set_property.assert_not_called() + + +def test_portal_no_preference_falls_back_to_gsettings(monkeypatch, patched_gi): + monkeypatch.delenv("GTK_THEME", raising=False) + patched_gi["gio"].DBusProxy.new_for_bus_sync.return_value = _portal_proxy((0,)) + patched_gi["run"].return_value = _gsettings_ok("'prefer-light'\n") + + gcs.apply_os_color_scheme() + + patched_gi["settings"].set_property.assert_called_once_with( + "gtk-application-prefer-dark-theme", False + ) + + +def test_unknown_scheme_leaves_gtk_defaults(monkeypatch, patched_gi): + monkeypatch.delenv("GTK_THEME", raising=False) + patched_gi["gio"].DBusProxy.new_for_bus_sync.return_value = _portal_proxy((0,)) + patched_gi["run"].return_value = _gsettings_ok("'default'\n") + + gcs.apply_os_color_scheme() + + patched_gi["settings"].set_property.assert_not_called() + + +def test_nested_portal_variant_unpacks_to_prefer_dark(): + inner = MagicMock() + inner.unpack.return_value = 1 + assert gcs._unwrap_portal_value(((inner,),)) == 1 + assert gcs._scheme_from_portal_uint(1) == "prefer-dark" + assert gcs._scheme_from_portal_uint(2) == "prefer-light" + assert gcs._scheme_from_portal_uint(0) is None + assert gcs._scheme_from_gsettings_text("'prefer-dark'") == "prefer-dark" + assert gcs._scheme_from_gsettings_text("prefer-light\n") == "prefer-light" + assert gcs._scheme_from_gsettings_text("'default'") is None + + +def test_apply_fail_soft_on_gtk_error(monkeypatch, patched_gi): + monkeypatch.delenv("GTK_THEME", raising=False) + patched_gi["gio"].DBusProxy.new_for_bus_sync.return_value = _portal_proxy((1,)) + patched_gi["gtk"].Settings.get_default.side_effect = RuntimeError("no display") + + gcs.apply_os_color_scheme()