1pub 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
75pub 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
97const 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
101pub const DEVTOOLS: Reason = Reason("devtools");
103
104const MAX_INBOX: usize = 1024;
106const MAX_SCRIPT: usize = 4096;
108const MAX_WAITS: usize = 64;
110const MAX_SHOTS: usize = 8;
111const IDLE_AFTER: Duration = Duration::from_millis(200);
113const SHOT_TIMEOUT_S: f64 = 1.0;
115const SHOT_RESEND_S: f64 = 0.25;
118
119#[derive(Debug)]
121pub enum Outcome {
122 Accepted,
124 NotMine(Request),
126}
127
128pub fn handles(method: &str) -> bool {
130 METHODS.contains(&method)
131}
132
133pub 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
148pub 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
170pub 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
179pub fn set_open(ctx: &egui::Context, open: bool) {
181 with(ctx, |s| s.overlay.set_open(open));
182 wake::wake(ctx, DEVTOOLS);
183}
184
185pub fn is_open(ctx: &egui::Context) -> bool {
187 ctx.with_plugin::<Plugin, _>(|p| p.st.overlay.open).unwrap_or(false)
188}
189
190pub 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
198pub fn set_busy(ctx: &egui::Context, busy: bool) {
201 ctx.with_plugin::<Plugin, _>(|p| p.st.busy = busy);
202}
203
204pub fn set_hotkey(ctx: &egui::Context, key: Option<Key>) {
207 with(ctx, |s| s.hotkey = key);
208}
209
210pub 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
227pub struct OffscreenFrame<'a> {
229 pub primitives: &'a [egui::ClippedPrimitive],
230 pub textures: &'a egui::TexturesDelta,
232 pub size_in_pixels: [u32; 2],
233 pub pixels_per_point: f32,
234 pub clear: egui::Color32,
236}
237
238pub type Offscreen = Arc<dyn Fn(&OffscreenFrame<'_>) -> Result<egui::ColorImage, String> + Send + Sync>;
241
242pub fn set_offscreen(ctx: &egui::Context, render: Option<Offscreen>) {
245 with(ctx, |s| s.offscreen = render);
246}
247
248#[derive(Clone, Copy, Debug, PartialEq, Eq)]
254struct ShotToken(u64);
255
256enum ShotStage {
257 Paint,
259 Render,
261 Sent(f64),
263 Got(Arc<egui::ColorImage>),
264}
265
266struct Shot {
267 req: Request,
268 token: u64,
269 stage: ShotStage,
270 asked: Option<f64>,
272 raise: bool,
274 offscreen_failed: bool,
276 crop: Option<Rect>,
277 highlight: Option<Rect>,
278 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#[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 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 fn input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
408 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 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 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 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; }
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 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 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 wake::Scheduler::of(ctx).cancel(DEVTOOLS);
605 }
606 }
607
608 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 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 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 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 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 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 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;