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    ws_tx: mpsc::SyncSender<Vec<ServerMsg>>,
39    // Receives browser edits from WS thread (wrapped in Mutex for Sync)
40    edit_rx: Mutex<mpsc::Receiver<(ElementId, Value)>>,
41    // Local cache of pending edits, drained from edit_rx on demand
42    incoming_edits: HashMap<ElementId, Value>,
43    // Signals HTTP thread to shut down
44    shutdown: Arc<AtomicBool>,
45
46    prev_frame: Vec<ElementDecl>,
47    current_frame: Vec<ElementDecl>,
48    http_port: u16,
49    ws_port: u16,
50    _http_handle: JoinHandle<()>,
51    _ws_handle: JoinHandle<()>,
52}
53
54impl Context {
55    /// Create a new wgui context with default options (localhost, port 9080, title "wgui").
56    pub fn new() -> Self {
57        Self::with_options(ContextOptions::default())
58    }
59
60    /// Create a new wgui context starting port search from `start_port`.
61    pub fn with_port(start_port: u16) -> Self {
62        Self::with_options(ContextOptions {
63            start_port,
64            ..Default::default()
65        })
66    }
67
68    /// Create a new wgui context with the given options.
69    pub fn with_options(opts: ContextOptions) -> Self {
70        let bind_addr = if opts.public { "0.0.0.0" } else { "127.0.0.1" };
71        let (http_port, ws_port) = server::find_port_pair(opts.start_port, bind_addr);
72
73        // Create channels for inter-thread communication
74        let (ws_tx, ws_rx) = mpsc::sync_channel::<Vec<ServerMsg>>(2);
75        let (edit_tx, edit_rx) = mpsc::channel::<(ElementId, Value)>();
76        let shutdown = Arc::new(AtomicBool::new(false));
77
78        let http_handle =
79            server::spawn_http(shutdown.clone(), http_port, bind_addr, &opts.title, opts.favicon);
80        let ws_handle = server::spawn_ws(ws_rx, edit_tx, ws_port, bind_addr);
81
82        println!("wgui: UI available at http://{bind_addr}:{http_port}");
83
84        Self {
85            ws_tx,
86            edit_rx: Mutex::new(edit_rx),
87            incoming_edits: HashMap::new(),
88            shutdown,
89            prev_frame: Vec::new(),
90            current_frame: Vec::new(),
91            http_port,
92            ws_port,
93            _http_handle: http_handle,
94            _ws_handle: ws_handle,
95        }
96    }
97
98    /// Returns the HTTP port the UI is served on.
99    pub fn http_port(&self) -> u16 {
100        self.http_port
101    }
102
103    /// Returns the WebSocket port.
104    pub fn ws_port(&self) -> u16 {
105        self.ws_port
106    }
107
108    /// Get or create a named window. Call widget methods on the returned `Window`.
109    pub fn window(&mut self, name: &str) -> Window<'_> {
110        Window::new(name.to_string(), self)
111    }
112
113    /// Consume a pending browser edit for the given element id, if any.
114    pub(crate) fn consume_edit(&mut self, id: &str) -> Option<Value> {
115        // Drain all pending edits from the channel into the local cache
116        let rx = self.edit_rx.lock().unwrap();
117        while let Ok((elem_id, value)) = rx.try_recv() {
118            self.incoming_edits.insert(elem_id, value);
119        }
120        drop(rx);
121        self.incoming_edits.remove(id)
122    }
123
124    /// Record an element declaration for the current frame.
125    pub(crate) fn declare(&mut self, decl: ElementDecl) {
126        self.current_frame.push(decl);
127    }
128
129    /// Finish the current frame: reconcile with previous frame, send diffs over WS.
130    pub fn end_frame(&mut self) {
131        let outgoing = reconcile(&self.prev_frame, &self.current_frame);
132
133        if !outgoing.is_empty() {
134            match self.ws_tx.try_send(outgoing) {
135                Ok(()) => {}
136                Err(mpsc::TrySendError::Full(_)) => {
137                    log::warn!("wgui: WS channel backpressure, skipping frame update");
138                }
139                Err(mpsc::TrySendError::Disconnected(_)) => {
140                    log::warn!("wgui: WS thread disconnected");
141                }
142            }
143        }
144
145        // Swap frames
146        self.prev_frame = std::mem::take(&mut self.current_frame);
147    }
148}
149
150impl Default for Context {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl Drop for Context {
157    fn drop(&mut self) {
158        self.shutdown.store(true, Ordering::Release);
159    }
160}
161
162/// Compare previous and current frames, producing the minimal set of
163/// Add / Update / Remove messages needed to bring a client up to date.
164fn reconcile(prev: &[ElementDecl], current: &[ElementDecl]) -> Vec<ServerMsg> {
165    let mut outgoing = Vec::new();
166
167    // Build index of previous frame for O(1) lookup
168    let prev_index: HashMap<&str, usize> = prev
169        .iter()
170        .enumerate()
171        .map(|(i, d)| (d.id.as_str(), i))
172        .collect();
173
174    // Detect added and updated elements
175    for decl in current {
176        match prev_index.get(decl.id.as_str()) {
177            None => {
178                outgoing.push(ServerMsg::Add {
179                    element: decl.clone(),
180                });
181            }
182            Some(&idx) => {
183                let prev_decl = &prev[idx];
184                let value_changed = prev_decl.value != decl.value || prev_decl.kind != decl.kind || prev_decl.label != decl.label;
185                let meta_changed = prev_decl.meta != decl.meta;
186                let label_changed = prev_decl.label != decl.label;
187                if value_changed || meta_changed || label_changed {
188                    outgoing.push(ServerMsg::Update {
189                        id: decl.id.clone(),
190                        value: decl.value.clone(),
191                        label: if label_changed {
192                            Some(decl.label.clone())
193                        } else {
194                            None
195                        },
196                        meta: if meta_changed {
197                            Some(decl.meta.clone())
198                        } else {
199                            None
200                        },
201                    });
202                }
203            }
204        }
205    }
206
207    // Detect removed elements
208    let current_ids: HashSet<&str> = current.iter().map(|d| d.id.as_str()).collect();
209    for prev_decl in prev {
210        if !current_ids.contains(prev_decl.id.as_str()) {
211            outgoing.push(ServerMsg::Remove {
212                id: prev_decl.id.clone(),
213            });
214        }
215    }
216
217    // Detect reorder: same set of IDs per window, different order
218    // Only emit if no adds/removes happened (pure reorder)
219    let has_structural = outgoing.iter().any(|m| matches!(m, ServerMsg::Add { .. } | ServerMsg::Remove { .. }));
220    if !has_structural && !prev.is_empty() {
221        // Group by window and check order
222        let mut prev_order: HashMap<&str, Vec<&str>> = HashMap::new();
223        let mut curr_order: HashMap<&str, Vec<&str>> = HashMap::new();
224        for d in prev {
225            prev_order.entry(d.window.as_ref()).or_default().push(&d.id);
226        }
227        for d in current {
228            curr_order.entry(d.window.as_ref()).or_default().push(&d.id);
229        }
230        for (win, curr_ids) in &curr_order {
231            if let Some(prev_ids) = prev_order.get(win) {
232                if prev_ids.len() == curr_ids.len() && prev_ids != curr_ids {
233                    outgoing.push(ServerMsg::Reorder {
234                        window: win.to_string(),
235                        ids: curr_ids.iter().map(|s| s.to_string()).collect(),
236                    });
237                }
238            }
239        }
240    }
241
242    outgoing
243}
244
245const _: () = {
246    fn _assert_send_sync<T: Send + Sync>() {}
247    fn _check() { _assert_send_sync::<Context>(); }
248};
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::element::{ElementKind, ElementMeta, Value};
254    use std::sync::Arc;
255
256    fn make_decl(id: &str, value: Value) -> ElementDecl {
257        ElementDecl {
258            id: id.to_string(),
259            kind: ElementKind::Label,
260            label: id.to_string(),
261            value,
262            meta: ElementMeta::default(),
263            window: Arc::from("test"),
264        }
265    }
266
267    #[test]
268    fn reconcile_detects_additions() {
269        let msgs = reconcile(&[], &[make_decl("a", Value::Bool(true))]);
270        assert_eq!(msgs.len(), 1);
271        assert!(matches!(&msgs[0], ServerMsg::Add { element } if element.id == "a"));
272    }
273
274    #[test]
275    fn reconcile_detects_removals() {
276        let msgs = reconcile(&[make_decl("a", Value::Bool(true))], &[]);
277        assert_eq!(msgs.len(), 1);
278        assert!(matches!(&msgs[0], ServerMsg::Remove { id } if id == "a"));
279    }
280
281    #[test]
282    fn reconcile_detects_updates() {
283        let prev = vec![make_decl("a", Value::Bool(true))];
284        let current = vec![make_decl("a", Value::Bool(false))];
285        let msgs = reconcile(&prev, &current);
286        assert_eq!(msgs.len(), 1);
287        assert!(matches!(&msgs[0], ServerMsg::Update { id, .. } if id == "a"));
288    }
289
290    #[test]
291    fn reconcile_unchanged() {
292        let prev = vec![make_decl("a", Value::Bool(true))];
293        let current = vec![make_decl("a", Value::Bool(true))];
294        assert!(reconcile(&prev, &current).is_empty());
295    }
296
297    #[test]
298    fn reconcile_mixed() {
299        let prev = vec![
300            make_decl("keep", Value::Bool(true)),
301            make_decl("update", Value::Float(1.0)),
302            make_decl("remove", Value::Bool(false)),
303        ];
304        let current = vec![
305            make_decl("keep", Value::Bool(true)),
306            make_decl("update", Value::Float(2.0)),
307            make_decl("add", Value::Bool(true)),
308        ];
309        let msgs = reconcile(&prev, &current);
310        assert_eq!(msgs.len(), 3); // update + remove + add
311    }
312}