Skip to main content

ashlar_debug/
demo.rs

1//! A tiny app without a window that answers the debugger methods (`ui.snapshot`, `ui.click`, …)
2//! over a real control port, deterministically: `ashlar-debug demo --control <port>`. It is what
3//! the tests drive, and a way to try the CLI without building an app.
4//!
5//! ```text
6//! - window "Demo" [ref=e1]
7//!   - textbox "Title" [ref=e2]
8//!   - button "Save" [ref=e3]               Save: status "Saved: <title>", enables Delete
9//!   - checkbox "Wrap lines" [ref=e4]       toggles
10//!   - button "Delete" [ref=e5] [disabled]  removes the last item
11//!   - button "Load" [ref=e6]               "Loading…", then 1 s later "Loaded" and "Item 4"
12//!   - label "Ready" [ref=e7]               the status line (id `status`)
13//!   - list "Items" [ref=e8]
14//!     - listitem "Item 1" [ref=e9] …
15//! ```
16
17use std::cell::RefCell;
18use std::path::Path;
19use std::rc::Rc;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::mpsc::RecvTimeoutError;
23use std::time::{Duration, Instant};
24
25use ashlar_command::{CommandError, CommandInfo, Dispatch};
26use ashlar_control::{AppInfo, Config, Reply, Request, Server, UiHandler, handle};
27use base64::Engine;
28use serde_json::{Value, json};
29
30/// What the demo answers besides the standard methods.
31pub const METHODS: &[&str] = &[
32    "ui.inspect",
33    "ui.snapshot",
34    "ui.click",
35    "ui.hover",
36    "ui.focus",
37    "ui.fill",
38    "ui.type",
39    "ui.press",
40    "ui.key",
41    "ui.scroll",
42    "ui.scroll_into_view",
43    "ui.drag",
44    "ui.get",
45    "ui.wait",
46    "ui.screenshot",
47    "ui.highlight",
48    "ui.menu.list",
49    "ui.menu.invoke",
50    "devtools.toggle",
51    "devtools.state",
52    "app.quit",
53    "app.stats",
54    "app.logs",
55];
56
57const W: u32 = 360;
58const H: u32 = 260;
59const LOAD_DELAY: Duration = Duration::from_millis(1000);
60
61#[derive(Debug, Clone)]
62struct El {
63    r: u32,
64    id: &'static str,
65    role: &'static str,
66    name: String,
67    value: Option<String>,
68    enabled: bool,
69    checked: Option<bool>,
70    rect: [f32; 4],
71    parent: Option<u32>,
72}
73
74impl El {
75    fn interactive(&self) -> bool {
76        matches!(self.role, "button" | "textbox" | "checkbox" | "listitem")
77    }
78}
79
80/// The demo's state.
81struct Model {
82    els: Vec<El>,
83    next_ref: u32,
84    generation: u64,
85    focused: Option<u32>,
86    hovered: Option<u32>,
87    title: String,
88    load_at: Option<Instant>,
89    devtools: bool,
90    log: Vec<String>,
91    requests: u64,
92    /// The `raise` of the last `ui.screenshot` (null when not given).
93    screenshot_raise: Value,
94    quit: bool,
95}
96
97fn item_rect(i: usize) -> [f32; 4] {
98    [20.0, 114.0 + 22.0 * i as f32, 192.0, 20.0]
99}
100
101impl Model {
102    fn new() -> Self {
103        let el = |r, id, role, name: &str, rect, parent| El {
104            r,
105            id,
106            role,
107            name: name.to_string(),
108            value: None,
109            enabled: true,
110            checked: None,
111            rect,
112            parent,
113        };
114        let mut els = vec![
115            el(1, "window", "window", "Demo", [0.0, 0.0, W as f32, H as f32], None),
116            El { value: Some(String::new()), ..el(2, "title", "textbox", "Title", [16.0, 16.0, 200.0, 24.0], Some(1)) },
117            el(3, "file.save", "button", "Save", [224.0, 16.0, 56.0, 24.0], Some(1)),
118            El { checked: Some(false), ..el(4, "view.wrap", "checkbox", "Wrap lines", [16.0, 52.0, 120.0, 20.0], Some(1)) },
119            El { enabled: false, ..el(5, "edit.delete", "button", "Delete", [144.0, 52.0, 64.0, 24.0], Some(1)) },
120            el(6, "file.load", "button", "Load", [216.0, 52.0, 64.0, 24.0], Some(1)),
121            el(7, "status", "label", "Ready", [16.0, 84.0, 200.0, 18.0], Some(1)),
122            el(8, "items", "list", "Items", [16.0, 110.0, 200.0, 130.0], Some(1)),
123        ];
124        for i in 0..3u32 {
125            els.push(el(9 + i, "", "listitem", &format!("Item {}", i + 1), item_rect(i as usize), Some(8)));
126        }
127        Self {
128            els,
129            next_ref: 12,
130            generation: 0,
131            focused: None,
132            hovered: None,
133            title: String::new(),
134            load_at: None,
135            devtools: false,
136            log: Vec::new(),
137            requests: 0,
138            screenshot_raise: Value::Null,
139            quit: false,
140        }
141    }
142
143    fn note(&mut self, s: String) {
144        self.log.push(s);
145        if self.log.len() > 500 {
146            self.log.remove(0);
147        }
148    }
149
150    /// Time-based changes (the Load button's delayed result).
151    fn tick(&mut self) {
152        if let Some(at) = self.load_at
153            && Instant::now() >= at
154        {
155            self.load_at = None;
156            self.set_status("Loaded");
157            let n = self.els.iter().filter(|e| e.role == "listitem").count();
158            let r = self.next_ref;
159            self.next_ref = self.next_ref.saturating_add(1);
160            self.els.push(El {
161                r,
162                id: "",
163                role: "listitem",
164                name: format!("Item {}", n.saturating_add(1)),
165                value: None,
166                enabled: true,
167                checked: None,
168                rect: item_rect(n),
169                parent: Some(8),
170            });
171            self.note("loaded".into());
172        }
173    }
174
175    fn by_id(&mut self, id: &str) -> Option<&mut El> {
176        self.els.iter_mut().find(|e| e.id == id)
177    }
178
179    fn set_status(&mut self, s: &str) {
180        if let Some(e) = self.by_id("status") {
181            e.name = s.to_string();
182        }
183    }
184
185    fn get(&self, r: u32) -> Option<&El> {
186        self.els.iter().find(|e| e.r == r)
187    }
188
189    /// Resolve a target (`{target: …}` or the target's fields at the top level).
190    fn resolve(&self, p: &Value) -> Result<u32, Reply> {
191        let t = p.get("target").filter(|t| t.is_object()).unwrap_or(p);
192        self.resolve_target(t)
193    }
194
195    fn resolve_target(&self, t: &Value) -> Result<u32, Reply> {
196        let s = |k: &str| t.get(k).and_then(Value::as_str);
197        if let Some(r) = s("ref") {
198            let n = r.strip_prefix('e').and_then(|d| d.parse::<u32>().ok());
199            return n.filter(|n| self.get(*n).is_some()).ok_or_else(|| {
200                Reply::err_with(
201                    format!("stale_ref: no element {r} in snapshot {} (take a new snapshot)", self.generation),
202                    json!({"kind": "stale_ref", "ref": r, "generation": self.generation}),
203                )
204            });
205        }
206        let found = if let Some(id) = s("id") {
207            self.els.iter().find(|e| e.id == id)
208        } else if let Some(text) = s("text") {
209            let role = s("role");
210            self.els.iter().find(|e| e.name == text && role.is_none_or(|r| r == e.role))
211        } else if let (Some(x), Some(y)) = (t.get("x").and_then(Value::as_f64), t.get("y").and_then(Value::as_f64)) {
212            let (x, y) = (x as f32, y as f32);
213            self.els.iter().rev().find(|e| x >= e.rect[0] && y >= e.rect[1] && x < e.rect[0] + e.rect[2] && y < e.rect[1] + e.rect[3])
214        } else {
215            return Err(Reply::err("a target is {ref}, {id}, {text, role?} or {x, y}"));
216        };
217        found.map(|e| e.r).ok_or_else(|| Reply::err_with(format!("not_found: no element matches {t}"), json!({"kind": "not_found"})))
218    }
219
220    fn save(&mut self) {
221        let msg = if self.title.is_empty() { "Saved".to_string() } else { format!("Saved: {}", self.title) };
222        self.set_status(&msg);
223        if let Some(d) = self.by_id("edit.delete") {
224            d.enabled = true;
225        }
226        self.note(msg);
227    }
228
229    /// Activate an element (a click, or Enter on it).
230    fn activate(&mut self, r: u32) -> Result<(), Reply> {
231        let Some(e) = self.get(r).cloned() else { return Err(Reply::err("no such element")) };
232        if !e.enabled {
233            return Err(Reply::err_with(format!("`{}` is disabled", e.name), json!({"kind": "disabled"})));
234        }
235        if e.role == "textbox" {
236            self.focused = Some(r);
237        }
238        match e.id {
239            "file.save" => self.save(),
240            "view.wrap" => {
241                if let Some(c) = self.by_id("view.wrap") {
242                    c.checked = Some(!c.checked.unwrap_or(false));
243                }
244            }
245            "edit.delete" => {
246                if let Some(i) = self.els.iter().rposition(|e| e.role == "listitem") {
247                    self.els.remove(i);
248                }
249                self.set_status("Deleted");
250            }
251            "file.load" => {
252                self.load_at = Some(Instant::now() + LOAD_DELAY);
253                self.set_status("Loading…");
254            }
255            _ => {}
256        }
257        self.note(format!("activated {}", e.name));
258        Ok(())
259    }
260
261    fn element_json(&self, e: &El) -> Value {
262        let mut o = json!({
263            "ref": format!("e{}", e.r),
264            "role": e.role,
265            "name": e.name,
266            "enabled": e.enabled,
267            "focused": self.focused == Some(e.r),
268            "visible": true,
269            "rect": e.rect,
270        });
271        if !e.id.is_empty() {
272            o["id"] = json!(e.id);
273        }
274        if let Some(v) = &e.value {
275            o["value"] = json!(v);
276        }
277        if let Some(c) = e.checked {
278            o["checked"] = json!(c);
279        }
280        if let Some(p) = e.parent {
281            o["parent"] = json!(format!("e{p}"));
282        }
283        o
284    }
285
286    fn depth_of(&self, e: &El) -> usize {
287        let mut d = 0usize;
288        let mut p = e.parent;
289        while let Some(r) = p {
290            d = d.saturating_add(1);
291            p = self.get(r).and_then(|e| e.parent);
292            if d > 64 {
293                break;
294            }
295        }
296        d
297    }
298
299    fn within(&self, e: &El, scope: u32) -> bool {
300        let mut cur = Some(e.r);
301        for _ in 0..64 {
302            match cur {
303                Some(r) if r == scope => return true,
304                Some(r) => cur = self.get(r).and_then(|e| e.parent),
305                None => return false,
306            }
307        }
308        false
309    }
310
311    fn snapshot(&mut self, p: &Value) -> Reply {
312        let interactive = p.get("interactive").and_then(Value::as_bool).unwrap_or(false);
313        let max_depth = p.get("depth").and_then(Value::as_u64).map(|d| d as usize);
314        let scope = match p.get("scope") {
315            Some(t) if t.is_object() => match self.resolve_target(t) {
316                Ok(r) => Some(r),
317                Err(e) => return e,
318            },
319            _ => None,
320        };
321        self.generation = self.generation.saturating_add(1);
322        let base = scope.and_then(|s| self.get(s)).map(|e| self.depth_of(e)).unwrap_or(0);
323        let mut tree = String::new();
324        let mut elements = Vec::new();
325        for e in &self.els {
326            if scope.is_some_and(|s| !self.within(e, s)) || (interactive && !e.interactive()) {
327                continue;
328            }
329            let depth = self.depth_of(e).saturating_sub(base);
330            if max_depth.is_some_and(|m| depth > m) {
331                continue;
332            }
333            let indent = if interactive { 0 } else { depth };
334            let mut line = format!("{}- {} \"{}\" [ref=e{}]", "  ".repeat(indent), e.role, e.name, e.r);
335            if !e.enabled {
336                line.push_str(" [disabled]");
337            }
338            if self.focused == Some(e.r) {
339                line.push_str(" [focused]");
340            }
341            if e.checked == Some(true) {
342                line.push_str(" [checked]");
343            }
344            if let Some(v) = e.value.as_ref().filter(|v| !v.is_empty()) {
345                line.push_str(&format!(" value=\"{v}\""));
346            }
347            tree.push_str(&line);
348            tree.push('\n');
349            elements.push(self.element_json(e));
350        }
351        Reply::ok(json!({"generation": self.generation, "tree": tree, "elements": elements}))
352    }
353
354    fn get_what(&self, r: u32, what: &str) -> Reply {
355        let Some(e) = self.get(r) else { return Reply::err("no such element") };
356        let v = match what {
357            "text" => json!(e.value.clone().unwrap_or_else(|| e.name.clone())),
358            "value" => json!(e.value),
359            "rect" => json!(e.rect),
360            "enabled" => json!(e.enabled),
361            "visible" => json!(true),
362            "focused" => json!(self.focused == Some(r)),
363            "checked" => json!(e.checked.unwrap_or(false)),
364            "name" => json!(e.name),
365            "role" => json!(e.role),
366            "selected" => json!(false),
367            "element" => self.element_json(e),
368            other => {
369                return Reply::err(format!(
370                    "ui.get: unknown `what` `{other}` (text, value, name, role, rect, enabled, visible, focused, checked, selected, element)"
371                ));
372            }
373        };
374        Reply::ok(json!({"value": v}))
375    }
376
377    fn condition(&self, p: &Value) -> Result<bool, Reply> {
378        let what = p.get("for").and_then(Value::as_str).unwrap_or("element");
379        Ok(match what {
380            "idle" => self.load_at.is_none(),
381            "text" => {
382                let Some(t) = p.get("text").and_then(Value::as_str) else { return Err(Reply::err("ui.wait for text needs `text`")) };
383                self.els.iter().any(|e| e.name.contains(t) || e.value.as_deref().is_some_and(|v| v.contains(t)))
384            }
385            "element" | "gone" | "enabled" => {
386                let found = match self.resolve(p) {
387                    Ok(r) => Some(r),
388                    Err(Reply::Err { detail: Some(d), .. }) if d.get("kind").and_then(Value::as_str) == Some("not_found") => None,
389                    // A stale ref also means "not there" for `gone`.
390                    Err(Reply::Err { detail: Some(d), .. }) if what == "gone" && d.get("kind").and_then(Value::as_str) == Some("stale_ref") => None,
391                    Err(e) => return Err(e),
392                };
393                match what {
394                    "element" => found.is_some(),
395                    "gone" => found.is_none(),
396                    _ => found.and_then(|r| self.get(r)).is_some_and(|e| e.enabled),
397                }
398            }
399            other => return Err(Reply::err(format!("ui.wait: unknown `for` `{other}` (element, gone, text, enabled, idle)"))),
400        })
401    }
402
403    fn wait(&mut self, p: &Value) -> Reply {
404        let timeout = Duration::from_millis(p.get("timeout_ms").and_then(Value::as_u64).unwrap_or(5000).min(55_000));
405        let start = Instant::now();
406        loop {
407            self.tick();
408            match self.condition(p) {
409                Err(e) => return e,
410                Ok(true) => return Reply::ok(json!({"elapsed_ms": start.elapsed().as_millis() as u64})),
411                Ok(false) if start.elapsed() >= timeout => {
412                    return Reply::err_with(
413                        format!("timeout: the condition did not hold within {} ms", timeout.as_millis()),
414                        json!({"kind": "timeout", "elapsed_ms": start.elapsed().as_millis() as u64}),
415                    );
416                }
417                Ok(false) => std::thread::sleep(Duration::from_millis(10)),
418            }
419        }
420    }
421
422    fn press(&mut self, keys: &str) -> Reply {
423        let chord = keys.trim();
424        if chord.is_empty() {
425            return Reply::err("ui.press needs `keys`, like Mod+S");
426        }
427        for k in chord.split_whitespace() {
428            match k.to_ascii_lowercase().as_str() {
429                "mod+s" | "cmd+s" | "ctrl+s" => self.save(),
430                "enter" => {
431                    if let Some(r) = self.focused
432                        && let Err(e) = self.activate(r)
433                    {
434                        return e;
435                    }
436                    if self.focused.and_then(|r| self.get(r)).is_some_and(|e| e.role == "textbox") {
437                        self.save();
438                    }
439                }
440                "tab" => {
441                    let order: Vec<u32> = self.els.iter().filter(|e| e.interactive() && e.enabled).map(|e| e.r).collect();
442                    let at = self.focused.and_then(|f| order.iter().position(|r| *r == f));
443                    self.focused = match at {
444                        Some(i) => order.get(i.saturating_add(1)).or(order.first()).copied(),
445                        None => order.first().copied(),
446                    };
447                }
448                _ => {}
449            }
450            self.note(format!("pressed {k}"));
451        }
452        Reply::ok(json!({}))
453    }
454
455    fn screenshot(&mut self, p: &Value) -> Reply {
456        self.screenshot_raise = p.get("raise").cloned().unwrap_or(Value::Null);
457        let mut img = vec![0u8; (W * H * 4) as usize];
458        let fill = |img: &mut Vec<u8>, r: [f32; 4], c: [u8; 4], outline: bool| {
459            let (x0, y0) = (r[0].max(0.0) as u32, r[1].max(0.0) as u32);
460            let (x1, y1) = (((r[0] + r[2]).max(0.0) as u32).min(W), ((r[1] + r[3]).max(0.0) as u32).min(H));
461            for y in y0..y1 {
462                for x in x0..x1 {
463                    if outline && x > x0 && x + 1 < x1 && y > y0 && y + 1 < y1 {
464                        continue;
465                    }
466                    let i = ((y * W + x) * 4) as usize;
467                    if let Some(px) = img.get_mut(i..i + 4) {
468                        px.copy_from_slice(&c);
469                    }
470                }
471            }
472        };
473        for e in &self.els {
474            let c = match e.role {
475                "window" => [245, 245, 245, 255],
476                "textbox" => [255, 255, 255, 255],
477                "button" if e.enabled => [70, 120, 220, 255],
478                "button" => [170, 170, 170, 255],
479                "checkbox" if e.checked == Some(true) => [60, 170, 90, 255],
480                "checkbox" => [200, 200, 200, 255],
481                "list" => [230, 230, 236, 255],
482                _ => [210, 214, 222, 255],
483            };
484            fill(&mut img, e.rect, c, false);
485        }
486        let mut annotations = Vec::new();
487        if p.get("annotate").and_then(Value::as_bool) == Some(true) {
488            for e in self.els.iter().filter(|e| e.interactive()) {
489                fill(&mut img, [e.rect[0], e.rect[1], 8.0, 8.0], [220, 40, 40, 255], false);
490                annotations.push(json!({"ref": format!("e{}", e.r), "rect": e.rect}));
491            }
492        }
493        if let Some(h) = p.get("highlight").filter(|h| h.is_object()) {
494            match self.resolve_target(h) {
495                Ok(r) => {
496                    if let Some(e) = self.get(r) {
497                        fill(&mut img, e.rect, [255, 0, 255, 255], true);
498                    }
499                }
500                Err(e) => return e,
501            }
502        }
503        let pad = p.get("pad").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0) as f32;
504        let crop = match p.get("target").filter(|t| t.is_object()).or(p.get("id").map(|_| p)) {
505            Some(t) => match self.resolve_target(t) {
506                Ok(r) => self.get(r).map(|e| e.rect),
507                Err(e) => return e,
508            },
509            None => None,
510        };
511        let (x0, y0, w, h) = match crop {
512            Some(r) => {
513                let x0 = (r[0] - pad).max(0.0) as u32;
514                let y0 = (r[1] - pad).max(0.0) as u32;
515                let x1 = ((r[0] + r[2] + pad) as u32).min(W);
516                let y1 = ((r[1] + r[3] + pad) as u32).min(H);
517                (x0, y0, x1.saturating_sub(x0).max(1), y1.saturating_sub(y0).max(1))
518            }
519            None => (0, 0, W, H),
520        };
521        let mut out = Vec::with_capacity((w * h * 4) as usize);
522        for y in y0..y0 + h {
523            let i = ((y * W + x0) * 4) as usize;
524            out.extend_from_slice(img.get(i..i + (w * 4) as usize).unwrap_or(&[]));
525        }
526        let mut png_bytes = Vec::new();
527        {
528            let mut enc = png::Encoder::new(&mut png_bytes, w, h);
529            enc.set_color(png::ColorType::Rgba);
530            enc.set_depth(png::BitDepth::Eight);
531            let written = enc.write_header().and_then(|mut wr| wr.write_image_data(&out));
532            if let Err(e) = written {
533                return Reply::err(format!("cannot encode the picture: {e}"));
534            }
535        }
536        let mut v = json!({"png": base64::engine::general_purpose::STANDARD.encode(&png_bytes), "width": w, "height": h});
537        if !annotations.is_empty() {
538            v["annotations"] = json!(annotations);
539        }
540        Reply::ok(v)
541    }
542
543    fn ui(&mut self, method: &str, p: &Value) -> Reply {
544        self.requests = self.requests.saturating_add(1);
545        self.tick();
546        let target = |m: &Self| m.resolve(p);
547        match method {
548            "ui.snapshot" => self.snapshot(p),
549            "ui.inspect" => {
550                let els: Vec<Value> =
551                    self.els.iter().filter(|e| !e.id.is_empty()).map(|e| json!({"id": e.id, "label": e.name, "rect": e.rect})).collect();
552                Reply::ok(json!({"elements": els}))
553            }
554            "ui.click" => match target(self) {
555                Ok(r) => {
556                    let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
557                    for _ in 0..count {
558                        if let Err(e) = self.activate(r) {
559                            return e;
560                        }
561                    }
562                    Reply::ok(json!({}))
563                }
564                Err(e) => e,
565            },
566            "ui.hover" | "ui.focus" | "ui.scroll_into_view" | "ui.highlight" => match target(self) {
567                Ok(r) => {
568                    match method {
569                        "ui.hover" => self.hovered = Some(r),
570                        "ui.focus" => self.focused = Some(r),
571                        _ => {}
572                    }
573                    Reply::ok(json!({}))
574                }
575                Err(e) => e,
576            },
577            "ui.fill" => {
578                let Some(text) = p.get("text").and_then(Value::as_str).map(str::to_string) else { return Reply::err("ui.fill needs `text`") };
579                let r = match target(self) {
580                    Ok(r) => r,
581                    Err(e) => return e,
582                };
583                let Some(e) = self.els.iter_mut().find(|e| e.r == r) else { return Reply::err("no such element") };
584                if e.role != "textbox" {
585                    return Reply::err(format!("`{}` is a {}, not a text field", e.name, e.role));
586                }
587                e.value = Some(text.clone());
588                self.title = text;
589                self.focused = Some(r);
590                if p.get("submit").and_then(Value::as_bool) == Some(true) {
591                    self.save();
592                }
593                Reply::ok(json!({}))
594            }
595            "ui.type" => {
596                let Some(text) = p.get("text").and_then(Value::as_str) else { return Reply::err("ui.type needs `text`") };
597                let Some(f) = self.focused else { return Reply::err("nothing has keyboard focus (click or focus a text field first)") };
598                match self.els.iter_mut().find(|e| e.r == f && e.role == "textbox") {
599                    Some(e) => {
600                        let v = format!("{}{text}", e.value.as_deref().unwrap_or(""));
601                        e.value = Some(v.clone());
602                        self.title = v;
603                        Reply::ok(json!({}))
604                    }
605                    None => Reply::err("the focused element does not take text"),
606                }
607            }
608            "ui.press" => self.press(p.get("keys").and_then(Value::as_str).unwrap_or("")),
609            "ui.key" => self.press(p.get("key").and_then(Value::as_str).unwrap_or("")),
610            "ui.scroll" => match p.get("target").filter(|t| t.is_object()) {
611                Some(t) => self.resolve_target(t).map_or_else(|e| e, |_| Reply::ok(json!({}))),
612                None => Reply::ok(json!({})),
613            },
614            "ui.drag" => {
615                let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return Reply::err("ui.drag needs `from` and `to`") };
616                match (self.resolve_target(from), self.resolve_target(to)) {
617                    (Ok(a), Ok(b)) => {
618                        self.note(format!("dragged e{a} to e{b}"));
619                        Reply::ok(json!({}))
620                    }
621                    (Err(e), _) | (_, Err(e)) => e,
622                }
623            }
624            "ui.get" => match target(self) {
625                Ok(r) => self.get_what(r, p.get("what").and_then(Value::as_str).unwrap_or("text")),
626                Err(e) => e,
627            },
628            "ui.wait" => self.wait(p),
629            "ui.screenshot" => self.screenshot(p),
630            "ui.menu.list" => Reply::ok(json!([
631                {"label": "File", "items": [
632                    {"label": "Save", "id": "file.save", "shortcut": "Mod+S", "enabled": true},
633                    {"label": "Load", "id": "file.load", "enabled": true}]},
634                {"label": "View", "items": [{"label": "Wrap lines", "id": "view.wrap", "enabled": true}]}
635            ])),
636            "ui.menu.invoke" => {
637                let id = p.get("id").and_then(Value::as_str).unwrap_or("");
638                match self.els.iter().find(|e| e.id == id && !id.is_empty()).map(|e| e.r) {
639                    Some(r) => self.activate(r).map_or_else(|e| e, |()| Reply::ok(json!({}))),
640                    None => Reply::err(format!("no menu item `{id}`")),
641                }
642            }
643            "devtools.toggle" => {
644                self.devtools = p.get("open").and_then(Value::as_bool).unwrap_or(!self.devtools);
645                Reply::ok(json!({"open": self.devtools}))
646            }
647            "devtools.state" => Reply::ok(json!({"open": self.devtools})),
648            "app.quit" => {
649                self.quit = true;
650                Reply::ok(json!({"closing": true}))
651            }
652            "app.stats" => Reply::ok(
653                json!({"requests": self.requests, "elements": self.els.len(), "generation": self.generation, "screenshot_raise": self.screenshot_raise}),
654            ),
655            "app.logs" => {
656                let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
657                let lines: Vec<Value> =
658                    self.log.iter().rev().take(limit).rev().map(|m| json!({"level": "INFO", "target": "demo", "message": m})).collect();
659                Reply::ok(json!({"lines": lines}))
660            }
661            _ => Reply::err(format!("unknown method `{method}` (see app.version for the methods this app answers)")),
662        }
663    }
664
665    fn commands(&self) -> Vec<CommandInfo> {
666        let c = |id: &str, label: &str, menu: &str, shortcut: Option<&str>, enabled: bool| CommandInfo {
667            id: id.into(),
668            label: label.into(),
669            menu: vec![menu.into()],
670            shortcut: shortcut.map(String::from),
671            params_schema: json!({"type": "object"}),
672            params_text: None,
673            enabled,
674            disabled_reason: (!enabled).then(|| "nothing saved yet".to_string()),
675            mutates: true,
676        };
677        let delete = self.els.iter().any(|e| e.id == "edit.delete" && e.enabled);
678        vec![
679            c("file.save", "Save", "File", Some("Mod+S"), true),
680            c("file.load", "Load", "File", None, true),
681            c("view.wrap", "Wrap lines", "View", None, true),
682            c("edit.delete", "Delete", "Edit", None, delete),
683        ]
684    }
685}
686
687/// The model shared by the two halves `handle` needs (commands and UI), on the app thread only.
688#[derive(Clone)]
689struct Shared(Rc<RefCell<Model>>);
690
691impl Shared {
692    fn with<T>(&self, f: impl FnOnce(&mut Model) -> T, busy: T) -> T {
693        match self.0.try_borrow_mut() {
694            Ok(mut m) => f(&mut m),
695            Err(_) => busy,
696        }
697    }
698}
699
700impl Dispatch for Shared {
701    fn commands(&self) -> Vec<CommandInfo> {
702        self.0.try_borrow().map(|m| m.commands()).unwrap_or_default()
703    }
704
705    fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
706        let _ = params;
707        let busy = Err(CommandError::Failed { id: id.into(), msg: "busy".into() });
708        self.with(
709            |m| {
710                m.tick();
711                let Some(r) = m.els.iter().find(|e| e.id == id).map(|e| e.r).filter(|_| m.commands().iter().any(|c| c.id == id)) else {
712                    return Err(CommandError::Unknown { id: id.into() });
713                };
714                m.activate(r).map(|()| json!({"status": m.els.iter().find(|e| e.id == "status").map(|e| e.name.clone())})).map_err(|e| match e {
715                    Reply::Err { error, .. } => CommandError::Disabled { id: id.into(), reason: error },
716                    Reply::Ok(_) => CommandError::Failed { id: id.into(), msg: "?".into() },
717                })
718            },
719            busy,
720        )
721    }
722}
723
724impl UiHandler for Shared {
725    fn methods(&self) -> Vec<String> {
726        METHODS.iter().map(|m| m.to_string()).collect()
727    }
728
729    fn handle(&mut self, req: Request) {
730        let (method, params) = (req.method().to_string(), req.params().clone());
731        let r = self.with(|m| m.ui(&method, &params), Reply::err("busy"));
732        req.reply(r);
733    }
734}
735
736/// A running demo app (for tests): its port and key folder. Dropping it stops the app.
737pub struct Running {
738    pub port: u16,
739    stop: Arc<AtomicBool>,
740    thread: Option<std::thread::JoinHandle<()>>,
741}
742
743impl Running {
744    /// Whether the app has quit (`app.quit`).
745    pub fn finished(&self) -> bool {
746        self.thread.as_ref().is_none_or(|t| t.is_finished())
747    }
748}
749
750impl Drop for Running {
751    fn drop(&mut self) {
752        self.stop.store(true, Ordering::Relaxed);
753        if let Some(t) = self.thread.take() {
754            let _ = t.join();
755        }
756    }
757}
758
759/// Serve the demo on `port` (0 picks one) with its key in `key_dir`, on a thread of its own.
760pub fn start(port: u16, key_dir: &Path) -> Result<Running, String> {
761    let (server, rx) = Server::start(Config::new(port, key_dir), || {}).map_err(|e| e.to_string())?;
762    let port = server.port();
763    let stop = Arc::new(AtomicBool::new(false));
764    let stop2 = Arc::clone(&stop);
765    let thread = std::thread::Builder::new()
766        .name("ashlar-debug-demo".into())
767        .spawn(move || {
768            let shared = Shared(Rc::new(RefCell::new(Model::new())));
769            let (mut d, mut ui) = (shared.clone(), shared.clone());
770            let info = AppInfo::new("demo", env!("CARGO_PKG_VERSION"));
771            while !stop2.load(Ordering::Relaxed) && !shared.with(|m| m.quit, true) {
772                match rx.recv_timeout(Duration::from_millis(50)) {
773                    Ok(req) => handle(req, &mut d, &mut ui, &info),
774                    Err(RecvTimeoutError::Timeout) => {}
775                    Err(RecvTimeoutError::Disconnected) => break,
776                }
777            }
778            // Let the quit reply reach the client before the port closes.
779            std::thread::sleep(Duration::from_millis(50));
780            drop(server);
781        })
782        .map_err(|e| e.to_string())?;
783    Ok(Running { port, stop, thread: Some(thread) })
784}
785
786/// `ashlar-debug demo [--control PORT]`: serve until `app.quit`. The key goes to
787/// `ASHLAR_CONTROL_KEY_DIR`, or a folder of its own in the temporary folder.
788pub fn run(port: Option<u16>, key_dir: Option<&Path>, out: &mut dyn std::io::Write) -> Result<(), String> {
789    let fallback = std::env::temp_dir().join(format!("ashlar-debug-demo-{}", std::process::id()));
790    let key_dir = key_dir.unwrap_or(&fallback);
791    let mut running = start(port.unwrap_or(0), key_dir)?;
792    let _ = writeln!(out, "demo: control port 127.0.0.1:{}, key folder {}", running.port, key_dir.display());
793    let _ = out.flush();
794    if let Some(t) = running.thread.take() {
795        let _ = t.join();
796    }
797    Ok(())
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    #[test]
805    fn the_demo_answers_the_whole_debugger_vocabulary() {
806        for m in ashlar_control::DEBUG_METHODS {
807            assert!(METHODS.contains(m), "{m}");
808        }
809    }
810}