From b8d70591c57d0cced1bbc728a2c923621399a470 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Fri, 13 Nov 2026 09:41:22 +0200 Subject: [PATCH 1/2] Linux: ask the desktop for light or dark once, not every 30 ms CustomTkinter's "system" mode asks the system again every 30 ms. On Linux each question starts a gsettings process (darkdetect), so 1.1.0- alpha.2 started dozens of processes a second for as long as it ran. - theme.apply_appearance settles "system" itself: desktop_is_dark() asks gsettings once (colour scheme, then the theme's name), with a two second limit, and light or dark is set. CustomTkinter is never left in "system" mode, at import either. - A desktop that changes while the app runs is followed at the next start, or by choosing in Settings -> Appearance. - tests/test_look_linux.py. Since alpha.2, two CI jobs failed on their first attempt, twice, in the same sync tests with a 20 second timeout. This is the likely cause; the CI runs of this change are the check. Version 1.1.0-alpha.3. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 7 ++- sourceFiles/tests/test_look_linux.py | 71 ++++++++++++++++++++++++++++ sourceFiles/theme.py | 36 ++++++++++++-- sourceFiles/version.py | 2 +- 4 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 sourceFiles/tests/test_look_linux.py diff --git a/README.md b/README.md index 0bc779b..57574ab 100644 --- a/README.md +++ b/README.md @@ -217,8 +217,11 @@ with the Secret Key from your Emergency Kit. Font License); it is copied to `~/.fonts` at the first start, which is how the toolkit makes a font known. Codes and fingerprints use DejaVu Sans Mono, which Ubuntu ships. -- **Light and dark** *(1.1)* follow the desktop where it reports one; - *Settings → Appearance* chooses one either way. +- **Light and dark** *(1.1)* follow the desktop where it reports one + (GNOME's colour scheme, or a theme whose name ends in `-dark`), asked + **once, at start**: change the desktop while SecretSpace runs and it + follows at the next start. *Settings → Appearance* chooses one either + way. - **A package instead of an installer.** The `.deb` does what the Windows installer does: a menu entry, data in your home folder, removal that keeps the vaults. Unlike the Windows installer it does not offer to move a vault diff --git a/sourceFiles/tests/test_look_linux.py b/sourceFiles/tests/test_look_linux.py new file mode 100644 index 0000000..a656e0c --- /dev/null +++ b/sourceFiles/tests/test_look_linux.py @@ -0,0 +1,71 @@ +""" +Light and dark on Linux: the desktop is asked once. + +CustomTkinter's own "system" mode asks again every 30 ms, and on Linux each +question starts a gsettings process. So this app never leaves it in that +mode: theme.apply_appearance asks once and sets light or dark. +""" +import subprocess + +import pytest + +pytest.importorskip("customtkinter") + +import customtkinter as ctk +from customtkinter.windows.widgets.appearance_mode import AppearanceModeTracker + +import theme + + +@pytest.fixture(autouse=True) +def put_back(): + before = ctk.get_appearance_mode() + yield + ctk.set_appearance_mode(before) + + +def test_importing_the_theme_does_not_leave_system_mode(): + assert AppearanceModeTracker.appearance_mode_set_by != "system" + + +@pytest.mark.parametrize("dark,shown", [(True, "Dark"), (False, "Light")]) +def test_system_is_settled_once(monkeypatch, dark, shown): + asked = [] + monkeypatch.setattr(theme, "desktop_is_dark", + lambda: asked.append(1) or dark) + theme.apply_appearance("system") + assert ctk.get_appearance_mode() == shown and asked == [1] + assert AppearanceModeTracker.appearance_mode_set_by != "system" + + +@pytest.mark.parametrize("mode", ["light", "dark"]) +def test_a_chosen_mode_does_not_ask_the_desktop(monkeypatch, mode): + monkeypatch.setattr(theme, "desktop_is_dark", + lambda: pytest.fail("asked the desktop")) + theme.apply_appearance(mode) + assert ctk.get_appearance_mode() == mode.capitalize() + + +def _gsettings(answers): + """A stand-in for subprocess.run that answers per key.""" + def run(command, **options): + assert options.get("timeout") == 2 + reply = answers[command[-1]] + if isinstance(reply, Exception): + raise reply + return subprocess.CompletedProcess(command, 0, reply.encode(), b"") + return run + + +@pytest.mark.parametrize("answers,dark", [ + ({"color-scheme": "'prefer-dark'\n"}, True), + ({"color-scheme": "'default'\n"}, False), + ({"color-scheme": "", "gtk-theme": "'Yaru-dark'\n"}, True), # older + ({"color-scheme": "", "gtk-theme": "'Yaru'\n"}, False), + ({"color-scheme": "", "gtk-theme": ""}, False), # no answer + ({"color-scheme": FileNotFoundError("gsettings")}, False), # no tool + ({"color-scheme": subprocess.TimeoutExpired("gsettings", 2)}, False), +]) +def test_what_the_desktop_says(monkeypatch, answers, dark): + monkeypatch.setattr(subprocess, "run", _gsettings(answers)) + assert theme.desktop_is_dark() is dark diff --git a/sourceFiles/theme.py b/sourceFiles/theme.py index 8cac636..c8605f3 100644 --- a/sourceFiles/theme.py +++ b/sourceFiles/theme.py @@ -17,7 +17,10 @@ #: What Settings offers. "system" follows the desktop. APPEARANCES = ("system", "light", "dark") -ctk.set_appearance_mode("system") +# Not "system" here: on Linux CustomTkinter then asks the desktop again +# every 30 ms, by starting a gsettings process each time. apply_appearance +# asks once instead; the app calls it at start. +ctk.set_appearance_mode("light") ctk.set_default_color_theme("green") # Rounded corners are drawn the way CustomTkinter prefers here: out of @@ -124,9 +127,36 @@ def pick(color): return color +def desktop_is_dark(): + """ + Whether the desktop asks for dark, from gsettings: the colour scheme, + and the theme's name on older desktops. False when it cannot say, and + it gets two seconds to answer. + """ + import subprocess + for key in ("color-scheme", "gtk-theme"): + try: + answer = subprocess.run( + ["gsettings", "get", "org.gnome.desktop.interface", key], + capture_output=True, timeout=2).stdout.decode() + except Exception: + return False + if answer.strip(): + return "-dark" in answer.lower() + return False + + def apply_appearance(mode): - """Switch to "system", "light" or "dark"; anything else means system.""" - ctk.set_appearance_mode(mode if mode in APPEARANCES else "system") + """ + Switch to "system", "light" or "dark"; anything else means system. + + "system" is settled here, once: the desktop is asked and the answer is + set as light or dark. So a desktop that changes while the app runs is + followed at the next start (or by choosing in Settings). + """ + if mode not in ("light", "dark"): + mode = "dark" if desktop_is_dark() else "light" + ctk.set_appearance_mode(mode) def strength_color(name): diff --git a/sourceFiles/version.py b/sourceFiles/version.py index b422354..49285ba 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "1.1.0-alpha.2" +__version__ = "1.1.0-alpha.3" #: The container a 3.0 account's vaults are written in (account_crypto). VAULT_FORMAT = "PMV3" From 8899a63d86b6e627520bec02a1f37184aaf468b4 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Fri, 13 Nov 2026 09:47:44 +0200 Subject: [PATCH 2/2] Tests: collect the test window's Tk objects in Tk's own thread Since the look tests open a real window, CI failed now and then in a sync test with a 20 second timeout. Fonts and images delete themselves through Tk. When the garbage collector ran in the test server's thread, each of them waited a second for a main loop that no test runs, and the request being served timed out. The window's fixture now collects in the main thread when it closes the window. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- sourceFiles/tests/test_look.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sourceFiles/tests/test_look.py b/sourceFiles/tests/test_look.py index 25037fd..e21b97d 100644 --- a/sourceFiles/tests/test_look.py +++ b/sourceFiles/tests/test_look.py @@ -153,6 +153,12 @@ def window(): root.withdraw() yield root root.destroy() + # Collect what the window left behind here, in the thread Tk lives in. + # Fonts and images delete themselves through Tk; collected later from + # another thread (a test server's, say) each waits a second for a main + # loop that no test runs, and the request being served times out. + import gc + gc.collect() @pytest.fixture