Resolve light, dark and automatic appearance centrally and publish live theme snapshots to native surfaces and the appearance portal. Add paired preset families, custom theme imports, accessibility transforms and contrast-safe transitions. Refine the Settings gallery and Auto controls, move overrides under advanced customization, and share palette-aware switches and circular slider thumbs. Document configuration and include theme licenses and integration coverage.
139 lines
9.2 KiB
Python
139 lines
9.2 KiB
Python
"""Opt-in theme integration test against a disposable nested desktop.
|
|
|
|
FERESE_TEST_THEME=1 python3 scripts/tests/test_theme_isolated.py
|
|
Requires target/debug/ferese, feresectl, and a running Wayland desktop.
|
|
"""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import signal
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
|
|
|
|
@unittest.skipUnless(os.environ.get("FERESE_TEST_THEME") == "1", "nested theme test is opt-in")
|
|
class ThemeTest(unittest.TestCase):
|
|
def test_modes_files_preview_and_idle_subscription(self):
|
|
repo = Path(__file__).resolve().parents[2]
|
|
children = []
|
|
with tempfile.TemporaryDirectory(prefix="ferese-theme-test-") as temporary:
|
|
root = Path(temporary)
|
|
runtime = root / "runtime"
|
|
runtime.mkdir(mode=0o700)
|
|
config = root / "config/ferese"
|
|
config.mkdir(parents=True)
|
|
source = config / "config.kdl"
|
|
shared = config / "themes/shared.kdl"
|
|
shared.parent.mkdir()
|
|
shared.write_text('colors { accent "#3D7BE6"; }\n')
|
|
source.write_text('// Keep my comment\ntheme { mode "dark"; file "themes/shared.kdl"; material { style "translucent"; opacity 0.78; }; }\n')
|
|
display = Path(os.environ["WAYLAND_DISPLAY"])
|
|
if not display.is_absolute():
|
|
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
|
|
env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime),
|
|
XDG_CONFIG_HOME=str(root / "config"))
|
|
|
|
def launch(command, **kwargs):
|
|
child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs)
|
|
children.append(child)
|
|
return child
|
|
|
|
def command(*args):
|
|
return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), *args], env=env, timeout=5))
|
|
|
|
def until(predicate, seconds=5):
|
|
deadline = time.monotonic() + seconds
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
value = predicate()
|
|
if value:
|
|
return value
|
|
except (subprocess.SubprocessError, ConnectionError):
|
|
pass
|
|
time.sleep(.025)
|
|
self.fail("Theme did not reach expected state")
|
|
|
|
def replace(path, text):
|
|
temporary = path.with_suffix(".new")
|
|
temporary.write_text(text)
|
|
temporary.replace(path)
|
|
|
|
with (root / "compositor.log").open("w") as log:
|
|
try:
|
|
bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE, text=True)
|
|
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
|
|
compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log)
|
|
until(lambda: (runtime / "ferese/control.sock").is_socket())
|
|
self.assertEqual(command("theme", "get")["theme"]["appearance"], "dark")
|
|
subscriber = launch([str(repo / "target/debug/feresectl"), "theme", "subscribe"], stdout=subprocess.PIPE, text=True)
|
|
self.assertTrue(select.select([subscriber.stdout], [], [], 3)[0])
|
|
initial = json.loads(subscriber.stdout.readline())
|
|
command("theme", "mode", "light")
|
|
ready = until(lambda: (snapshot := command("theme", "get"))["presented"] == snapshot["theme"] and snapshot["theme"]["appearance"] == "light" and snapshot)
|
|
self.assertEqual(ready["mode"], "light")
|
|
self.assertIn("// Keep my comment", source.read_text())
|
|
self.assertGreater(ready["revision"], initial["revision"])
|
|
candidate = root / "candidate.kdl"
|
|
candidate.write_text('theme { mode "dark"; accent "#FE8019"; }\n')
|
|
preview = command("theme", "preview", str(candidate))
|
|
self.assertEqual(preview["theme"]["appearance"], "dark")
|
|
self.assertEqual(command("theme", "get")["revision"], ready["revision"])
|
|
replace(shared, 'colors { accent "invalid"; }\n')
|
|
rejected = until(lambda: (snapshot := command("theme", "status"))["error"] and snapshot)
|
|
self.assertEqual(rejected["theme"], ready["theme"])
|
|
replace(shared, 'colors { accent "#8F5300"; }; future-token 1\n')
|
|
repaired = until(lambda: (snapshot := command("theme", "get"))["error"] is None and snapshot["theme"]["requested_accent"] == "#8F5300" and snapshot["presented"] == snapshot["theme"] and snapshot)
|
|
self.assertTrue(any("future_token" in warning for warning in repaired["warnings"]))
|
|
time.sleep(.35)
|
|
self.assertEqual(command("theme", "get")["revision"], repaired["revision"])
|
|
replace(source, source.read_text().replace('file "themes/shared.kdl"', 'file "themes/missing.kdl"'))
|
|
until(lambda: command("theme", "get")["error"])
|
|
(shared.parent / "missing.kdl").write_text('material { style "translucent"; }; colors { accent "#3D7BE6"; }\n')
|
|
until(lambda: command("theme", "get")["error"] is None)
|
|
replace(source, source.read_text().replace(' mode ', ' accessibility { reduce-transparency #true; }\n mode '))
|
|
solid = until(lambda: (snapshot := command("theme", "get"))["theme"]["accessibility"]["reduce_transparency"] and snapshot["presented"] == snapshot["theme"] and snapshot)
|
|
self.assertEqual(solid["theme"]["tokens"]["material"]["blur_radius"], 0)
|
|
self.assertEqual(solid["theme"]["tokens"]["material"]["style"], "solid")
|
|
imported = shared.parent / "family.kdl"
|
|
imported.write_text('theme { name "Imported"; dark { colors { accent "#CBA6F7"; }; }; }\n')
|
|
replace(source, 'theme { mode "light"; family "custom-demo"; split #false; custom-themes { custom-demo { file "themes/family.kdl"; }; }; }\n')
|
|
missing = until(lambda: (snapshot := command("theme", "get"))["fallback_note"] and "no light variant" in snapshot["fallback_note"] and snapshot)
|
|
self.assertTrue(any(family["id"] == "custom-demo" and family["light"] is None for family in missing["families"]))
|
|
command("theme", "mode", "dark")
|
|
applied = until(lambda: (snapshot := command("theme", "get"))["theme"]["requested_accent"] == "#CBA6F7" and snapshot["presented"] == snapshot["theme"] and snapshot)
|
|
self.assertIsNone(applied["fallback_note"])
|
|
replace(imported, 'theme { dark { colors { accent "invalid"; }; }; }\n')
|
|
rejected = until(lambda: (snapshot := command("theme", "get"))["error"] and snapshot)
|
|
self.assertEqual(rejected["theme"], applied["theme"])
|
|
replace(imported, 'theme { name "Repaired"; dark { colors { accent "#FE8019"; }; }; }\n')
|
|
until(lambda: (snapshot := command("theme", "get"))["error"] is None and snapshot["theme"]["requested_accent"] == "#FE8019")
|
|
replace(source, 'theme { mode "dark"; family "ferese-blue"; split #true; light { family "gruvbox"; }; dark { family "catppuccin"; }; }\n')
|
|
until(lambda: command("theme", "get")["theme"]["tokens"]["colors"]["surface_base"] == "#1E1E2E")
|
|
command("theme", "mode", "light")
|
|
until(lambda: command("theme", "get")["theme"]["tokens"]["colors"]["surface_base"] == "#FBF1C7")
|
|
if shutil.which("gsettings"):
|
|
subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark"], env=env, check=True, timeout=5)
|
|
replace(source, 'theme { mode "auto"; family "everforest"; schedule { source "system"; }; }\n')
|
|
until(lambda: (snapshot := command("theme", "get"))["mode"] == "auto" and snapshot["theme"]["appearance"] == "dark" and snapshot["fallback_note"] is None)
|
|
subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-light"], env=env, check=True, timeout=5)
|
|
until(lambda: (snapshot := command("theme", "get"))["theme"]["appearance"] == "light" and snapshot["presented"] == snapshot["theme"])
|
|
self.assertIsNone(compositor.poll())
|
|
finally:
|
|
for child in reversed(children):
|
|
if child.poll() is None:
|
|
os.killpg(child.pid, signal.SIGTERM)
|
|
child.wait(timeout=5)
|
|
if child.stdout:
|
|
child.stdout.close()
|
|
if compositor.returncode not in (0, -signal.SIGTERM):
|
|
log.flush()
|
|
print((root / "compositor.log").read_text())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|