1use 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
30pub 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
80struct 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 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 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 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 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 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#[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, ¶ms), Reply::err("busy"));
732 req.reply(r);
733 }
734}
735
736pub struct Running {
738 pub port: u16,
739 stop: Arc<AtomicBool>,
740 thread: Option<std::thread::JoinHandle<()>>,
741}
742
743impl Running {
744 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
759pub 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 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
786pub 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}