Skip to main content

ashlar_ui/devtools/
mod.rs

1//! The UI debugger: drive any egui app the way a browser automation tool drives a web page.
2//! Snapshot the UI as an element tree with short refs (`e12`), act on refs (click, fill, press,
3//! drag…), wait for conditions, read values, take screenshots, and an in-app inspector (F12).
4//!
5//! Everything lives in an egui [plugin](egui::Plugin) on the context, so it works in any egui app,
6//! with or without `ashlar-app`'s runtime: the host only passes control requests in.
7//!
8//! - The element tree is egui's own AccessKit output (so plain egui widgets show up with their
9//!   role, label, value and state) merged with ashlar's [`automation`](crate::automation)
10//!   registry (dotted ids and component roles). The first request turns egui's AccessKit output
11//!   on for the rest of the session.
12//! - Refs are stable: the same widget keeps its ref across snapshots while it exists. A ref whose
13//!   element is gone fails with `stale_ref`.
14//! - Input is injected one frame at a time; a request is answered once the frames that carry it
15//!   have run. Waits are checked on frames the app runs anyway, plus one wake at their deadline:
16//!   nothing repaints in a loop.
17//! - The inspector costs nothing while closed: no frames, no allocations.
18//!
19//! The wire protocol is `docs/control-protocol.md` (methods [`METHODS`]).
20//!
21//! # In an app without `ashlar-app`
22//!
23//! Install the plugin once, and hand every request your control dispatcher does not answer
24//! itself to [`handle`] (from any thread). The reply arrives on the request's channel once the
25//! frames it needs have run, so keep your event loop running as usual.
26//!
27//! ```
28//! use ashlar_control::{Reply, Request};
29//! use ashlar_ui::devtools::{self, Outcome};
30//!
31//! let ctx = egui::Context::default();
32//! devtools::install(&ctx);
33//!
34//! // in your control dispatcher:
35//! let (req, reply) = Request::new("ui.snapshot", serde_json::json!({"interactive": true}));
36//! match devtools::handle(&ctx, req) {
37//!     Outcome::Accepted => {}            // answered on a later frame
38//!     Outcome::NotMine(req) => req.unknown(), // or your own methods
39//! }
40//!
41//! // your event loop runs frames; the debugger answers from them
42//! for _ in 0..3 {
43//!     let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
44//!         if ui.button("Save").clicked() { /* … */ }
45//!     });
46//!     out.textures_delta.clear();
47//! }
48//! let Ok(Reply::Ok(snap)) = reply.try_recv() else { panic!("no snapshot") };
49//! assert!(snap["tree"].as_str().unwrap().contains(r#"button "Save""#));
50//! ```
51//!
52//! Optional hooks: [`set_busy`] (your own pending work, for `ui.wait {for: "idle"}`),
53//! [`section`] (extra inspector panels: logs, undo history…), [`set_hotkey`].
54
55pub mod image;
56pub mod input;
57pub mod model;
58mod overlay;
59
60use std::collections::VecDeque;
61use std::sync::Arc;
62use std::sync::mpsc::Receiver;
63use std::time::Duration;
64
65use ashlar_control::{Reply, Request};
66use egui::accesskit::Action;
67use egui::{Event, Key, Pos2, Rect, ViewportCommand};
68use serde_json::{Value, json};
69
70use crate::automation;
71use crate::wake::{self, Reason};
72use input::InputFrame;
73use model::{Model, Outline, Refs, Sources, Target, rect_json};
74
75/// The methods [`handle`] answers.
76pub const METHODS: &[&str] = &[
77    "ui.snapshot",
78    "ui.click",
79    "ui.hover",
80    "ui.move",
81    "ui.focus",
82    "ui.fill",
83    "ui.press",
84    "ui.key",
85    "ui.type",
86    "ui.scroll",
87    "ui.scroll_into_view",
88    "ui.drag",
89    "ui.get",
90    "ui.wait",
91    "ui.screenshot",
92    "ui.highlight",
93    "devtools.toggle",
94    "devtools.state",
95];
96
97/// The methods answered with injected input (`{}` once it has been processed).
98const INPUT_METHODS: &[&str] =
99    &["ui.click", "ui.hover", "ui.move", "ui.focus", "ui.fill", "ui.type", "ui.press", "ui.key", "ui.scroll", "ui.scroll_into_view", "ui.drag"];
100
101/// Frames the inspector and the debugger's timers ask for.
102pub const DEVTOOLS: Reason = Reason("devtools");
103
104/// Most requests waiting for the next frame.
105const MAX_INBOX: usize = 1024;
106/// Most input frames queued at once (a hostile `steps` cannot grow memory without bound).
107const MAX_SCRIPT: usize = 4096;
108/// Most waits and screenshots pending at once.
109const MAX_WAITS: usize = 64;
110const MAX_SHOTS: usize = 8;
111/// A frame that asks for no repaint sooner than this counts as idle.
112const IDLE_AFTER: Duration = Duration::from_millis(200);
113/// A screenshot not delivered within this long fails.
114const SHOT_TIMEOUT_S: f64 = 1.0;
115/// A backend drops a screenshot request when it cannot present a frame (a window in the
116/// background on macOS gets no drawable): ask again this often until it arrives.
117const SHOT_RESEND_S: f64 = 0.25;
118
119/// What [`handle`] did with a request.
120#[derive(Debug)]
121pub enum Outcome {
122    /// It is a debugger method: answered now or on a later frame.
123    Accepted,
124    /// Not a debugger method: handled by the caller.
125    NotMine(Request),
126}
127
128/// Whether [`handle`] answers `method`.
129pub fn handles(method: &str) -> bool {
130    METHODS.contains(&method)
131}
132
133/// Add the debugger (and the automation registry) to a context. Idempotent; [`handle`] does it
134/// too, but installing before the first frame saves one frame on the first request.
135pub fn install(ctx: &egui::Context) {
136    automation::install(ctx);
137    if ctx.with_plugin::<Plugin, _>(|_| ()).is_none() {
138        ctx.add_plugin(Plugin::default());
139    }
140}
141
142fn with<R>(ctx: &egui::Context, f: impl FnOnce(&mut State) -> R) -> Option<R> {
143    install(ctx);
144    let mut f = Some(f);
145    ctx.with_plugin::<Plugin, _>(|p| f.take().map(|f| f(&mut p.st))).flatten()
146}
147
148/// Take a control request: a debugger method is queued for the next frame (which this asks
149/// for) and answered there or later; anything else is handed back. Callable from any thread, but
150/// not from inside a `ctx.input`/`ctx.memory` closure (they hold the context's lock).
151pub fn handle(ctx: &egui::Context, req: Request) -> Outcome {
152    if !handles(req.method()) {
153        return Outcome::NotMine(req);
154    }
155    let refused = with(ctx, |s| {
156        if s.inbox.len() >= MAX_INBOX {
157            return Some(req);
158        }
159        s.inbox.push(req);
160        None
161    });
162    match refused {
163        Some(Some(req)) => req.fail("too many debugger requests waiting; let the app draw a frame"),
164        Some(None) => wake::wake(ctx, Reason::CONTROL),
165        None => {}
166    }
167    Outcome::Accepted
168}
169
170/// [`handle`] with a fresh request: the receiver gets the reply.
171pub fn call(ctx: &egui::Context, method: &str, params: Value) -> Receiver<Reply> {
172    let (req, rx) = Request::new(method, params);
173    if let Outcome::NotMine(req) = handle(ctx, req) {
174        req.unknown();
175    }
176    rx
177}
178
179/// Open or close the inspector.
180pub fn set_open(ctx: &egui::Context, open: bool) {
181    with(ctx, |s| s.overlay.set_open(open));
182    wake::wake(ctx, DEVTOOLS);
183}
184
185/// Whether the inspector is open.
186pub fn is_open(ctx: &egui::Context) -> bool {
187    ctx.with_plugin::<Plugin, _>(|p| p.st.overlay.open).unwrap_or(false)
188}
189
190/// Whether debugger work waits for frames: requests not yet read, injected input still playing,
191/// waits and screenshots. An app whose window is hidden (eframe then runs no UI pass) runs one
192/// itself while this holds, so agents can drive a covered or minimized window.
193pub fn has_pending(ctx: &egui::Context) -> bool {
194    ctx.with_plugin::<Plugin, _>(|p| !p.st.inbox.is_empty() || p.st.script.is_busy() || !p.st.waits.is_empty() || !p.st.shots.is_empty())
195        .unwrap_or(false)
196}
197
198/// Tell `ui.wait {for: "idle"}` whether the app has work of its own pending (background jobs,
199/// a save in flight). Cheap: call it every frame.
200pub fn set_busy(ctx: &egui::Context, busy: bool) {
201    ctx.with_plugin::<Plugin, _>(|p| p.st.busy = busy);
202}
203
204/// The key that opens and closes the inspector (F12 by default; `None`: only
205/// `devtools.toggle` and [`set_open`]).
206pub fn set_hotkey(ctx: &egui::Context, key: Option<Key>) {
207    with(ctx, |s| s.hotkey = key);
208}
209
210/// Show a panel of text lines in the inspector (logs, the undo history…). `lines` runs only while
211/// the inspector is open, so calling this every frame costs nothing when it is closed. Call it
212/// each frame before the frame ends (sections are not kept).
213pub fn section(ctx: &egui::Context, title: &'static str, lines: impl FnOnce() -> Vec<String>) {
214    if !is_open(ctx) {
215        return;
216    }
217    let lines = lines();
218    ctx.with_plugin::<Plugin, _>(|p| {
219        if let Some(s) = p.st.overlay.sections.iter_mut().find(|(t, _)| *t == title) {
220            s.1 = lines;
221        } else if p.st.overlay.sections.len() < 16 {
222            p.st.overlay.sections.push((title, lines));
223        }
224    });
225}
226
227/// One frame for an [`Offscreen`] renderer: what the backend would paint, already tessellated.
228pub struct OffscreenFrame<'a> {
229    pub primitives: &'a [egui::ClippedPrimitive],
230    /// This frame's texture changes (apply `set`; the backend applies them again, and frees).
231    pub textures: &'a egui::TexturesDelta,
232    pub size_in_pixels: [u32; 2],
233    pub pixels_per_point: f32,
234    /// What to clear to (the window background).
235    pub clear: egui::Color32,
236}
237
238/// Renders a frame without presenting it: screenshots then work for a window that is covered,
239/// in the background or on a locked screen. `ashlar-app` installs one for its wgpu renderer.
240pub type Offscreen = Arc<dyn Fn(&OffscreenFrame<'_>) -> Result<egui::ColorImage, String> + Send + Sync>;
241
242/// Render screenshots with `render` instead of asking the backend to capture a presented frame
243/// (`None`: the backend's `ViewportCommand::Screenshot`).
244pub fn set_offscreen(ctx: &egui::Context, render: Option<Offscreen>) {
245    with(ctx, |s| s.offscreen = render);
246}
247
248// ---------------------------------------------------------------------------------------------
249// State
250// ---------------------------------------------------------------------------------------------
251
252/// Matches a screenshot to its request.
253#[derive(Clone, Copy, Debug, PartialEq, Eq)]
254struct ShotToken(u64);
255
256enum ShotStage {
257    /// Paint annotations and ask for the screenshot on the next frame.
258    Paint,
259    /// Painted this frame: render it off screen at the end of the frame.
260    Render,
261    /// Asked at this time; annotations stay painted until the image arrives.
262    Sent(f64),
263    Got(Arc<egui::ColorImage>),
264}
265
266struct Shot {
267    req: Request,
268    token: u64,
269    stage: ShotStage,
270    /// When it was first asked for (egui time).
271    asked: Option<f64>,
272    /// Bring the window to the front when no frame is presented (the request's `raise`).
273    raise: bool,
274    /// Off-screen rendering failed for it: use the backend's capture.
275    offscreen_failed: bool,
276    crop: Option<Rect>,
277    highlight: Option<Rect>,
278    /// (ref, rect) of interactive elements, painted on the image.
279    annotations: Vec<(String, Rect)>,
280}
281
282#[derive(Clone, Debug)]
283enum Cond {
284    Element(Target),
285    Gone(Target),
286    Text { text: String, target: Option<Target> },
287    Enabled(Target),
288    Idle,
289}
290
291struct Wait {
292    req: Request,
293    cond: Cond,
294    start: f64,
295    deadline: f64,
296}
297
298/// Injected input and the requests waiting for it.
299#[derive(Default)]
300struct Script {
301    frames: VecDeque<InputFrame>,
302    injected: u64,
303    queued: u64,
304    waiting: Vec<(u64, Request)>,
305}
306
307impl Script {
308    fn push(&mut self, frames: Vec<InputFrame>, req: Request) {
309        if self.frames.len().saturating_add(frames.len()) > MAX_SCRIPT {
310            return req.fail("too much queued input; wait for earlier ui.* requests to finish");
311        }
312        for f in frames {
313            self.frames.push_back(f);
314            self.queued = self.queued.saturating_add(1);
315        }
316        self.waiting.push((self.queued, req));
317    }
318
319    fn settle(&mut self) {
320        if self.waiting.iter().all(|(mark, _)| *mark > self.injected) {
321            return;
322        }
323        let injected = self.injected;
324        let (done, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.waiting).into_iter().partition(|(mark, _)| *mark <= injected);
325        self.waiting = keep;
326        for (_, req) in done {
327            req.ok(json!({}));
328        }
329    }
330
331    fn is_busy(&self) -> bool {
332        !self.frames.is_empty() || !self.waiting.is_empty()
333    }
334}
335
336struct State {
337    inbox: Vec<Request>,
338    script: Script,
339    waits: Vec<Wait>,
340    shots: Vec<Shot>,
341    shot_seq: u64,
342    /// (rect, until egui time)
343    highlights: Vec<(Rect, f64)>,
344    model: Model,
345    refs: Refs,
346    generation: u64,
347    busy: bool,
348    hotkey: Option<Key>,
349    overlay: overlay::Overlay,
350    offscreen: Option<Offscreen>,
351}
352
353impl Default for State {
354    fn default() -> Self {
355        State {
356            inbox: Vec::new(),
357            script: Script::default(),
358            waits: Vec::new(),
359            shots: Vec::new(),
360            shot_seq: 0,
361            highlights: Vec::new(),
362            model: Model::default(),
363            refs: Refs::default(),
364            generation: 0,
365            busy: false,
366            hotkey: Some(Key::F12),
367            overlay: overlay::Overlay::default(),
368            offscreen: None,
369        }
370    }
371}
372
373#[derive(Default)]
374struct Plugin {
375    st: State,
376}
377
378impl egui::Plugin for Plugin {
379    fn debug_name(&self) -> &'static str {
380        "ashlar_ui::devtools"
381    }
382
383    fn input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
384        self.st.input(ctx, raw);
385    }
386
387    fn on_end_pass(&mut self, ui: &mut egui::Ui) {
388        self.st.paint(ui);
389    }
390
391    fn output_hook(&mut self, ctx: &egui::Context, out: &mut egui::FullOutput) {
392        self.st.output(ctx, out);
393    }
394}
395
396fn param_str<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
397    p.get(k).and_then(Value::as_str)
398}
399
400fn ms(p: &Value, k: &str, default: u64, max: u64) -> u64 {
401    p.get(k).and_then(Value::as_u64).unwrap_or(default).min(max)
402}
403
404impl State {
405    // ------------------------------------------------------------------ frame start
406
407    fn input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
408        // a pass egui runs again (discarded) gets no new input
409        if ctx.output(|o| o.num_completed_passes) != 0 {
410            return;
411        }
412        if let Some(k) = self.hotkey
413            && raw.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, repeat: false, .. } if *key == k))
414        {
415            raw.events.retain(|e| !matches!(e, Event::Key { key, .. } if *key == k));
416            self.overlay.set_open(!self.overlay.open);
417        }
418        if !self.shots.is_empty() {
419            let shots = &mut self.shots;
420            raw.events.retain(|e| {
421                let Event::Screenshot { user_data, image, .. } = e else { return true };
422                let Some(token) = user_data.data.as_ref().and_then(|d| d.downcast_ref::<ShotToken>()) else { return true };
423                if let Some(s) = shots.iter_mut().find(|s| s.token == token.0) {
424                    s.stage = ShotStage::Got(Arc::clone(image));
425                }
426                false
427            });
428        }
429        if self.overlay.open {
430            self.overlay.input(raw);
431        }
432        if let Some(f) = self.script.frames.pop_front() {
433            raw.events.push(Event::ModifiersChanged(f.modifiers));
434            raw.events.extend(f.events);
435            self.script.injected = self.script.injected.saturating_add(1);
436        }
437    }
438
439    // ------------------------------------------------------------------ painting
440
441    fn paint(&mut self, ui: &mut egui::Ui) {
442        if self.highlights.is_empty() && self.shots.is_empty() && !self.overlay.open {
443            return;
444        }
445        let ctx = ui.ctx().clone();
446        let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Debug, egui::Id::new("ashlar.devtools.paint")));
447        let accent = egui::Color32::from_rgb(255, 80, 160);
448        for (r, _) in &self.highlights {
449            painter.rect_stroke(*r, 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
450        }
451        let discard = ctx.will_discard();
452        for s in &mut self.shots {
453            if matches!(s.stage, ShotStage::Got(_)) {
454                continue;
455            }
456            if let Some(r) = s.highlight {
457                painter.rect_stroke(r, 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
458            }
459            let mut placed = Vec::with_capacity(s.annotations.len());
460            for (label, r) in &s.annotations {
461                overlay::tag_among(&painter, *r, label, accent, &mut placed);
462            }
463            let now = ctx.input(|i| i.time);
464            if matches!(s.stage, ShotStage::Paint) && !discard && self.offscreen.is_some() && !s.offscreen_failed {
465                s.stage = ShotStage::Render;
466                continue;
467            }
468            let due = match s.stage {
469                ShotStage::Paint => true,
470                ShotStage::Sent(at) => now - at >= SHOT_RESEND_S,
471                ShotStage::Render | ShotStage::Got(_) => false,
472            };
473            if due && !discard {
474                // asked before and nothing came: the window presents no frames (in the background
475                // or covered); bring it to the front once so it does
476                if matches!(s.stage, ShotStage::Sent(_)) && std::mem::take(&mut s.raise) {
477                    ctx.send_viewport_cmd(ViewportCommand::Visible(true));
478                    ctx.send_viewport_cmd(ViewportCommand::Minimized(false));
479                    ctx.send_viewport_cmd(ViewportCommand::Focus);
480                }
481                ctx.send_viewport_cmd(ViewportCommand::Screenshot(egui::UserData::new(ShotToken(s.token))));
482                s.stage = ShotStage::Sent(now);
483                s.asked.get_or_insert(now);
484            }
485        }
486        if self.overlay.open {
487            overlay::show(ui, self);
488        }
489    }
490
491    // ------------------------------------------------------------------ frame end
492
493    fn output(&mut self, ctx: &egui::Context, out: &egui::FullOutput) {
494        let p = &out.platform_output;
495        let max_passes = ctx.options(|o| o.max_passes.get());
496        if p.requested_discard() && p.num_completed_passes < max_passes {
497            return; // another pass follows
498        }
499        self.script.settle();
500        let busy_work = !self.inbox.is_empty() || !self.waits.is_empty() || !self.shots.is_empty() || !self.highlights.is_empty();
501        if !busy_work && !self.overlay.open && !self.script.is_busy() {
502            return;
503        }
504        let now = ctx.input(|i| i.time);
505        if self.overlay.open {
506            self.overlay.frames = self.overlay.frames.saturating_add(1);
507        }
508        let need_model = !self.inbox.is_empty() || !self.waits.is_empty() || self.overlay.open;
509        let mut fresh = false;
510        if need_model {
511            match &p.accesskit_update {
512                Some(u) => {
513                    self.rebuild(ctx, u);
514                    fresh = true;
515                }
516                // the tree comes with the next frame
517                None => {
518                    ctx.enable_accesskit();
519                    wake::wake(ctx, Reason::CONTROL);
520                }
521            }
522        }
523        if fresh {
524            for req in std::mem::take(&mut self.inbox) {
525                self.answer(ctx, req, now);
526            }
527            let idle = self.is_idle(out);
528            self.check_waits(now, idle);
529        }
530        self.render_offscreen(ctx, out);
531        self.finish_shots(ctx, now);
532        self.highlights.retain(|(_, until)| *until > now);
533        self.schedule(ctx, now);
534    }
535
536    fn is_idle(&self, out: &egui::FullOutput) -> bool {
537        let delay = out.viewport_output.get(&egui::ViewportId::ROOT).map_or(Duration::MAX, |v| v.repaint_delay);
538        delay >= IDLE_AFTER && !self.script.is_busy() && !self.busy && self.shots.is_empty()
539    }
540
541    fn rebuild(&mut self, ctx: &egui::Context, update: &egui::accesskit::TreeUpdate) {
542        let elements = automation::inspect(ctx).elements;
543        let raw: Vec<(u64, egui::LayerId, Rect)> = ctx.viewport(|vp| {
544            vp.prev_pass.widgets.layers().flat_map(|(layer, rects)| rects.iter().map(move |w| (w.id.value(), *layer, w.interact_rect))).collect()
545        });
546        let mut widgets = std::collections::HashMap::with_capacity(raw.len());
547        let mut exclude = std::collections::HashSet::new();
548        let overlay = overlay::window_id();
549        if self.overlay.open {
550            exclude.insert(overlay.value());
551        }
552        for (id, layer, r) in raw {
553            if self.overlay.open && layer.id == overlay {
554                exclude.insert(id);
555                continue;
556            }
557            let r = match ctx.layer_transform_to_global(layer) {
558                Some(t) => t * r,
559                None => r,
560            };
561            widgets.insert(id, r);
562        }
563        let src = Sources {
564            update: Some(update),
565            elements: &elements,
566            widgets: &widgets,
567            screen: ctx.content_rect(),
568            exclude: &exclude,
569            pass: ctx.cumulative_pass_nr(),
570        };
571        self.model = Model::build(&src, &mut self.refs);
572    }
573
574    fn schedule(&self, ctx: &egui::Context, now: f64) {
575        if self.script.is_busy() || !self.inbox.is_empty() {
576            wake::wake(ctx, Reason::CONTROL);
577        }
578        // a backend delivers the image with a later frame's input: keep frames coming until it
579        // has (a frame or two per screenshot)
580        // a screenshot to paint or to ask for again needs a frame; one in flight comes back with
581        // a frame of its own (the backend wakes the app), so only its resend and its deadline do
582        let mut next_shot = f64::INFINITY;
583        for s in &self.shots {
584            match s.stage {
585                ShotStage::Paint | ShotStage::Render | ShotStage::Got(_) => next_shot = now,
586                ShotStage::Sent(at) => next_shot = next_shot.min(at + SHOT_RESEND_S),
587            }
588        }
589        if next_shot <= now {
590            wake::wake(ctx, Reason::SCREENSHOT);
591        } else if next_shot.is_finite() {
592            wake::reschedule(ctx, Reason::SCREENSHOT, Duration::try_from_secs_f64(next_shot - now).unwrap_or_default());
593        } else {
594            wake::Scheduler::of(ctx).cancel(Reason::SCREENSHOT);
595        }
596        let mut next = self.waits.iter().map(|w| w.deadline).chain(self.highlights.iter().map(|h| h.1)).fold(f64::INFINITY, f64::min);
597        if let Some(t) = self.shots.iter().filter_map(|s| s.asked).map(|t| t + SHOT_TIMEOUT_S).reduce(f64::min) {
598            next = next.min(t);
599        }
600        if next.is_finite() {
601            wake::reschedule(ctx, DEVTOOLS, Duration::try_from_secs_f64((next - now).max(0.0)).unwrap_or_default());
602        } else {
603            // the wait or highlight it was for is over: no frame at its old deadline
604            wake::Scheduler::of(ctx).cancel(DEVTOOLS);
605        }
606    }
607
608    // ------------------------------------------------------------------ requests
609
610    fn hit(&self, t: &Target) -> Result<model::Hit, Reply> {
611        self.model.resolve(t, &self.refs, self.generation).map_err(|m| Reply::err_with(m.error, m.detail))
612    }
613
614    /// The target of a request (`params.target` or flat), resolved.
615    fn target(&self, p: &Value, method: &str) -> Result<(Target, model::Hit), Reply> {
616        let t = Target::of(p).ok_or_else(|| Reply::err(format!("{method} needs a target: {{ref}}, {{id}}, {{text, role?}} or {{x, y}}")))?;
617        let h = self.hit(&t)?;
618        Ok((t, h))
619    }
620
621    fn answer(&mut self, ctx: &egui::Context, req: Request, now: f64) {
622        let p = req.params().clone();
623        let method = req.method().to_string();
624        let r = match method.as_str() {
625            "ui.snapshot" => self.snapshot(&p).map(Some),
626            "ui.get" => self.get(&p).map(Some),
627            m if INPUT_METHODS.contains(&m) => match self.input_frames(ctx, &method, &p) {
628                Ok(frames) => return self.script.push(frames, req),
629                Err(e) => Err(e),
630            },
631            "ui.wait" => match self.wait_cond(&p) {
632                Ok(cond) => {
633                    if self.waits.len() >= MAX_WAITS {
634                        return req.fail("too many waits pending");
635                    }
636                    let timeout = ms(&p, "timeout_ms", 5000, 600_000) as f64 / 1000.0;
637                    self.waits.push(Wait { req, cond, start: now, deadline: now + timeout });
638                    return;
639                }
640                Err(e) => Err(e),
641            },
642            "ui.screenshot" => match self.screenshot(ctx, &p) {
643                Ok(shot) => {
644                    if self.shots.len() >= MAX_SHOTS {
645                        return req.fail("too many screenshots pending");
646                    }
647                    self.shot_seq = self.shot_seq.wrapping_add(1);
648                    let token = self.shot_seq;
649                    let (crop, highlight, annotations) = shot;
650                    let raise = p.get("raise").and_then(Value::as_bool).unwrap_or(true);
651                    self.shots.push(Shot {
652                        req,
653                        token,
654                        stage: ShotStage::Paint,
655                        asked: None,
656                        raise,
657                        offscreen_failed: false,
658                        crop,
659                        highlight,
660                        annotations,
661                    });
662                    return;
663                }
664                Err(e) => Err(e),
665            },
666            "ui.highlight" => self.target(&p, "ui.highlight").map(|(_, h)| {
667                let rect = h.node.and_then(|i| self.model.get(i)).map_or(Rect::from_center_size(h.point, egui::vec2(8.0, 8.0)), |n| n.rect);
668                let until = now + ms(&p, "ms", 1500, 60_000).max(50) as f64 / 1000.0;
669                if self.highlights.len() >= 64 {
670                    self.highlights.remove(0);
671                }
672                self.highlights.push((rect, until));
673                Some(json!({"rect": rect_json(rect)}))
674            }),
675            "devtools.toggle" => {
676                let open = p.get("open").and_then(Value::as_bool).unwrap_or(!self.overlay.open);
677                self.overlay.set_open(open);
678                Ok(Some(self.state_json()))
679            }
680            "devtools.state" => Ok(Some(self.state_json())),
681            _ => return req.unknown(),
682        };
683        match r {
684            Ok(Some(v)) => req.ok(v),
685            Ok(None) => req.ok(json!({})),
686            Err(e) => req.reply(e),
687        }
688    }
689
690    fn state_json(&self) -> Value {
691        json!({
692            "open": self.overlay.open,
693            "generation": self.generation,
694            "elements": self.model.nodes.len(),
695            "pending": {"input_frames": self.script.frames.len(), "waits": self.waits.len(), "screenshots": self.shots.len()},
696            "repaints_while_open": self.overlay.frames,
697        })
698    }
699
700    fn snapshot(&mut self, p: &Value) -> Result<Value, Reply> {
701        let scope = match p.get("scope") {
702            Some(s) if !s.is_null() => {
703                let t = Target::parse(s).ok_or_else(|| Reply::err("`scope` is a target: {ref}, {id}, {text} or {x, y}"))?;
704                self.hit(&t)?.node
705            }
706            _ => None,
707        };
708        let opts = Outline {
709            interactive: p.get("interactive").and_then(Value::as_bool).unwrap_or(false),
710            compact: p.get("compact").and_then(Value::as_bool).unwrap_or(false),
711            depth: p.get("depth").and_then(Value::as_u64).map(|d| d.min(1000) as usize),
712            scope,
713        };
714        self.generation = self.generation.saturating_add(1);
715        let (tree, listed) = self.model.outline(&opts);
716        let nodes = &self.model.nodes;
717        let elements: Vec<Value> = listed.iter().filter_map(|i| nodes.get(*i)).map(|n| n.to_json(nodes)).collect();
718        let focused = nodes.iter().find(|n| n.focused).map(|n| n.ref_str());
719        Ok(json!({"generation": self.generation, "tree": tree, "elements": elements, "focused": focused}))
720    }
721
722    fn get(&self, p: &Value) -> Result<Value, Reply> {
723        let (_, h) = self.target(p, "ui.get")?;
724        let what = param_str(p, "what").unwrap_or("text");
725        let Some(n) = h.node.and_then(|i| self.model.get(i)) else {
726            return Err(Reply::err_with("not_found: no element there", json!({"kind": "not_found", "generation": self.generation})));
727        };
728        let value = match what {
729            "text" => json!(n.text()),
730            "name" => json!(n.name),
731            "value" => json!(n.value),
732            "rect" => rect_json(n.rect),
733            "enabled" => json!(n.enabled),
734            "visible" => json!(n.visible),
735            "focused" => json!(n.focused),
736            "checked" => json!(n.checked),
737            "selected" => json!(n.selected),
738            "role" => json!(n.role),
739            "element" => n.to_json(&self.model.nodes),
740            other => {
741                return Err(Reply::err(format!(
742                    "ui.get cannot read `{other}` (text, name, value, rect, enabled, visible, focused, checked, selected, role, element)"
743                )));
744            }
745        };
746        Ok(json!({"value": value}))
747    }
748
749    fn input_frames(&self, ctx: &egui::Context, method: &str, p: &Value) -> Result<Vec<InputFrame>, Reply> {
750        let mods = input::modifiers(p);
751        Ok(match method {
752            "ui.click" => {
753                let (_, h) = self.target(p, method)?;
754                let count = ms(p, "count", 1, 3) as u32;
755                input::click_frames(h.point, input::button(p), count, mods)
756            }
757            "ui.hover" | "ui.move" => input::hover_frames(self.target(p, method)?.1.point, mods),
758            "ui.focus" => self.focus_frames(p, method)?,
759            "ui.fill" => {
760                let text = param_str(p, "text").ok_or_else(|| Reply::err("ui.fill needs `text`"))?;
761                let mut v = if Target::of(p).is_some() { self.focus_frames(p, method)? } else { Vec::new() };
762                v.extend(input::replace_frames(text, p.get("submit").and_then(Value::as_bool).unwrap_or(false)));
763                v
764            }
765            "ui.type" => {
766                let text = param_str(p, "text").ok_or_else(|| Reply::err("ui.type needs `text`"))?;
767                let mut v = if Target::of(p).is_some() { self.focus_frames(p, method)? } else { Vec::new() };
768                v.extend(input::type_frames(text));
769                v
770            }
771            "ui.press" => {
772                let keys = param_str(p, "keys").or_else(|| param_str(p, "key")).unwrap_or("");
773                let mut v = Vec::new();
774                for (k, m) in input::chords(keys).map_err(Reply::err)? {
775                    v.extend(input::key_frames(k, m));
776                }
777                v
778            }
779            "ui.key" => match param_str(p, "key").and_then(input::key) {
780                Some(k) => input::key_frames(k, mods),
781                None => return Err(Reply::err("ui.key needs a `key` egui knows (Enter, Escape, A, F5, ArrowDown…)")),
782            },
783            "ui.scroll" => {
784                let at = match Target::of(p) {
785                    Some(t) => self.hit(&t)?.point,
786                    None => ctx.content_rect().center(),
787                };
788                // the protocol's dy > 0 scrolls down (towards the end); egui's wheel delta is the
789                // other way round
790                input::scroll_frames(at, -input::delta(p), mods)
791            }
792            "ui.scroll_into_view" => {
793                let (_, h) = self.target(p, method)?;
794                match h.node.and_then(|i| self.model.get(i)).and_then(|n| n.egui) {
795                    Some(id) => vec![input::action_frame(id, Action::ScrollIntoView)],
796                    // an element only ashlar knows (a virtualized row) is on screen already
797                    None => Vec::new(),
798                }
799            }
800            "ui.drag" => {
801                let end = |k: &str| -> Result<Pos2, Reply> {
802                    let t = p
803                        .get(k)
804                        .and_then(Target::parse)
805                        .ok_or_else(|| Reply::err(format!("ui.drag needs `{k}`: {{ref}}, {{id}}, {{text}} or {{x, y}}")))?;
806                    Ok(self.hit(&t)?.point)
807                };
808                let steps = ms(p, "steps", 8, 240) as u32;
809                input::drag_frames(end("from")?, end("to")?, steps, mods)
810            }
811            _ => Vec::new(),
812        })
813    }
814
815    /// Give the target the keyboard: AccessKit focus for an egui widget that takes it, otherwise
816    /// a click on it.
817    fn focus_frames(&self, p: &Value, method: &str) -> Result<Vec<InputFrame>, Reply> {
818        let (_, h) = self.target(p, method)?;
819        let node = h.node.and_then(|i| self.model.get(i));
820        let text_field = node.is_some_and(|n| matches!(n.role, "textbox" | "searchbox"));
821        match node.and_then(|n| n.egui) {
822            // a click puts the caret in a text field the way a user would
823            Some(id) if !text_field && method == "ui.focus" => Ok(vec![input::action_frame(id, Action::Focus)]),
824            _ => Ok(input::click_frames(h.point, egui::PointerButton::Primary, 1, egui::Modifiers::NONE)),
825        }
826    }
827
828    fn wait_cond(&self, p: &Value) -> Result<Cond, Reply> {
829        let target = || Target::of(p).ok_or_else(|| Reply::err("this wait needs a `target`"));
830        Ok(match param_str(p, "for").unwrap_or(if p.get("text").is_some() { "text" } else { "element" }) {
831            "element" | "visible" => Cond::Element(target()?),
832            "gone" | "hidden" => Cond::Gone(target()?),
833            "enabled" => Cond::Enabled(target()?),
834            "text" => Cond::Text {
835                text: param_str(p, "text").ok_or_else(|| Reply::err("ui.wait {for: \"text\"} needs `text`"))?.to_string(),
836                target: Target::of(p),
837            },
838            "idle" => Cond::Idle,
839            other => return Err(Reply::err(format!("ui.wait cannot wait for `{other}` (element, gone, text, enabled, idle)"))),
840        })
841    }
842
843    fn met(&self, c: &Cond, idle: bool) -> bool {
844        let node = |t: &Target| self.hit(t).ok().and_then(|h| h.node).and_then(|i| self.model.get(i));
845        match c {
846            Cond::Element(t) => node(t).is_some_and(|n| n.visible),
847            Cond::Gone(t) => node(t).is_none_or(|n| !n.visible),
848            Cond::Enabled(t) => node(t).is_some_and(|n| n.enabled),
849            Cond::Text { text, target: Some(t) } => {
850                node(t).is_some_and(|n| n.name.contains(text.as_str()) || n.value.as_deref().is_some_and(|v| v.contains(text.as_str())))
851            }
852            Cond::Text { text, target: None } => self.model.shows_text(text),
853            Cond::Idle => idle,
854        }
855    }
856
857    fn check_waits(&mut self, now: f64, idle: bool) {
858        if self.waits.is_empty() {
859            return;
860        }
861        let waits = std::mem::take(&mut self.waits);
862        for w in waits {
863            let elapsed_ms = ((now - w.start).max(0.0) * 1000.0).round() as u64;
864            if self.met(&w.cond, idle) {
865                w.req.ok(json!({"elapsed_ms": elapsed_ms}));
866            } else if now >= w.deadline {
867                let what = match &w.cond {
868                    Cond::Element(t) => format!("{} to appear", t.describe()),
869                    Cond::Gone(t) => format!("{} to go away", t.describe()),
870                    Cond::Enabled(t) => format!("{} to be enabled", t.describe()),
871                    Cond::Text { text, .. } => format!("text \"{text}\""),
872                    Cond::Idle => "the app to go idle".into(),
873                };
874                w.req.reply(Reply::err_with(
875                    format!("timeout: waited {elapsed_ms} ms for {what}"),
876                    json!({"kind": "timeout", "elapsed_ms": elapsed_ms}),
877                ));
878            } else {
879                self.waits.push(w);
880            }
881        }
882    }
883
884    #[allow(clippy::type_complexity)]
885    fn screenshot(&self, ctx: &egui::Context, p: &Value) -> Result<(Option<Rect>, Option<Rect>, Vec<(String, Rect)>), Reply> {
886        let pad = p.get("pad").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1000.0) as f32;
887        let crop = match Target::of(p) {
888            Some(t) => {
889                let h = self.hit(&t)?;
890                let r = h.node.and_then(|i| self.model.get(i)).map(|n| n.rect).ok_or_else(|| Reply::err("no element there to crop to"))?;
891                Some(r.expand(pad).intersect(ctx.content_rect()))
892            }
893            None => None,
894        };
895        let highlight = match p.get("highlight") {
896            Some(v) if !v.is_null() => {
897                let t = Target::parse(v).ok_or_else(|| Reply::err("`highlight` is a target"))?;
898                let h = self.hit(&t)?;
899                h.node.and_then(|i| self.model.get(i)).map(|n| n.rect)
900            }
901            _ => None,
902        };
903        let mut annotations = Vec::new();
904        if p.get("annotate").and_then(Value::as_bool).unwrap_or(false) {
905            let area = crop.unwrap_or(ctx.content_rect());
906            for n in &self.model.nodes {
907                if n.interactive && n.visible && area.intersects(n.rect) && annotations.len() < 2000 {
908                    annotations.push((n.ref_str(), n.rect));
909                }
910            }
911        }
912        Ok((crop, highlight, annotations))
913    }
914
915    /// Render the screenshots painted this frame off screen (no presented frame needed).
916    fn render_offscreen(&mut self, ctx: &egui::Context, out: &egui::FullOutput) {
917        let Some(render) = self.offscreen.clone() else { return };
918        if !self.shots.iter().any(|s| matches!(s.stage, ShotStage::Render)) {
919            return;
920        }
921        let ppp = ctx.pixels_per_point();
922        let size = ctx.content_rect().size() * ppp;
923        let primitives = ctx.tessellate(out.shapes.clone(), ppp);
924        let frame = OffscreenFrame {
925            primitives: &primitives,
926            textures: &out.textures_delta,
927            size_in_pixels: [size.x.round().clamp(1.0, 16384.0) as u32, size.y.round().clamp(1.0, 16384.0) as u32],
928            pixels_per_point: ppp,
929            clear: ctx.global_style().visuals.panel_fill,
930        };
931        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render(&frame)))
932            .unwrap_or_else(|_| Err("the off-screen renderer panicked".into()));
933        for s in &mut self.shots {
934            if !matches!(s.stage, ShotStage::Render) {
935                continue;
936            }
937            match &result {
938                Ok(img) => s.stage = ShotStage::Got(Arc::new(img.clone())),
939                Err(e) => {
940                    log::warn!("off-screen screenshot failed, using the window's: {e}");
941                    s.offscreen_failed = true;
942                    s.stage = ShotStage::Paint;
943                }
944            }
945        }
946    }
947
948    fn finish_shots(&mut self, ctx: &egui::Context, now: f64) {
949        if self.shots.is_empty() {
950            return;
951        }
952        let ppp = ctx.pixels_per_point();
953        let mut i = 0;
954        while i < self.shots.len() {
955            let done = match self.shots.get(i).map(|s| &s.stage) {
956                Some(ShotStage::Got(_)) => true,
957                Some(ShotStage::Sent(_)) => self.shots.get(i).and_then(|s| s.asked).is_some_and(|at| now - at > SHOT_TIMEOUT_S),
958                _ => false,
959            };
960            if !done {
961                i += 1;
962                continue;
963            }
964            let s = self.shots.remove(i);
965            let ShotStage::Got(img) = &s.stage else {
966                s.req.fail(format!(
967                    "the window presented no frame for the screenshot within {SHOT_TIMEOUT_S} s: the window is covered, minimized, on another desktop or the screen is locked (the system gives such a window nothing to draw into); show it and try again"
968                ));
969                continue;
970            };
971            match image::encode_png(img, s.crop, ppp) {
972                Ok((png, w, h)) => {
973                    let mut v = json!({"png": image::base64(&png), "width": w, "height": h});
974                    if !s.annotations.is_empty() {
975                        let origin = s.crop.map_or(Pos2::ZERO, |c| c.min);
976                        let list: Vec<Value> = s
977                            .annotations
978                            .iter()
979                            .map(|(r, rect)| {
980                                let px = rect.translate(-origin.to_vec2()) * ppp;
981                                json!({"ref": r, "rect": rect_json(px)})
982                            })
983                            .collect();
984                        if let Some(o) = v.as_object_mut() {
985                            o.insert("annotations".into(), Value::Array(list));
986                        }
987                    }
988                    s.req.ok(v);
989                }
990                Err(e) => s.req.fail(e),
991            }
992        }
993    }
994}
995
996#[cfg(test)]
997mod tests;