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}