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.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 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"