fix(ui): follow OS dark/light on native GTK installs (#830)
* fix(ui): follow OS dark/light on native GTK installs AppImage already maps portal/gsettings color-scheme into GTK_THEME. AUR and install.sh use system GTK3, which ignores prefer-dark. Set gtk-application-prefer-dark-theme from the same signals, and skip when GTK_THEME is already set so AppImage and user overrides stay in charge. Fixes #816 * docs: portal no-preference still checks gsettings The helper already falls back after a portal 0 or a failed Read. The module doc said that left GTK defaults, which is only true after gsettings also comes up empty.
This commit is contained in:
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user