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
|
||||
Reference in New Issue
Block a user