Skip to main content

w_gui/
context.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::mpsc;
4use std::sync::{Arc, Mutex};
5use std::thread::JoinHandle;
6
7use crate::element::{ElementDecl, ElementId, Value};
8use crate::protocol::ServerMsg;
9use crate::server;
10use crate::window::Window;
11
12/// Options for creating a wgui [`Context`].
13pub struct ContextOptions {
14    /// Starting port for the HTTP server (WS gets port + 1).
15    pub start_port: u16,
16    /// Page title shown in the browser tab.
17    pub title: String,
18    /// Optional PNG favicon bytes. When `None`, no favicon is served.
19    pub favicon: Option<Vec<u8>>,
20    /// When `true`, bind to `0.0.0.0` (accessible on the network).
21    /// When `false`, bind to `127.0.0.1` (localhost only).
22    pub public: bool,
23}
24
25impl Default for ContextOptions {
26    fn default() -> Self {
27        Self {
28            start_port: 9080,
29            title: "wgui".to_string(),
30            favicon: None,
31            public: false,
32        }
33    }
34}
35
36pub struct Context {
37    // Sends batched ServerMsg diffs to the WS thread each frame.
38    // `None` when no free port was found (headless / degraded mode).
39    ws_tx: Option<mpsc::SyncSender<Vec<ServerMsg>>>,
40    // Receives browser edits from WS thread (wrapped in Mutex for Sync).
41    // `None` when no free port was found.
42    edit_rx: Mutex<Option<mpsc::Receiver<(ElementId, Value)>>>,
43    // Local cache of pending edits, drained from edit_rx on demand
44    incoming_edits: HashMap<ElementId, Value>,
45    // Signals HTTP thread to shut down
46    shutdown: Arc<AtomicBool>,
47
48    prev_frame: Vec<ElementDecl>,
49    current_frame: Vec<ElementDecl>,
50    http_port: u16,
51    ws_port: u16,
52    _http_handle: Option<JoinHandle<()>>,
53    _ws_handle: Option<JoinHandle<()>>,
54}
55
56impl Context {
57    /// Create a new wgui context with default options (localhost, port 9080, title "wgui").
58    pub fn new() -> Self {
59        Self::with_options(ContextOptions::default())
60    }
61
62    /// Create a new wgui context starting port search from `start_port`.
63    pub fn with_port(start_port: u16) -> Self {
64        Self::with_options(ContextOptions {
65            start_port,
66            ..Default::default()
67        })
68    }
69
70    /// Create a new wgui context with the given options.
71    /// If no free port pair is found, the context runs in degraded (headless) mode:
72    /// UI calls still work locally, but nothing is served over the network.
73    pub fn with_options(opts: ContextOptions) -> Self {
74        let bind_addr = if opts.public { "0.0.0.0" } else { "127.0.0.1" };
75
76        if let Some((http_listener, ws_listener)) = server::find_port_pair(opts.start_port, bind_addr) {
77            let http_port = http_listener.local_addr().map(|a| a.port()).unwrap_or(0);
78            let ws_port = ws_listener.local_addr().map(|a| a.port()).unwrap_or(0);
79
80            // Create channels for inter-thread communication
81            let (ws_tx, ws_rx) = mpsc::sync_channel::<Vec<ServerMsg>>(2);
82            let (edit_tx, edit_rx) = mpsc::channel::<(ElementId, Value)>();
83            let shutdown = Arc::new(AtomicBool::new(false));
84
85            let http_handle =
86                server::spawn_http(shutdown.clone(), http_listener, &opts.title, opts.favicon);
87            let ws_handle = server::spawn_ws(ws_rx, edit_tx, ws_listener, shutdown.clone());
88
89            println!("wgui: UI available at http://{bind_addr}:{http_port}");
90
91            Self {
92                ws_tx: Some(ws_tx),
93                edit_rx: Mutex::new(Some(edit_rx)),
94                incoming_edits: HashMap::new(),
95                shutdown,
96                prev_frame: Vec::new(),
97                current_frame: Vec::new(),
98                http_port,
99                ws_port,
100                _http_handle: Some(http_handle),
101                _ws_handle: Some(ws_handle),
102            }
103        } else {
104            log::warn!("wgui: running in headless mode (no free ports)");
105            Self {
106                ws_tx: None,
107                edit_rx: Mutex::new(None),
108                incoming_edits: HashMap::new(),
109                shutdown: Arc::new(AtomicBool::new(false)),
110                prev_frame: Vec::new(),
111                current_frame: Vec::new(),
112                http_port: 0,
113                ws_port: 0,
114                _http_handle: None,
115                _ws_handle: None,
116            }
117        }
118    }
119
120    /// Returns the HTTP port the UI is served on, or `0` if running headless.
121    pub fn http_port(&self) -> u16 {
122        self.http_port
123    }
124
125    /// Returns the WebSocket port, or `0` if running headless.
126    pub fn ws_port(&self) -> u16 {
127        self.ws_port
128    }
129
130    /// Get or create a named window. Call widget methods on the returned `Window`.
131    pub fn window(&mut self, name: &str) -> Window<'_> {
132        Window::new(name.to_string(), self)
133    }
134
135    /// Consume a pending browser edit for the given element id, if any.
136    pub(crate) fn consume_edit(&mut self, id: &str) -> Option<Value> {
137        // Drain all pending edits from the channel into the local cache
138        let rx = self.edit_rx.lock().unwrap();
139        if let Some(ref channel) = *rx {
140            while let Ok((elem_id, value)) = channel.try_recv() {
141                self.incoming_edits.insert(elem_id, value);
142            }
143        }
144        drop(rx);
145        self.incoming_edits.remove(id)
146    }
147
148    /// Record an element declaration for the current frame.
149    pub(crate) fn declare(&mut self, decl: ElementDecl) {
150        self.current_frame.push(decl);
151    }
152
153    /// Finish the current frame: reconcile with previous frame, send diffs over WS.
154    /// In headless mode this is a no-op.
155    pub fn end_frame(&mut self) {
156        let outgoing = reconcile(&self.prev_frame, &self.current_frame);
157
158        if !outgoing.is_empty() {
159            if let Some(ref tx) = self.ws_tx {
160                match tx.try_send(outgoing) {
161                    Ok(()) => {}
162                    Err(mpsc::TrySendError::Full(_)) => {
163                        log::debug!("wgui: WS channel backpressure, skipping frame update");
164                    }
165                    Err(mpsc::TrySendError::Disconnected(_)) => {
166                        log::debug!("wgui: WS thread disconnected");
167                    }
168                }
169            }
170        }
171
172        // Swap frames
173        self.prev_frame = std::mem::take(&mut self.current_frame);
174    }
175}
176
177impl Default for Context {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183impl Drop for Context {
184    fn drop(&mut self) {
185        self.shutdown.store(true, Ordering::Release);
186        // Join the server threads so their listeners (and the ports) are fully
187        // released before any replacement Context tries to bind. Without this,
188        // a quick in-process restart can drift to the next port pair while an
189        // already-open browser tab keeps pointing at the old port and never
190        // reconnects. Threads observe `shutdown` within ~200ms (HTTP poll).
191        if let Some(handle) = self._http_handle.take() {
192            let _ = handle.join();
193        }
194        if let Some(handle) = self._ws_handle.take() {
195            let _ = handle.join();
196        }
197    }
198}
199
200/// Compare previous and current frames, producing the minimal set of
201/// Add / Update / Remove messages needed to bring a client up to date.
202fn reconcile(prev: &[ElementDecl], current: &[ElementDecl]) -> Vec<ServerMsg> {
203    let mut outgoing = Vec::new();
204
205    // Build index of previous frame for O(1) lookup
206    let prev_index: HashMap<&str, usize> = prev
207        .iter()
208        .enumerate()
209        .map(|(i, d)| (d.id.as_str(), i))
210        .collect();
211
212    // Detect added and updated elements
213    for decl in current {
214        match prev_index.get(decl.id.as_str()) {
215            None => {
216                outgoing.push(ServerMsg::Add {
217                    element: decl.clone(),
218                });
219            }
220            Some(&idx) => {
221                let prev_decl = &prev[idx];
222                let value_changed = prev_decl.value != decl.value || prev_decl.kind != decl.kind || prev_decl.label != decl.label;
223                let meta_changed = prev_decl.meta != decl.meta;
224                let label_changed = prev_decl.label != decl.label;
225                if value_changed || meta_changed || label_changed {
226                    outgoing.push(ServerMsg::Update {
227                        id: decl.id.clone(),
228                        value: decl.value.clone(),
229                        label: if label_changed {
230                            Some(decl.label.clone())
231                        } else {
232                            None
233                        },
234                        meta: if meta_changed {
235                            Some(decl.meta.clone())
236                        } else {
237                            None
238                        },
239                    });
240                }
241            }
242        }
243    }
244
245    // Detect removed elements
246    let current_ids: HashSet<&str> = current.iter().map(|d| d.id.as_str()).collect();
247    for prev_decl in prev {
248        if !current_ids.contains(prev_decl.id.as_str()) {
249            outgoing.push(ServerMsg::Remove {
250                id: prev_decl.id.clone(),
251            });
252        }
253    }
254
255    // Re-assert per-window element order when the client's post-batch order
256    // would differ from the declared order. The client applies `Add` by
257    // appending and `Remove` by deletion, preserving order otherwise — so after
258    // a batch its order is the surviving previous elements followed by the newly
259    // added ones. An element first declared *between* existing ones (e.g. a
260    // table row that only appears once its data exists, landing under a later
261    // section) therefore ends up in the wrong place; a Reorder carrying the
262    // declared order fixes it. Pure reorders fall out of the same comparison,
263    // and a plain append (new element declared last) predicts correctly and
264    // emits nothing.
265    let mut prev_order: HashMap<&str, Vec<&str>> = HashMap::new();
266    for d in prev {
267        prev_order.entry(d.window.as_ref()).or_default().push(&d.id);
268    }
269    let mut curr_windows: Vec<&str> = Vec::new();
270    let mut curr_order: HashMap<&str, Vec<&str>> = HashMap::new();
271    for d in current {
272        let w = d.window.as_ref();
273        if !curr_order.contains_key(w) {
274            curr_windows.push(w);
275        }
276        curr_order.entry(w).or_default().push(&d.id);
277    }
278    let empty: Vec<&str> = Vec::new();
279    for win in curr_windows {
280        let desired = &curr_order[win];
281        let prev_ids = prev_order.get(win).unwrap_or(&empty);
282        let curr_set: HashSet<&str> = desired.iter().copied().collect();
283        let prev_set: HashSet<&str> = prev_ids.iter().copied().collect();
284        // Predicted client order: surviving previous ids, then the new ones.
285        let mut predicted: Vec<&str> = prev_ids.iter().copied().filter(|id| curr_set.contains(id)).collect();
286        predicted.extend(desired.iter().copied().filter(|id| !prev_set.contains(id)));
287        if &predicted != desired {
288            outgoing.push(ServerMsg::Reorder {
289                window: win.to_string(),
290                ids: desired.iter().map(|s| s.to_string()).collect(),
291            });
292        }
293    }
294
295    outgoing
296}
297
298const _: () = {
299    fn _assert_send_sync<T: Send + Sync>() {}
300    fn _check() { _assert_send_sync::<Context>(); }
301};
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::element::{ElementKind, ElementMeta, Value};
307    use std::sync::Arc;
308
309    fn make_decl(id: &str, value: Value) -> ElementDecl {
310        ElementDecl {
311            id: id.to_string(),
312            kind: ElementKind::Label,
313            label: id.to_string(),
314            value,
315            meta: ElementMeta::default(),
316            window: Arc::from("test"),
317        }
318    }
319
320    #[test]
321    fn reconcile_detects_additions() {
322        let msgs = reconcile(&[], &[make_decl("a", Value::Bool(true))]);
323        assert_eq!(msgs.len(), 1);
324        assert!(matches!(&msgs[0], ServerMsg::Add { element } if element.id == "a"));
325    }
326
327    #[test]
328    fn reconcile_detects_removals() {
329        let msgs = reconcile(&[make_decl("a", Value::Bool(true))], &[]);
330        assert_eq!(msgs.len(), 1);
331        assert!(matches!(&msgs[0], ServerMsg::Remove { id } if id == "a"));
332    }
333
334    #[test]
335    fn reconcile_detects_updates() {
336        let prev = vec![make_decl("a", Value::Bool(true))];
337        let current = vec![make_decl("a", Value::Bool(false))];
338        let msgs = reconcile(&prev, &current);
339        assert_eq!(msgs.len(), 1);
340        assert!(matches!(&msgs[0], ServerMsg::Update { id, .. } if id == "a"));
341    }
342
343    #[test]
344    fn reconcile_unchanged() {
345        let prev = vec![make_decl("a", Value::Bool(true))];
346        let current = vec![make_decl("a", Value::Bool(true))];
347        assert!(reconcile(&prev, &current).is_empty());
348    }
349
350    #[test]
351    fn reconcile_mixed() {
352        let prev = vec![
353            make_decl("keep", Value::Bool(true)),
354            make_decl("update", Value::Float(1.0)),
355            make_decl("remove", Value::Bool(false)),
356        ];
357        let current = vec![
358            make_decl("keep", Value::Bool(true)),
359            make_decl("update", Value::Float(2.0)),
360            make_decl("add", Value::Bool(true)),
361        ];
362        let msgs = reconcile(&prev, &current);
363        assert_eq!(msgs.len(), 3); // update + remove + add
364    }
365}