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.
200/// Whether a platform AccessKit adapter (a screen reader) can turn AccessKit on for `ctx`
201/// (`true` by default). With `false` (a context with no window, or a build without the adapter)
202/// devtools switches AccessKit off again once it has answered the requests that needed the tree.
203/// With `true` AccessKit stays on once a request turned it on: egui does not say whether a screen
204/// reader turned it on meanwhile, and turning it off under one would leave it a stale tree.
205pub fn set_platform_accesskit(ctx: &egui::Context, possible: bool) {
206    install(ctx);
207    ctx.with_plugin::<Plugin, _>(|p| p.st.platform_accesskit = possible);
208}
209
210pub fn set_busy(ctx: &egui::Context, busy: bool) {
211    ctx.with_plugin::<Plugin, _>(|p| p.st.busy = busy);
212}
213
214/// The key that opens and closes the inspector (F12 by default; `None`: only
215/// `devtools.toggle` and [`set_open`]).
216pub fn set_hotkey(ctx: &egui::Context, key: Option<Key>) {
217    with(ctx, |s| s.hotkey = key);
218}
219
220/// Show a panel of text lines in the inspector (logs, the undo history…). `lines` runs only while
221/// the inspector is open, so calling this every frame costs nothing when it is closed. Call it
222/// each frame before the frame ends (sections are not kept).
223pub fn section(ctx: &egui::Context, title: &'static str, lines: impl FnOnce() -> Vec<String>) {
224    if !is_open(ctx) {
225        return;
226    }
227    let lines = lines();
228    ctx.with_plugin::<Plugin, _>(|p| {
229        if let Some(s) = p.st.overlay.sections.iter_mut().find(|(t, _)| *t == title) {
230            s.1 = lines;
231        } else if p.st.overlay.sections.len() < 16 {
232            p.st.overlay.sections.push((title, lines));
233        }
234    });
235}
236
237/// One frame for an [`Offscreen`] renderer: what the backend would paint, already tessellated.
238pub struct OffscreenFrame<'a> {
239    pub primitives: &'a [egui::ClippedPrimitive],
240    /// This frame's texture changes (apply `set`; the backend applies them again, and frees).
241    pub textures: &'a egui::TexturesDelta,
242    pub size_in_pixels: [u32; 2],
243    pub pixels_per_point: f32,
244    /// What to clear to (the window background).
245    pub clear: egui::Color32,
246}
247
248/// Renders a frame without presenting it: screenshots then work for a window that is covered,
249/// in the background or on a locked screen. `ashlar-app` installs one for its wgpu renderer.
250pub type Offscreen = Arc<dyn Fn(&OffscreenFrame<'_>) -> Result<egui::ColorImage, String> + Send + Sync>;
251
252/// Render screenshots with `render` instead of asking the backend to capture a presented frame
253/// (`None`: the backend's `ViewportCommand::Screenshot`).
254pub fn set_offscreen(ctx: &egui::Context, render: Option<Offscreen>) {
255    with(ctx, |s| s.offscreen = render);
256}
257
258// ---------------------------------------------------------------------------------------------
259// State
260// ---------------------------------------------------------------------------------------------
261
262/// Matches a screenshot to its request.
263#[derive(Clone, Copy, Debug, PartialEq, Eq)]
264struct ShotToken(u64);
265
266enum ShotStage {
267    /// Paint annotations and ask for the screenshot on the next frame.
268    Paint,
269    /// Painted this frame: render it off screen at the end of the frame.
270    Render,
271    /// Asked at this time; annotations stay painted until the image arrives.
272    Sent(f64),
273    Got(Arc<egui::ColorImage>),
274}
275
276struct Shot {
277    req: Request,
278    token: u64,
279    stage: ShotStage,
280    /// When it was first asked for (egui time).
281    asked: Option<f64>,
282    /// Bring the window to the front when no frame is presented (the request's `raise`).
283    raise: bool,
284    /// Off-screen rendering failed for it: use the backend's capture.
285    offscreen_failed: bool,
286    crop: Option<Rect>,
287    highlight: Option<Rect>,
288    /// (ref, rect) of interactive elements, painted on the image.
289    annotations: Vec<(String, Rect)>,
290}
291
292#[derive(Clone, Debug)]
293enum Cond {
294    Element(Target),
295    Gone(Target),
296    Text { text: String, target: Option<Target> },
297    Enabled(Target),
298    Idle,
299}
300
301struct Wait {
302    req: Request,
303    cond: Cond,
304    start: f64,
305    deadline: f64,
306}
307
308/// Injected input and the requests waiting for it.
309#[derive(Default)]
310struct Script {
311    frames: VecDeque<InputFrame>,
312    injected: u64,
313    queued: u64,
314    waiting: Vec<(u64, Request)>,
315}
316
317impl Script {
318    fn push(&mut self, frames: Vec<InputFrame>, req: Request) {
319        if self.frames.len().saturating_add(frames.len()) > MAX_SCRIPT {
320            return req.fail("too much queued input; wait for earlier ui.* requests to finish");
321        }
322        for f in frames {
323            self.frames.push_back(f);
324            self.queued = self.queued.saturating_add(1);
325        }
326        self.waiting.push((self.queued, req));
327    }
328
329    fn settle(&mut self) {
330        if self.waiting.iter().all(|(mark, _)| *mark > self.injected) {
331            return;
332        }
333        let injected = self.injected;
334        let (done, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.waiting).into_iter().partition(|(mark, _)| *mark <= injected);
335        self.waiting = keep;
336        for (_, req) in done {
337            req.ok(json!({}));
338        }
339    }
340
341    fn is_busy(&self) -> bool {
342        !self.frames.is_empty() || !self.waiting.is_empty()
343    }
344}
345
346struct State {
347    inbox: Vec<Request>,
348    script: Script,
349    waits: Vec<Wait>,
350    shots: Vec<Shot>,
351    shot_seq: u64,
352    /// (rect, until egui time)
353    highlights: Vec<(Rect, f64)>,
354    model: Model,
355    refs: Refs,
356    generation: u64,
357    busy: bool,
358    hotkey: Option<Key>,
359    overlay: overlay::Overlay,
360    offscreen: Option<Offscreen>,
361    /// AccessKit was off until a request needed the tree: switch it back off when done, so a
362    /// driven app does not build a screen reader's tree on every later frame
363    accesskit_ours: bool,
364    /// A platform adapter (a screen reader) may turn AccessKit on in this context. egui does not
365    /// say who turned it on, so then devtools never turns it off.
366    platform_accesskit: bool,
367}
368
369impl Default for State {
370    fn default() -> Self {
371        State {
372            inbox: Vec::new(),
373            script: Script::default(),
374            waits: Vec::new(),
375            shots: Vec::new(),
376            shot_seq: 0,
377            highlights: Vec::new(),
378            model: Model::default(),
379            refs: Refs::default(),
380            generation: 0,
381            busy: false,
382            hotkey: Some(Key::F12),
383            overlay: overlay::Overlay::default(),
384            offscreen: None,
385            accesskit_ours: false,
386            platform_accesskit: true,
387        }
388    }
389}
390
391#[derive(Default)]
392struct Plugin {
393    st: State,
394}
395
396impl egui::Plugin for Plugin {
397    fn debug_name(&self) -> &'static str {
398        "ashlar_ui::devtools"
399    }
400
401    fn input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
402        self.st.input(ctx, raw);
403    }
404
405    fn on_end_pass(&mut self, ui: &mut egui::Ui) {
406        self.st.paint(ui);
407    }
408
409    fn output_hook(&mut self, ctx: &egui::Context, out: &mut egui::FullOutput) {
410        self.st.output(ctx, out);
411    }
412}
413
414fn param_str<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
415    p.get(k).and_then(Value::as_str)
416}
417
418fn ms(p: &Value, k: &str, default: u64, max: u64) -> u64 {
419    p.get(k).and_then(Value::as_u64).unwrap_or(default).min(max)
420}
421
422impl State {
423    // ------------------------------------------------------------------ frame start
424
425    fn input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
426        // a pass egui runs again (discarded) gets no new input
427        if ctx.output(|o| o.num_completed_passes) != 0 {
428            return;
429        }
430        if let Some(k) = self.hotkey
431            && raw.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, repeat: false, .. } if *key == k))
432        {
433            raw.events.retain(|e| !matches!(e, Event::Key { key, .. } if *key == k));
434            self.overlay.set_open(!self.overlay.open);
435        }
436        if !self.shots.is_empty() {
437            let shots = &mut self.shots;
438            raw.events.retain(|e| {
439                let Event::Screenshot { user_data, image, .. } = e else { return true };
440                let Some(token) = user_data.data.as_ref().and_then(|d| d.downcast_ref::<ShotToken>()) else { return true };
441                if let Some(s) = shots.iter_mut().find(|s| s.token == token.0) {
442                    s.stage = ShotStage::Got(Arc::clone(image));
443                }
444                false
445            });
446        }
447        if self.overlay.open {
448            self.overlay.input(raw);
449        }
450        if let Some(f) = self.script.frames.pop_front() {
451            raw.events.push(Event::ModifiersChanged(f.modifiers));
452            raw.events.extend(f.events);
453            self.script.injected = self.script.injected.saturating_add(1);
454        }
455    }
456
457    // ------------------------------------------------------------------ painting
458
459    fn paint(&mut self, ui: &mut egui::Ui) {
460        if self.highlights.is_empty() && self.shots.is_empty() && !self.overlay.open {
461            return;
462        }
463        let ctx = ui.ctx().clone();
464        let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Debug, egui::Id::new("ashlar.devtools.paint")));
465        let accent = egui::Color32::from_rgb(255, 80, 160);
466        for (r, _) in &self.highlights {
467            painter.rect_stroke(*r, 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
468        }
469        let discard = ctx.will_discard();
470        for s in &mut self.shots {
471            if matches!(s.stage, ShotStage::Got(_)) {
472                continue;
473            }
474            if let Some(r) = s.highlight {
475                painter.rect_stroke(r, 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
476            }
477            let mut placed = Vec::with_capacity(s.annotations.len());
478            for (label, r) in &s.annotations {
479                overlay::tag_among(&painter, *r, label, accent, &mut placed);
480            }
481            let now = ctx.input(|i| i.time);
482            if matches!(s.stage, ShotStage::Paint) && !discard && self.offscreen.is_some() && !s.offscreen_failed {
483                s.stage = ShotStage::Render;
484                continue;
485            }
486            let due = match s.stage {
487                ShotStage::Paint => true,
488                ShotStage::Sent(at) => now - at >= SHOT_RESEND_S,
489                ShotStage::Render | ShotStage::Got(_) => false,
490            };
491            if due && !discard {
492                // asked before and nothing came: the window presents no frames (in the background
493                // or covered); bring it to the front once so it does
494                if matches!(s.stage, ShotStage::Sent(_)) && std::mem::take(&mut s.raise) {
495                    ctx.send_viewport_cmd(ViewportCommand::Visible(true));
496                    ctx.send_viewport_cmd(ViewportCommand::Minimized(false));
497                    ctx.send_viewport_cmd(ViewportCommand::Focus);
498                }
499                ctx.send_viewport_cmd(ViewportCommand::Screenshot(egui::UserData::new(ShotToken(s.token))));
500                s.stage = ShotStage::Sent(now);
501                s.asked.get_or_insert(now);
502            }
503        }
504        if self.overlay.open {
505            overlay::show(ui, self);
506        }
507    }
508
509    // ------------------------------------------------------------------ frame end
510
511    fn output(&mut self, ctx: &egui::Context, out: &egui::FullOutput) {
512        let p = &out.platform_output;
513        let max_passes = ctx.options(|o| o.max_passes.get());
514        if p.requested_discard() && p.num_completed_passes < max_passes {
515            return; // another pass follows
516        }
517        self.script.settle();
518        self.release_accesskit(ctx);
519        let busy_work = !self.inbox.is_empty() || !self.waits.is_empty() || !self.shots.is_empty() || !self.highlights.is_empty();
520        if !busy_work && !self.overlay.open && !self.script.is_busy() {
521            return;
522        }
523        let now = ctx.input(|i| i.time);
524        if self.overlay.open {
525            self.overlay.frames = self.overlay.frames.saturating_add(1);
526        }
527        let need_model = !self.inbox.is_empty() || !self.waits.is_empty() || self.overlay.open;
528        let mut fresh = false;
529        if need_model {
530            match &p.accesskit_update {
531                Some(u) => {
532                    self.rebuild(ctx, u);
533                    fresh = true;
534                }
535                // the tree comes with the next frame
536                None => {
537                    ctx.enable_accesskit();
538                    self.accesskit_ours = true;
539                    wake::wake(ctx, Reason::CONTROL);
540                }
541            }
542        }
543        if fresh {
544            for req in std::mem::take(&mut self.inbox) {
545                self.answer(ctx, req, now);
546            }
547            let idle = self.is_idle(out);
548            self.check_waits(now, idle);
549            self.release_accesskit(ctx);
550        }
551        self.render_offscreen(ctx, out);
552        self.finish_shots(ctx, now);
553        self.highlights.retain(|(_, until)| *until > now);
554        self.schedule(ctx, now);
555    }
556
557    /// Turn AccessKit off again once nothing needs the tree, if this turned it on and no platform
558    /// adapter can have turned it on meanwhile (see [`set_platform_accesskit`]). The next request
559    /// turns it on for one frame.
560    fn release_accesskit(&mut self, ctx: &egui::Context) {
561        let needed = !self.inbox.is_empty() || !self.waits.is_empty() || self.overlay.open || self.script.is_busy();
562        if self.accesskit_ours && !self.platform_accesskit && !needed {
563            ctx.disable_accesskit();
564            self.accesskit_ours = false;
565        }
566    }
567
568    fn is_idle(&self, out: &egui::FullOutput) -> bool {
569        let delay = out.viewport_output.get(&egui::ViewportId::ROOT).map_or(Duration::MAX, |v| v.repaint_delay);
570        delay >= IDLE_AFTER && !self.script.is_busy() && !self.busy && self.shots.is_empty()
571    }
572
573    fn rebuild(&mut self, ctx: &egui::Context, update: &egui::accesskit::TreeUpdate) {
574        let elements = automation::inspect(ctx).elements;
575        let raw: Vec<(u64, egui::LayerId, Rect)> = ctx.viewport(|vp| {
576            vp.prev_pass.widgets.layers().flat_map(|(layer, rects)| rects.iter().map(move |w| (w.id.value(), *layer, w.interact_rect))).collect()
577        });
578        let mut widgets = std::collections::HashMap::with_capacity(raw.len());
579        let mut exclude = std::collections::HashSet::new();
580        let overlay = overlay::window_id();
581        if self.overlay.open {
582            exclude.insert(overlay.value());
583        }
584        for (id, layer, r) in raw {
585            if self.overlay.open && layer.id == overlay {
586                exclude.insert(id);
587                continue;
588            }
589            let r = match ctx.layer_transform_to_global(layer) {
590                Some(t) => t * r,
591                None => r,
592            };
593            widgets.insert(id, r);
594        }
595        let src = Sources {
596            update: Some(update),
597            elements: &elements,
598            widgets: &widgets,
599            screen: ctx.content_rect(),
600            exclude: &exclude,
601            pass: ctx.cumulative_pass_nr(),
602        };
603        self.model = Model::build(&src, &mut self.refs);
604    }
605
606    fn schedule(&self, ctx: &egui::Context, now: f64) {
607        if self.script.is_busy() || !self.inbox.is_empty() {
608            wake::wake(ctx, Reason::CONTROL);
609        }
610        // a backend delivers the image with a later frame's input: keep frames coming until it
611        // has (a frame or two per screenshot)
612        // a screenshot to paint or to ask for again needs a frame; one in flight comes back with
613        // a frame of its own (the backend wakes the app), so only its resend and its deadline do
614        let mut next_shot = f64::INFINITY;
615        for s in &self.shots {
616            match s.stage {
617                ShotStage::Paint | ShotStage::Render | ShotStage::Got(_) => next_shot = now,
618                ShotStage::Sent(at) => next_shot = next_shot.min(at + SHOT_RESEND_S),
619            }
620        }
621        if next_shot <= now {
622            wake::wake(ctx, Reason::SCREENSHOT);
623        } else if next_shot.is_finite() {
624            wake::reschedule(ctx, Reason::SCREENSHOT, Duration::try_from_secs_f64(next_shot - now).unwrap_or_default());
625        } else {
626            wake::Scheduler::of(ctx).cancel(Reason::SCREENSHOT);
627        }
628        let mut next = self.waits.iter().map(|w| w.deadline).chain(self.highlights.iter().map(|h| h.1)).fold(f64::INFINITY, f64::min);
629        if let Some(t) = self.shots.iter().filter_map(|s| s.asked).map(|t| t + SHOT_TIMEOUT_S).reduce(f64::min) {
630            next = next.min(t);
631        }
632        if next.is_finite() {
633            wake::reschedule(ctx, DEVTOOLS, Duration::try_from_secs_f64((next - now).max(0.0)).unwrap_or_default());
634        } else {
635            // the wait or highlight it was for is over: no frame at its old deadline
636            wake::Scheduler::of(ctx).cancel(DEVTOOLS);
637        }
638    }
639
640    // ------------------------------------------------------------------ requests
641
642    fn hit(&self, t: &Target) -> Result<model::Hit, Reply> {
643        self.model.resolve(t, &self.refs, self.generation).map_err(|m| Reply::err_with(m.error, m.detail))
644    }
645
646    /// The target of a request (`params.target` or flat), resolved.
647    fn target(&self, p: &Value, method: &str) -> Result<(Target, model::Hit), Reply> {
648        let t = Target::of(p).ok_or_else(|| Reply::err(format!("{method} needs a target: {{ref}}, {{id}}, {{text, role?}} or {{x, y}}")))?;
649        let h = self.hit(&t)?;
650        Ok((t, h))
651    }
652
653    fn answer(&mut self, ctx: &egui::Context, req: Request, now: f64) {
654        let p = req.params().clone();
655        let method = req.method().to_string();
656        let r = match method.as_str() {
657            "ui.snapshot" => self.snapshot(&p).map(Some),
658            "ui.get" => self.get(&p).map(Some),
659            m if INPUT_METHODS.contains(&m) => match self.input_frames(ctx, &method, &p) {
660                Ok(frames) => return self.script.push(frames, req),
661                Err(e) => Err(e),
662            },
663            "ui.wait" => match self.wait_cond(&p) {
664                Ok(cond) => {
665                    if self.waits.len() >= MAX_WAITS {
666                        return req.fail("too many waits pending");
667                    }
668                    let timeout = ms(&p, "timeout_ms", 5000, 600_000) as f64 / 1000.0;
669                    self.waits.push(Wait { req, cond, start: now, deadline: now + timeout });
670                    return;
671                }
672                Err(e) => Err(e),
673            },
674            "ui.screenshot" => match self.screenshot(ctx, &p) {
675                Ok(shot) => {
676                    if self.shots.len() >= MAX_SHOTS {
677                        return req.fail("too many screenshots pending");
678                    }
679                    self.shot_seq = self.shot_seq.wrapping_add(1);
680                    let token = self.shot_seq;
681                    let (crop, highlight, annotations) = shot;
682                    let raise = p.get("raise").and_then(Value::as_bool).unwrap_or(true);
683                    self.shots.push(Shot {
684                        req,
685                        token,
686                        stage: ShotStage::Paint,
687                        asked: None,
688                        raise,
689                        offscreen_failed: false,
690                        crop,
691                        highlight,
692                        annotations,
693                    });
694                    return;
695                }
696                Err(e) => Err(e),
697            },
698            "ui.highlight" => self.target(&p, "ui.highlight").map(|(_, h)| {
699                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);
700                let until = now + ms(&p, "ms", 1500, 60_000).max(50) as f64 / 1000.0;
701                if self.highlights.len() >= 64 {
702                    self.highlights.remove(0);
703                }
704                self.highlights.push((rect, until));
705                Some(json!({"rect": rect_json(rect)}))
706            }),
707            "devtools.toggle" => {
708                let open = p.get("open").and_then(Value::as_bool).unwrap_or(!self.overlay.open);
709                self.overlay.set_open(open);
710                Ok(Some(self.state_json()))
711            }
712            "devtools.state" => Ok(Some(self.state_json())),
713            _ => return req.unknown(),
714        };
715        match r {
716            Ok(Some(v)) => req.ok(v),
717            Ok(None) => req.ok(json!({})),
718            Err(e) => req.reply(e),
719        }
720    }
721
722    fn state_json(&self) -> Value {
723        json!({
724            "open": self.overlay.open,
725            "generation": self.generation,
726            "elements": self.model.nodes.len(),
727            "pending": {"input_frames": self.script.frames.len(), "waits": self.waits.len(), "screenshots": self.shots.len()},
728            "repaints_while_open": self.overlay.frames,
729        })
730    }
731
732    fn snapshot(&mut self, p: &Value) -> Result<Value, Reply> {
733        let scope = match p.get("scope") {
734            Some(s) if !s.is_null() => {
735                let t = Target::parse(s).ok_or_else(|| Reply::err("`scope` is a target: {ref}, {id}, {text} or {x, y}"))?;
736                self.hit(&t)?.node
737            }
738            _ => None,
739        };
740        let opts = Outline {
741            interactive: p.get("interactive").and_then(Value::as_bool).unwrap_or(false),
742            compact: p.get("compact").and_then(Value::as_bool).unwrap_or(false),
743            depth: p.get("depth").and_then(Value::as_u64).map(|d| d.min(1000) as usize),
744            scope,
745        };
746        self.generation = self.generation.saturating_add(1);
747        let (tree, listed) = self.model.outline(&opts);
748        let nodes = &self.model.nodes;
749        let elements: Vec<Value> = listed.iter().filter_map(|i| nodes.get(*i)).map(|n| n.to_json(nodes)).collect();
750        let focused = nodes.iter().find(|n| n.focused).map(|n| n.ref_str());
751        Ok(json!({"generation": self.generation, "tree": tree, "elements": elements, "focused": focused}))
752    }
753
754    fn get(&self, p: &Value) -> Result<Value, Reply> {
755        let (_, h) = self.target(p, "ui.get")?;
756        let what = param_str(p, "what").unwrap_or("text");
757        let Some(n) = h.node.and_then(|i| self.model.get(i)) else {
758            return Err(Reply::err_with("not_found: no element there", json!({"kind": "not_found", "generation": self.generation})));
759        };
760        let value = match what {
761            "text" => json!(n.text()),
762            "name" => json!(n.name),
763            "value" => json!(n.value),
764            "rect" => rect_json(n.rect),
765            "enabled" => json!(n.enabled),
766            "visible" => json!(n.visible),
767            "focused" => json!(n.focused),
768            "checked" => json!(n.checked),
769            "selected" => json!(n.selected),
770            "role" => json!(n.role),
771            "element" => n.to_json(&self.model.nodes),
772            other => {
773                return Err(Reply::err(format!(
774                    "ui.get cannot read `{other}` (text, name, value, rect, enabled, visible, focused, checked, selected, role, element)"
775                )));
776            }
777        };
778        Ok(json!({"value": value}))
779    }
780
781    fn input_frames(&self, ctx: &egui::Context, method: &str, p: &Value) -> Result<Vec<InputFrame>, Reply> {
782        let mods = input::modifiers(p);
783        Ok(match method {
784            "ui.click" => {
785                let (_, h) = self.target(p, method)?;
786                let count = ms(p, "count", 1, 3) as u32;
787                input::click_frames(h.point, input::button(p), count, mods)
788            }
789            "ui.hover" | "ui.move" => input::hover_frames(self.target(p, method)?.1.point, mods),
790            "ui.focus" => self.focus_frames(p, method)?,
791            "ui.fill" => {
792                let text = param_str(p, "text").ok_or_else(|| Reply::err("ui.fill needs `text`"))?;
793                let mut v = if Target::of(p).is_some() { self.focus_frames(p, method)? } else { Vec::new() };
794                v.extend(input::replace_frames(text, p.get("submit").and_then(Value::as_bool).unwrap_or(false)));
795                v
796            }
797            "ui.type" => {
798                let text = param_str(p, "text").ok_or_else(|| Reply::err("ui.type needs `text`"))?;
799                let mut v = if Target::of(p).is_some() { self.focus_frames(p, method)? } else { Vec::new() };
800                v.extend(input::type_frames(text));
801                v
802            }
803            "ui.press" => {
804                let keys = param_str(p, "keys").or_else(|| param_str(p, "key")).unwrap_or("");
805                let mut v = Vec::new();
806                for (k, m) in input::chords(keys).map_err(Reply::err)? {
807                    v.extend(input::key_frames(k, m));
808                }
809                v
810            }
811            "ui.key" => match param_str(p, "key").and_then(input::key) {
812                Some(k) => input::key_frames(k, mods),
813                None => return Err(Reply::err("ui.key needs a `key` egui knows (Enter, Escape, A, F5, ArrowDown…)")),
814            },
815            "ui.scroll" => {
816                let at = match Target::of(p) {
817                    Some(t) => self.hit(&t)?.point,
818                    None => ctx.content_rect().center(),
819                };
820                // the protocol's dy > 0 scrolls down (towards the end); egui's wheel delta is the
821                // other way round
822                input::scroll_frames(at, -input::delta(p), mods)
823            }
824            "ui.scroll_into_view" => {
825                let (_, h) = self.target(p, method)?;
826                match h.node.and_then(|i| self.model.get(i)).and_then(|n| n.egui) {
827                    Some(id) => vec![input::action_frame(id, Action::ScrollIntoView)],
828                    // an element only ashlar knows (a virtualized row) is on screen already
829                    None => Vec::new(),
830                }
831            }
832            "ui.drag" => {
833                let end = |k: &str| -> Result<Pos2, Reply> {
834                    let t = p
835                        .get(k)
836                        .and_then(Target::parse)
837                        .ok_or_else(|| Reply::err(format!("ui.drag needs `{k}`: {{ref}}, {{id}}, {{text}} or {{x, y}}")))?;
838                    Ok(self.hit(&t)?.point)
839                };
840                let steps = ms(p, "steps", 8, 240) as u32;
841                input::drag_frames(end("from")?, end("to")?, steps, mods)
842            }
843            _ => Vec::new(),
844        })
845    }
846
847    /// Give the target the keyboard: AccessKit focus for an egui widget that takes it, otherwise
848    /// a click on it. A text field that has the keyboard already is left as it is: clicking it
849    /// again would move the caret, and two requests in a row would make a double click that
850    /// selects a word for the next text to replace.
851    fn focus_frames(&self, p: &Value, method: &str) -> Result<Vec<InputFrame>, Reply> {
852        let (_, h) = self.target(p, method)?;
853        let node = h.node.and_then(|i| self.model.get(i));
854        let text_field = node.is_some_and(|n| matches!(n.role, "textbox" | "searchbox"));
855        if text_field && method != "ui.focus" && node.is_some_and(|n| n.focused) {
856            return Ok(Vec::new());
857        }
858        match node.and_then(|n| n.egui) {
859            // a click puts the caret in a text field the way a user would
860            Some(id) if !text_field && method == "ui.focus" => Ok(vec![input::action_frame(id, Action::Focus)]),
861            _ => Ok(input::click_frames(h.point, egui::PointerButton::Primary, 1, egui::Modifiers::NONE)),
862        }
863    }
864
865    fn wait_cond(&self, p: &Value) -> Result<Cond, Reply> {
866        let target = || Target::of(p).ok_or_else(|| Reply::err("this wait needs a `target`"));
867        Ok(match param_str(p, "for").unwrap_or(if p.get("text").is_some() { "text" } else { "element" }) {
868            "element" | "visible" => Cond::Element(target()?),
869            "gone" | "hidden" => Cond::Gone(target()?),
870            "enabled" => Cond::Enabled(target()?),
871            "text" => Cond::Text {
872                text: param_str(p, "text").ok_or_else(|| Reply::err("ui.wait {for: \"text\"} needs `text`"))?.to_string(),
873                target: Target::of(p),
874            },
875            "idle" => Cond::Idle,
876            other => return Err(Reply::err(format!("ui.wait cannot wait for `{other}` (element, gone, text, enabled, idle)"))),
877        })
878    }
879
880    fn met(&self, c: &Cond, idle: bool) -> bool {
881        let node = |t: &Target| self.hit(t).ok().and_then(|h| h.node).and_then(|i| self.model.get(i));
882        match c {
883            Cond::Element(t) => node(t).is_some_and(|n| n.visible),
884            Cond::Gone(t) => node(t).is_none_or(|n| !n.visible),
885            Cond::Enabled(t) => node(t).is_some_and(|n| n.enabled),
886            Cond::Text { text, target: Some(t) } => {
887                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())))
888            }
889            Cond::Text { text, target: None } => self.model.shows_text(text),
890            Cond::Idle => idle,
891        }
892    }
893
894    fn check_waits(&mut self, now: f64, idle: bool) {
895        if self.waits.is_empty() {
896            return;
897        }
898        let waits = std::mem::take(&mut self.waits);
899        for w in waits {
900            let elapsed_ms = ((now - w.start).max(0.0) * 1000.0).round() as u64;
901            if self.met(&w.cond, idle) {
902                w.req.ok(json!({"elapsed_ms": elapsed_ms}));
903            } else if now >= w.deadline {
904                let what = match &w.cond {
905                    Cond::Element(t) => format!("{} to appear", t.describe()),
906                    Cond::Gone(t) => format!("{} to go away", t.describe()),
907                    Cond::Enabled(t) => format!("{} to be enabled", t.describe()),
908                    Cond::Text { text, .. } => format!("text \"{text}\""),
909                    Cond::Idle => "the app to go idle".into(),
910                };
911                w.req.reply(Reply::err_with(
912                    format!("timeout: waited {elapsed_ms} ms for {what}"),
913                    json!({"kind": "timeout", "elapsed_ms": elapsed_ms}),
914                ));
915            } else {
916                self.waits.push(w);
917            }
918        }
919    }
920
921    #[allow(clippy::type_complexity)]
922    fn screenshot(&self, ctx: &egui::Context, p: &Value) -> Result<(Option<Rect>, Option<Rect>, Vec<(String, Rect)>), Reply> {
923        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;
924        let crop = match Target::of(p) {
925            Some(t) => {
926                let h = self.hit(&t)?;
927                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"))?;
928                Some(r.expand(pad).intersect(ctx.content_rect()))
929            }
930            None => None,
931        };
932        let highlight = match p.get("highlight") {
933            Some(v) if !v.is_null() => {
934                let t = Target::parse(v).ok_or_else(|| Reply::err("`highlight` is a target"))?;
935                let h = self.hit(&t)?;
936                h.node.and_then(|i| self.model.get(i)).map(|n| n.rect)
937            }
938            _ => None,
939        };
940        let mut annotations = Vec::new();
941        if p.get("annotate").and_then(Value::as_bool).unwrap_or(false) {
942            let area = crop.unwrap_or(ctx.content_rect());
943            for n in &self.model.nodes {
944                if n.interactive && n.visible && area.intersects(n.rect) && annotations.len() < 2000 {
945                    annotations.push((n.ref_str(), n.rect));
946                }
947            }
948        }
949        Ok((crop, highlight, annotations))
950    }
951
952    /// Render the screenshots painted this frame off screen (no presented frame needed).
953    fn render_offscreen(&mut self, ctx: &egui::Context, out: &egui::FullOutput) {
954        let Some(render) = self.offscreen.clone() else { return };
955        if !self.shots.iter().any(|s| matches!(s.stage, ShotStage::Render)) {
956            return;
957        }
958        let ppp = ctx.pixels_per_point();
959        let size = ctx.content_rect().size() * ppp;
960        let primitives = ctx.tessellate(out.shapes.clone(), ppp);
961        let frame = OffscreenFrame {
962            primitives: &primitives,
963            textures: &out.textures_delta,
964            size_in_pixels: [size.x.round().clamp(1.0, 16384.0) as u32, size.y.round().clamp(1.0, 16384.0) as u32],
965            pixels_per_point: ppp,
966            clear: ctx.global_style().visuals.panel_fill,
967        };
968        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render(&frame)))
969            .unwrap_or_else(|_| Err("the off-screen renderer panicked".into()));
970        for s in &mut self.shots {
971            if !matches!(s.stage, ShotStage::Render) {
972                continue;
973            }
974            match &result {
975                Ok(img) => s.stage = ShotStage::Got(Arc::new(img.clone())),
976                Err(e) => {
977                    log::warn!("off-screen screenshot failed, using the window's: {e}");
978                    s.offscreen_failed = true;
979                    s.stage = ShotStage::Paint;
980                }
981            }
982        }
983    }
984
985    fn finish_shots(&mut self, ctx: &egui::Context, now: f64) {
986        if self.shots.is_empty() {
987            return;
988        }
989        let ppp = ctx.pixels_per_point();
990        let mut i = 0;
991        while i < self.shots.len() {
992            let done = match self.shots.get(i).map(|s| &s.stage) {
993                Some(ShotStage::Got(_)) => true,
994                Some(ShotStage::Sent(_)) => self.shots.get(i).and_then(|s| s.asked).is_some_and(|at| now - at > SHOT_TIMEOUT_S),
995                _ => false,
996            };
997            if !done {
998                i += 1;
999                continue;
1000            }
1001            let s = self.shots.remove(i);
1002            let ShotStage::Got(img) = &s.stage else {
1003                s.req.fail(format!(
1004                    "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"
1005                ));
1006                continue;
1007            };
1008            match image::encode_png(img, s.crop, ppp) {
1009                Ok((png, w, h)) => {
1010                    let mut v = json!({"png": image::base64(&png), "width": w, "height": h});
1011                    if !s.annotations.is_empty() {
1012                        let origin = s.crop.map_or(Pos2::ZERO, |c| c.min);
1013                        let list: Vec<Value> = s
1014                            .annotations
1015                            .iter()
1016                            .map(|(r, rect)| {
1017                                let px = rect.translate(-origin.to_vec2()) * ppp;
1018                                json!({"ref": r, "rect": rect_json(px)})
1019                            })
1020                            .collect();
1021                        if let Some(o) = v.as_object_mut() {
1022                            o.insert("annotations".into(), Value::Array(list));
1023                        }
1024                    }
1025                    s.req.ok(v);
1026                }
1027                Err(e) => s.req.fail(e),
1028            }
1029        }
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests;