Skip to main content

zwire_host/
theme_watch.rs

1//! Cross-process live theme sync.
2//!
3//! The shared theme (`~/.zwire/global.toml`) is written by whichever app's
4//! zwire-host process the user toggled in. Snapshot-on-subscribe already makes a
5//! *newly connecting* client converge, but two apps open at once each run their
6//! OWN host process with a process-local bus — so a change in one wouldn't reach
7//! the other's already-subscribed clients.
8//!
9//! This watcher closes that gap: a background thread (started lazily on the first
10//! theme `sub`) polls the shared file and, when the scheme/ui differs from what
11//! THIS process last saw, republishes it to this process's local bus subscribers.
12//! So a toggle in Audio-Haxor fans out to the live zwire HUD, zemacs, etc.
13//!
14//! Echo control: local writes call [`note_scheme`] / [`note_ui`] to record the
15//! value this process just wrote, so the watcher recognises its own change and
16//! doesn't re-publish it (the write path already published to local subs).
17use crate::{bus, store};
18use serde_json::{json, Value};
19use std::sync::{Mutex, OnceLock};
20use std::time::Duration;
21
22fn last() -> &'static Mutex<(String, Value)> {
23    static L: OnceLock<Mutex<(String, Value)>> = OnceLock::new();
24    L.get_or_init(|| Mutex::new((String::new(), Value::Null)))
25}
26
27/// Record the scheme this process just wrote so the watcher won't echo it.
28pub fn note_scheme(s: &str) {
29    last().lock().unwrap().0 = s.to_string();
30}
31
32/// Record the ui this process just wrote so the watcher won't echo it.
33pub fn note_ui(ui: &Value) {
34    last().lock().unwrap().1 = ui.clone();
35}
36
37/// Start the shared-theme file watcher exactly once per process. No-op if the
38/// theme feature is never used (called from the first `sub` to a theme topic).
39pub fn ensure_started() {
40    static STARTED: OnceLock<()> = OnceLock::new();
41    if STARTED.set(()).is_err() {
42        return; // already running
43    }
44    // Seed with the current values so the first tick doesn't fire a spurious change.
45    let d = store::theme_dir();
46    {
47        let mut l = last().lock().unwrap();
48        l.0 = store::current_scheme(&d);
49        l.1 = store::current_ui(&d);
50    }
51    std::thread::spawn(|| loop {
52        std::thread::sleep(Duration::from_millis(700));
53        let d = store::theme_dir();
54        let scheme = store::current_scheme(&d);
55        let ui = store::current_ui(&d);
56        // Decide what changed under the lock, then release it BEFORE publishing so
57        // we never hold `last` across the bus fan-out.
58        let (pub_scheme, pub_ui) = {
59            let mut l = last().lock().unwrap();
60            let ps = l.0 != scheme;
61            if ps {
62                l.0 = scheme.clone();
63            }
64            let pu = l.1 != ui;
65            if pu {
66                l.1 = ui.clone();
67            }
68            (ps, pu)
69        };
70        if pub_scheme {
71            bus::publish("scheme", &json!({ "scheme": scheme }));
72        }
73        if pub_ui {
74            bus::publish("ui", &ui);
75        }
76    });
77}