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:
Jatin K Malik
2026-09-14 00:16:50 -07:00
committed by GitHub
parent 79df154292
commit 5e8a08fbb8
4 changed files with 290 additions and 10 deletions
+4
View File
@@ -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.")
+2 -10
View File
@@ -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
+148
View File
@@ -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
+136
View File
@@ -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()