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_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
214pub fn set_hotkey(ctx: &egui::Context, key: Option<Key>) {
217 with(ctx, |s| s.hotkey = key);
218}
219
220pub 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
237pub struct OffscreenFrame<'a> {
239 pub primitives: &'a [egui::ClippedPrimitive],
240 pub textures: &'a egui::TexturesDelta,
242 pub size_in_pixels: [u32; 2],
243 pub pixels_per_point: f32,
244 pub clear: egui::Color32,
246}
247
248pub type Offscreen = Arc<dyn Fn(&OffscreenFrame<'_>) -> Result<egui::ColorImage, String> + Send + Sync>;
251
252pub fn set_offscreen(ctx: &egui::Context, render: Option<Offscreen>) {
255 with(ctx, |s| s.offscreen = render);
256}
257
258#[derive(Clone, Copy, Debug, PartialEq, Eq)]
264struct ShotToken(u64);
265
266enum ShotStage {
267 Paint,
269 Render,
271 Sent(f64),
273 Got(Arc<egui::ColorImage>),
274}
275
276struct Shot {
277 req: Request,
278 token: u64,
279 stage: ShotStage,
280 asked: Option<f64>,
282 raise: bool,
284 offscreen_failed: bool,
286 crop: Option<Rect>,
287 highlight: Option<Rect>,
288 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#[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 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_ours: bool,
364 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 fn input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
426 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 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 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 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; }
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 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 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 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 wake::Scheduler::of(ctx).cancel(DEVTOOLS);
637 }
638 }
639
640 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 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 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 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 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 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 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;