1use crate::robot_assertions::{Bounds, SemanticElementLike};
7use cranpose::SemanticElement;
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13impl SemanticElementLike for SemanticElement {
16 fn text(&self) -> Option<&str> {
17 self.text.as_deref()
18 }
19
20 fn role(&self) -> &str {
21 &self.role
22 }
23
24 fn clickable(&self) -> bool {
25 self.clickable
26 }
27
28 fn bounds(&self) -> Bounds {
29 Bounds {
30 x: self.bounds.x,
31 y: self.bounds.y,
32 width: self.bounds.width,
33 height: self.bounds.height,
34 }
35 }
36
37 fn children(&self) -> &[Self] {
38 &self.children
39 }
40}
41
42pub fn find_text(elem: &SemanticElement, text: &str) -> Option<(f32, f32, f32, f32)> {
49 if let Some(ref t) = elem.text {
50 if t.contains(text) {
51 return Some((
52 elem.bounds.x,
53 elem.bounds.y,
54 elem.bounds.width,
55 elem.bounds.height,
56 ));
57 }
58 }
59 for child in &elem.children {
60 if let Some(pos) = find_text(child, text) {
61 return Some(pos);
62 }
63 }
64 None
65}
66
67pub fn find_text_exact(elem: &SemanticElement, text: &str) -> Option<(f32, f32, f32, f32)> {
71 if let Some(ref t) = elem.text {
72 if t == text {
73 return Some((
74 elem.bounds.x,
75 elem.bounds.y,
76 elem.bounds.width,
77 elem.bounds.height,
78 ));
79 }
80 }
81 for child in &elem.children {
82 if let Some(pos) = find_text_exact(child, text) {
83 return Some(pos);
84 }
85 }
86 None
87}
88
89pub fn find_text_center(elem: &SemanticElement, text: &str) -> Option<(f32, f32)> {
93 find_text(elem, text).map(|(x, y, w, h)| (x + w / 2.0, y + h / 2.0))
94}
95
96pub fn has_text(elem: &SemanticElement, text: &str) -> bool {
98 has_text_by(elem, text, text_contains)
99}
100
101pub fn find_button(elem: &SemanticElement, text: &str) -> Option<(f32, f32, f32, f32)> {
106 find_button_by(elem, text, text_contains)
107}
108
109pub fn find_button_center(elem: &SemanticElement, text: &str) -> Option<(f32, f32)> {
112 find_button(elem, text).map(|(x, y, w, h)| (x + w / 2.0, y + h / 2.0))
113}
114
115pub fn find_in_semantics<F>(robot: &cranpose::Robot, finder: F) -> Option<(f32, f32, f32, f32)>
123where
124 F: Fn(&SemanticElement) -> Option<(f32, f32, f32, f32)>,
125{
126 match robot.get_semantics() {
127 Ok(semantics) => {
128 for root in semantics.iter() {
129 if let Some(result) = finder(root) {
130 return Some(result);
131 }
132 }
133 None
134 }
135 Err(e) => {
136 eprintln!(" ✗ Failed to get semantics: {}", e);
137 None
138 }
139 }
140}
141
142pub fn find_text_in_semantics(robot: &cranpose::Robot, text: &str) -> Option<(f32, f32, f32, f32)> {
147 match robot.find_text_bounds(text) {
148 Ok(bounds) => bounds,
149 Err(e) => {
150 eprintln!(" ✗ Failed to query text semantics: {}", e);
151 None
152 }
153 }
154}
155
156pub fn find_text_by_prefix(
159 elem: &SemanticElement,
160 prefix: &str,
161) -> Option<(f32, f32, f32, f32, String)> {
162 if let Some(ref t) = elem.text {
163 if t.starts_with(prefix) {
164 return Some((
165 elem.bounds.x,
166 elem.bounds.y,
167 elem.bounds.width,
168 elem.bounds.height,
169 t.clone(),
170 ));
171 }
172 }
173 for child in &elem.children {
174 if let Some(result) = find_text_by_prefix(child, prefix) {
175 return Some(result);
176 }
177 }
178 None
179}
180
181pub fn find_text_by_prefix_in_semantics(
185 robot: &cranpose::Robot,
186 prefix: &str,
187) -> Option<(f32, f32, f32, f32, String)> {
188 match robot.find_text_by_prefix(prefix) {
189 Ok(bounds) => bounds,
190 Err(e) => {
191 eprintln!(" ✗ Failed to query text prefix semantics: {}", e);
192 None
193 }
194 }
195}
196
197pub fn find_button_in_semantics(
200 robot: &cranpose::Robot,
201 text: &str,
202) -> Option<(f32, f32, f32, f32)> {
203 find_button_in_semantics_by(robot, text, TextMatchMode::Contains)
204}
205
206pub fn find_button_exact_in_semantics(
208 robot: &cranpose::Robot,
209 text: &str,
210) -> Option<(f32, f32, f32, f32)> {
211 find_button_in_semantics_by(robot, text, TextMatchMode::Exact)
212}
213
214fn find_button_in_semantics_by(
215 robot: &cranpose::Robot,
216 text: &str,
217 match_mode: TextMatchMode,
218) -> Option<(f32, f32, f32, f32)> {
219 let text_owned = text.to_string();
220 let mut bounds = find_button_bounds_for_mode(robot, &text_owned, match_mode);
221
222 let Some(root) = root_bounds(robot) else {
223 return bounds;
224 };
225
226 if let Some(current) = bounds {
227 if is_fully_visible(current, root) {
228 return bounds;
229 }
230 } else {
231 return None;
232 }
233
234 for _ in 0..8 {
235 let Some(current) = bounds else {
236 break;
237 };
238
239 let Some((axis, dir)) = overflow_axis_direction(current, root) else {
240 break;
241 };
242
243 let semantics = match robot.get_semantics() {
244 Ok(semantics) => semantics,
245 Err(e) => {
246 eprintln!(" ✗ Failed to get semantics: {}", e);
247 break;
248 }
249 };
250
251 let Some((sx, sy, sw, sh)) = find_scroll_anchor(&semantics, current, root, axis) else {
252 break;
253 };
254
255 let start_x = sx + sw / 2.0;
256 let start_y = sy + sh / 2.0;
257 let (end_x, end_y) = match axis {
258 TabAxis::Horizontal => (start_x + dir * SCROLL_STEP, start_y),
259 TabAxis::Vertical => (start_x, start_y + dir * SCROLL_STEP),
260 };
261 let _ = robot.drag(start_x, start_y, end_x, end_y);
262 std::thread::sleep(Duration::from_millis(SCROLL_SETTLE_MS));
263 let _ = robot.wait_for_idle();
264
265 bounds = find_button_bounds_for_mode(robot, &text_owned, match_mode);
266 if let Some(current) = bounds {
267 if is_fully_visible(current, root) {
268 break;
269 }
270 }
271 }
272
273 bounds
274}
275
276pub fn find_by_text_recursive(elements: &[SemanticElement], text: &str) -> Option<SemanticElement> {
279 for elem in elements {
280 if let Some(ref elem_text) = elem.text {
281 if elem_text.contains(text) {
282 return Some(elem.clone());
283 }
284 }
285 if let Some(found) = find_by_text_recursive(&elem.children, text) {
286 return Some(found);
287 }
288 }
289 None
290}
291
292pub fn find_clickables_in_range(
295 elements: &[SemanticElement],
296 min_y: f32,
297 max_y: f32,
298) -> Vec<(String, f32, f32)> {
299 fn search(elem: &SemanticElement, tabs: &mut Vec<(String, f32, f32)>, min_y: f32, max_y: f32) {
300 if elem.role == "Layout" && elem.clickable && elem.bounds.y > min_y && elem.bounds.y < max_y
301 {
302 let label = elem
303 .children
304 .iter()
305 .find(|child| child.role == "Text")
306 .and_then(|text_elem| text_elem.text.clone())
307 .unwrap_or_else(|| "Unknown".to_string());
308 tabs.push((label, elem.bounds.x, elem.bounds.y));
309 }
310 for child in &elem.children {
311 search(child, tabs, min_y, max_y);
312 }
313 }
314
315 let mut tabs = Vec::new();
316 for elem in elements {
317 search(elem, &mut tabs, min_y, max_y);
318 }
319 tabs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
320 tabs
321}
322
323pub fn find_element_by_text_exact<'a>(
324 elements: &'a [SemanticElement],
325 text: &str,
326) -> Option<&'a SemanticElement> {
327 for elem in elements {
328 if elem.text.as_deref() == Some(text) {
329 return Some(elem);
330 }
331 if let Some(found) = find_element_by_text_exact(&elem.children, text) {
332 return Some(found);
333 }
334 }
335 None
336}
337
338pub fn find_bounds_by_text(robot: &cranpose::Robot, text: &str) -> Option<(f32, f32, f32, f32)> {
339 let semantics = robot.get_semantics().ok()?;
340 let elem = find_element_by_text_exact(&semantics, text)?;
341 Some((
342 elem.bounds.x,
343 elem.bounds.y,
344 elem.bounds.width,
345 elem.bounds.height,
346 ))
347}
348
349pub fn visible_bounds_in_viewport(
350 robot: &cranpose::Robot,
351 bounds: (f32, f32, f32, f32),
352 padding: f32,
353) -> Option<(f32, f32, f32, f32)> {
354 let semantics = robot.get_semantics().ok()?;
355 let mut viewport = None;
356 for elem in semantics.iter() {
357 let elem_bounds = (
358 elem.bounds.x,
359 elem.bounds.y,
360 elem.bounds.width,
361 elem.bounds.height,
362 );
363 viewport = Some(match viewport {
364 Some(existing) => union_bounds(existing, Some(elem_bounds)),
365 None => elem_bounds,
366 });
367 }
368 let (viewport_x, viewport_y, viewport_width, viewport_height) = viewport?;
369 let min_x = viewport_x + padding;
370 let min_y = viewport_y + padding;
371 let max_x = viewport_x + viewport_width - padding;
372 let max_y = viewport_y + viewport_height - padding;
373
374 let left = bounds.0.max(min_x);
375 let top = bounds.1.max(min_y);
376 let right = (bounds.0 + bounds.2).min(max_x);
377 let bottom = (bounds.1 + bounds.3).min(max_y);
378
379 if right <= left || bottom <= top {
380 None
381 } else {
382 Some((left, top, right - left, bottom - top))
383 }
384}
385
386pub fn find_center_by_text(robot: &cranpose::Robot, text: &str) -> Option<(f32, f32)> {
387 let (x, y, w, h) = find_bounds_by_text(robot, text)?;
388 Some((x + w / 2.0, y + h / 2.0))
389}
390
391pub fn find_in_subtree_by_text<'a>(
392 elem: &'a SemanticElement,
393 text: &str,
394) -> Option<&'a SemanticElement> {
395 if elem.text.as_deref() == Some(text) {
396 return Some(elem);
397 }
398 for child in &elem.children {
399 if let Some(found) = find_in_subtree_by_text(child, text) {
400 return Some(found);
401 }
402 }
403 None
404}
405
406pub fn print_semantics_with_bounds(elements: &[SemanticElement], indent: usize) {
407 for elem in elements {
408 let prefix = " ".repeat(indent);
409 let text = elem.text.as_deref().unwrap_or("");
410 println!(
411 "{}role={} text=\"{}\" bounds=({:.1},{:.1},{:.1},{:.1}){}",
412 prefix,
413 elem.role,
414 text,
415 elem.bounds.x,
416 elem.bounds.y,
417 elem.bounds.width,
418 elem.bounds.height,
419 if elem.clickable { " [CLICKABLE]" } else { "" }
420 );
421 print_semantics_with_bounds(&elem.children, indent + 1);
422 }
423}
424
425pub fn union_bounds(
426 base: (f32, f32, f32, f32),
427 other: Option<(f32, f32, f32, f32)>,
428) -> (f32, f32, f32, f32) {
429 let (x, y, w, h) = base;
430 let mut min_x = x;
431 let mut min_y = y;
432 let mut max_x = x + w;
433 let mut max_y = y + h;
434 if let Some((ox, oy, ow, oh)) = other {
435 min_x = min_x.min(ox);
436 min_y = min_y.min(oy);
437 max_x = max_x.max(ox + ow);
438 max_y = max_y.max(oy + oh);
439 }
440 (min_x, min_y, max_x - min_x, max_y - min_y)
441}
442
443pub fn count_text_in_tree(elements: &[SemanticElement], text: &str) -> usize {
444 let mut count = 0;
445 for elem in elements {
446 if elem.text.as_deref() == Some(text) {
447 count += 1;
448 }
449 count += count_text_in_tree(&elem.children, text);
450 }
451 count
452}
453
454pub fn collect_by_text_exact<'a>(
455 elements: &'a [SemanticElement],
456 text: &str,
457 results: &mut Vec<&'a SemanticElement>,
458) {
459 for elem in elements {
460 if elem.text.as_deref() == Some(text) {
461 results.push(elem);
462 }
463 collect_by_text_exact(&elem.children, text, results);
464 }
465}
466
467pub fn collect_text_prefix_counts(
468 elements: &[SemanticElement],
469 prefix: &str,
470 counts: &mut HashMap<String, usize>,
471) {
472 for elem in elements {
473 if let Some(text) = elem.text.as_deref() {
474 if text.starts_with(prefix) {
475 *counts.entry(text.to_string()).or_insert(0) += 1;
476 }
477 }
478 collect_text_prefix_counts(&elem.children, prefix, counts);
479 }
480}
481
482pub fn exit_with_timeout(robot: &cranpose::Robot, timeout: Duration) {
483 let done = Arc::new(AtomicBool::new(false));
484 let done_thread = Arc::clone(&done);
485 std::thread::spawn(move || {
486 std::thread::sleep(timeout);
487 if !done_thread.load(Ordering::Relaxed) {
488 std::process::exit(0);
489 }
490 });
491
492 let _ = robot.exit();
493 done.store(true, Ordering::Relaxed);
494}
495
496#[derive(Clone, Copy, Debug, PartialEq, Eq)]
497pub enum TabAxis {
498 Horizontal,
499 Vertical,
500}
501
502type RectBounds = (f32, f32, f32, f32);
503type LabeledRect = (String, RectBounds);
504
505pub fn collect_tab_bounds(robot: &cranpose::Robot, labels: &[&str]) -> Vec<LabeledRect> {
506 let mut tabs = Vec::new();
507 for label in labels {
508 if let Some(bounds) = find_in_semantics(robot, |elem| find_button(elem, label)) {
509 tabs.push(((*label).to_string(), bounds));
510 }
511 }
512 tabs
513}
514
515pub fn bounds_span(bounds: &[LabeledRect]) -> Option<RectBounds> {
516 let mut iter = bounds.iter();
517 let (_, (x, y, w, h)) = iter.next()?;
518 let mut min_x = *x;
519 let mut min_y = *y;
520 let mut max_x = x + w;
521 let mut max_y = y + h;
522 for (_, (bx, by, bw, bh)) in iter {
523 min_x = min_x.min(*bx);
524 min_y = min_y.min(*by);
525 max_x = max_x.max(*bx + *bw);
526 max_y = max_y.max(*by + *bh);
527 }
528 Some((min_x, min_y, max_x, max_y))
529}
530
531pub fn detect_tab_axis(bounds: &[LabeledRect]) -> Option<TabAxis> {
532 let (min_x, min_y, max_x, max_y) = bounds_span(bounds)?;
533 let span_x = max_x - min_x;
534 let span_y = max_y - min_y;
535 if span_x >= span_y {
536 Some(TabAxis::Horizontal)
537 } else {
538 Some(TabAxis::Vertical)
539 }
540}
541
542pub fn root_bounds(robot: &cranpose::Robot) -> Option<RectBounds> {
543 if let Ok(screenshot) = robot.screenshot() {
544 if screenshot.logical_width.is_finite()
545 && screenshot.logical_width > 0.0
546 && screenshot.logical_height.is_finite()
547 && screenshot.logical_height > 0.0
548 {
549 return Some((
550 0.0,
551 0.0,
552 screenshot.logical_width,
553 screenshot.logical_height,
554 ));
555 }
556 }
557
558 let semantics = robot.get_semantics().ok()?;
559 let root = semantics.first()?;
560 Some((
561 root.bounds.x,
562 root.bounds.y,
563 root.bounds.width,
564 root.bounds.height,
565 ))
566}
567
568const VISIBILITY_PADDING: f32 = 4.0;
569const SCROLL_STEP: f32 = 240.0;
570const SCROLL_SETTLE_MS: u64 = 140;
571type TextMatcher = fn(&str, &str) -> bool;
572
573#[derive(Clone, Copy, Debug, PartialEq, Eq)]
574enum TextMatchMode {
575 Contains,
576 Exact,
577}
578
579fn text_contains(actual: &str, needle: &str) -> bool {
580 actual.contains(needle)
581}
582
583#[cfg(test)]
584fn text_equals(actual: &str, needle: &str) -> bool {
585 actual == needle
586}
587
588fn find_button_bounds_for_mode(
589 robot: &cranpose::Robot,
590 text: &str,
591 match_mode: TextMatchMode,
592) -> Option<(f32, f32, f32, f32)> {
593 let result = match match_mode {
594 TextMatchMode::Contains => robot.find_button_bounds(text),
595 TextMatchMode::Exact => robot.find_button_bounds_exact(text),
596 };
597 match result {
598 Ok(bounds) => bounds,
599 Err(e) => {
600 eprintln!(" ✗ Failed to query button semantics: {}", e);
601 None
602 }
603 }
604}
605
606fn has_text_by(elem: &SemanticElement, text: &str, matcher: TextMatcher) -> bool {
607 if elem
608 .text
609 .as_deref()
610 .is_some_and(|actual| matcher(actual, text))
611 {
612 return true;
613 }
614 elem.children
615 .iter()
616 .any(|child| has_text_by(child, text, matcher))
617}
618
619fn find_button_by(
620 elem: &SemanticElement,
621 text: &str,
622 matcher: TextMatcher,
623) -> Option<(f32, f32, f32, f32)> {
624 if elem.clickable && has_text_by(elem, text, matcher) {
625 return Some((
626 elem.bounds.x,
627 elem.bounds.y,
628 elem.bounds.width,
629 elem.bounds.height,
630 ));
631 }
632 elem.children
633 .iter()
634 .find_map(|child| find_button_by(child, text, matcher))
635}
636
637fn is_axis_visible(bounds: RectBounds, root: RectBounds, axis: TabAxis) -> bool {
638 let (x, y, w, h) = bounds;
639 let (rx, ry, rw, rh) = root;
640 match axis {
641 TabAxis::Horizontal => {
642 x >= rx + VISIBILITY_PADDING && x + w <= rx + rw - VISIBILITY_PADDING
643 }
644 TabAxis::Vertical => y >= ry + VISIBILITY_PADDING && y + h <= ry + rh - VISIBILITY_PADDING,
645 }
646}
647
648fn is_fully_visible(bounds: RectBounds, root: RectBounds) -> bool {
649 is_axis_visible(bounds, root, TabAxis::Horizontal)
650 && is_axis_visible(bounds, root, TabAxis::Vertical)
651}
652
653fn overflow_axis_direction(bounds: RectBounds, root: RectBounds) -> Option<(TabAxis, f32)> {
654 let (x, y, w, h) = bounds;
655 let (rx, ry, rw, rh) = root;
656
657 let overflow_left = (rx + VISIBILITY_PADDING - x).max(0.0);
658 let overflow_right = (x + w - (rx + rw - VISIBILITY_PADDING)).max(0.0);
659 let overflow_top = (ry + VISIBILITY_PADDING - y).max(0.0);
660 let overflow_bottom = (y + h - (ry + rh - VISIBILITY_PADDING)).max(0.0);
661
662 let horizontal_overflow = overflow_left.max(overflow_right);
663 let vertical_overflow = overflow_top.max(overflow_bottom);
664
665 if horizontal_overflow <= 0.0 && vertical_overflow <= 0.0 {
666 return None;
667 }
668
669 if horizontal_overflow >= vertical_overflow {
670 if overflow_left > 0.0 {
671 Some((TabAxis::Horizontal, 1.0))
672 } else {
673 Some((TabAxis::Horizontal, -1.0))
674 }
675 } else if overflow_top > 0.0 {
676 Some((TabAxis::Vertical, 1.0))
677 } else {
678 Some((TabAxis::Vertical, -1.0))
679 }
680}
681
682fn intersects_root(bounds: RectBounds, root: RectBounds) -> bool {
683 let (x, y, w, h) = bounds;
684 let (rx, ry, rw, rh) = root;
685 let left = x.max(rx);
686 let top = y.max(ry);
687 let right = (x + w).min(rx + rw);
688 let bottom = (y + h).min(ry + rh);
689 right > left && bottom > top
690}
691
692fn overlap_len(a_start: f32, a_len: f32, b_start: f32, b_len: f32) -> f32 {
693 let a_end = a_start + a_len;
694 let b_end = b_start + b_len;
695 (a_end.min(b_end) - a_start.max(b_start)).max(0.0)
696}
697
698fn cross_axis_overlap(bounds: RectBounds, target: RectBounds, axis: TabAxis) -> f32 {
699 match axis {
700 TabAxis::Horizontal => overlap_len(bounds.1, bounds.3, target.1, target.3),
701 TabAxis::Vertical => overlap_len(bounds.0, bounds.2, target.0, target.2),
702 }
703}
704
705fn primary_axis_distance(bounds: RectBounds, target: RectBounds, axis: TabAxis) -> f32 {
706 let center = match axis {
707 TabAxis::Horizontal => bounds.0 + bounds.2 / 2.0,
708 TabAxis::Vertical => bounds.1 + bounds.3 / 2.0,
709 };
710 let target_center = match axis {
711 TabAxis::Horizontal => target.0 + target.2 / 2.0,
712 TabAxis::Vertical => target.1 + target.3 / 2.0,
713 };
714 (center - target_center).abs()
715}
716
717fn find_scroll_anchor(
718 elements: &[SemanticElement],
719 target: RectBounds,
720 root: RectBounds,
721 axis: TabAxis,
722) -> Option<RectBounds> {
723 let mut best: Option<(RectBounds, f32, f32)> = None;
724
725 for elem in elements {
726 if elem.clickable {
727 let bounds = (
728 elem.bounds.x,
729 elem.bounds.y,
730 elem.bounds.width,
731 elem.bounds.height,
732 );
733 if is_fully_visible(bounds, root) && intersects_root(bounds, root) {
734 let overlap = cross_axis_overlap(bounds, target, axis);
735 if overlap > 0.0 {
736 let distance = primary_axis_distance(bounds, target, axis);
737 match best {
738 None => best = Some((bounds, overlap, distance)),
739 Some((_, best_overlap, best_distance)) => {
740 let is_better_overlap = overlap > best_overlap + f32::EPSILON;
741 let is_better_distance = (overlap - best_overlap).abs() <= f32::EPSILON
742 && distance < best_distance;
743 if is_better_overlap || is_better_distance {
744 best = Some((bounds, overlap, distance));
745 }
746 }
747 }
748 }
749 }
750 }
751
752 if let Some(found) = find_scroll_anchor(&elem.children, target, root, axis) {
753 let overlap = cross_axis_overlap(found, target, axis);
754 let distance = primary_axis_distance(found, target, axis);
755 match best {
756 None => best = Some((found, overlap, distance)),
757 Some((_, best_overlap, best_distance)) => {
758 let is_better_overlap = overlap > best_overlap + f32::EPSILON;
759 let is_better_distance =
760 (overlap - best_overlap).abs() <= f32::EPSILON && distance < best_distance;
761 if is_better_overlap || is_better_distance {
762 best = Some((found, overlap, distance));
763 }
764 }
765 }
766 }
767 }
768
769 best.map(|(bounds, _, _)| bounds)
770}
771
772pub fn capture_screenshot(robot: &cranpose::Robot) -> Option<cranpose::RobotScreenshot> {
774 robot.screenshot().ok()
775}
776
777fn screenshot_logical_width(screenshot: &cranpose::RobotScreenshot) -> f32 {
778 if screenshot.logical_width.is_finite() && screenshot.logical_width > 0.0 {
779 screenshot.logical_width
780 } else {
781 screenshot.width.max(1) as f32
782 }
783}
784
785fn screenshot_logical_height(screenshot: &cranpose::RobotScreenshot) -> f32 {
786 if screenshot.logical_height.is_finite() && screenshot.logical_height > 0.0 {
787 screenshot.logical_height
788 } else {
789 screenshot.height.max(1) as f32
790 }
791}
792
793fn screenshot_scale_x(screenshot: &cranpose::RobotScreenshot) -> f32 {
794 screenshot.width.max(1) as f32 / screenshot_logical_width(screenshot)
795}
796
797fn screenshot_scale_y(screenshot: &cranpose::RobotScreenshot) -> f32 {
798 screenshot.height.max(1) as f32 / screenshot_logical_height(screenshot)
799}
800
801fn logical_to_screenshot_x(screenshot: &cranpose::RobotScreenshot, x: f32) -> f32 {
802 x * screenshot_scale_x(screenshot)
803}
804
805fn logical_to_screenshot_y(screenshot: &cranpose::RobotScreenshot, y: f32) -> f32 {
806 y * screenshot_scale_y(screenshot)
807}
808
809pub fn screenshot_logical_size(screenshot: &cranpose::RobotScreenshot) -> (f32, f32) {
810 (
811 screenshot_logical_width(screenshot),
812 screenshot_logical_height(screenshot),
813 )
814}
815
816pub fn screenshot_pixel(screenshot: &cranpose::RobotScreenshot, x: u32, y: u32) -> Option<[u8; 4]> {
818 if x >= screenshot.width || y >= screenshot.height {
819 return None;
820 }
821 let index = ((y * screenshot.width + x) * 4) as usize;
822 Some([
823 screenshot.pixels[index],
824 screenshot.pixels[index + 1],
825 screenshot.pixels[index + 2],
826 screenshot.pixels[index + 3],
827 ])
828}
829
830pub fn sample_screenshot_pixel_logical(
831 screenshot: &cranpose::RobotScreenshot,
832 x: f32,
833 y: f32,
834) -> Option<[u8; 4]> {
835 let logical_width = screenshot_logical_width(screenshot);
836 let logical_height = screenshot_logical_height(screenshot);
837 if x < 0.0 || y < 0.0 || x > logical_width || y > logical_height {
838 return None;
839 }
840
841 Some(sample_screenshot_pixel_bilinear(
842 screenshot,
843 logical_to_screenshot_x(screenshot, x),
844 logical_to_screenshot_y(screenshot, y),
845 ))
846}
847
848pub fn logical_region_to_pixel_bounds(
849 screenshot: &cranpose::RobotScreenshot,
850 region: (f32, f32, f32, f32),
851) -> Option<(u32, u32, u32, u32)> {
852 if region.2 <= 0.0 || region.3 <= 0.0 || screenshot.width == 0 || screenshot.height == 0 {
853 return None;
854 }
855
856 let left = logical_to_screenshot_x(screenshot, region.0.max(0.0))
857 .floor()
858 .max(0.0) as u32;
859 let top = logical_to_screenshot_y(screenshot, region.1.max(0.0))
860 .floor()
861 .max(0.0) as u32;
862 let right = logical_to_screenshot_x(
863 screenshot,
864 (region.0 + region.2).min(screenshot_logical_width(screenshot)),
865 )
866 .ceil()
867 .min(screenshot.width as f32) as u32;
868 let bottom = logical_to_screenshot_y(
869 screenshot,
870 (region.1 + region.3).min(screenshot_logical_height(screenshot)),
871 )
872 .ceil()
873 .min(screenshot.height as f32) as u32;
874
875 if right <= left || bottom <= top {
876 return None;
877 }
878
879 Some((left, top, right, bottom))
880}
881
882pub fn crop_screenshot(
884 screenshot: &cranpose::RobotScreenshot,
885 x: u32,
886 y: u32,
887 width: u32,
888 height: u32,
889) -> Option<cranpose::RobotScreenshot> {
890 if width == 0 || height == 0 {
891 return None;
892 }
893 let end_x = x.checked_add(width)?;
894 let end_y = y.checked_add(height)?;
895 if end_x > screenshot.width || end_y > screenshot.height {
896 return None;
897 }
898
899 let mut pixels = vec![0u8; (width * height * 4) as usize];
900 for row in 0..height {
901 let src_start = (((y + row) * screenshot.width + x) * 4) as usize;
902 let src_end = src_start + (width * 4) as usize;
903 let dst_start = (row * width * 4) as usize;
904 let dst_end = dst_start + (width * 4) as usize;
905 pixels[dst_start..dst_end].copy_from_slice(&screenshot.pixels[src_start..src_end]);
906 }
907
908 Some(cranpose::RobotScreenshot {
909 width,
910 height,
911 logical_width: width as f32 / screenshot_scale_x(screenshot),
912 logical_height: height as f32 / screenshot_scale_y(screenshot),
913 pixels,
914 })
915}
916
917pub fn crop_screenshot_logical(
918 screenshot: &cranpose::RobotScreenshot,
919 x: f32,
920 y: f32,
921 width: f32,
922 height: f32,
923) -> Option<cranpose::RobotScreenshot> {
924 let (left, top, right, bottom) =
925 logical_region_to_pixel_bounds(screenshot, (x, y, width, height))?;
926 crop_screenshot(screenshot, left, top, right - left, bottom - top)
927}
928
929#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct ScreenshotPixelDifference {
931 pub x: u32,
932 pub y: u32,
933 pub before: [u8; 4],
934 pub after: [u8; 4],
935 pub difference: u32,
936}
937
938#[derive(Debug, Clone, PartialEq, Eq)]
939pub struct ScreenshotDifferenceStats {
940 pub differing_pixels: usize,
941 pub max_difference: u32,
942 pub first_difference: Option<ScreenshotPixelDifference>,
943}
944
945pub fn normalize_screenshot_region(
950 screenshot: &cranpose::RobotScreenshot,
951 region: (f32, f32, f32, f32),
952 output_width: u32,
953 output_height: u32,
954) -> Option<cranpose::RobotScreenshot> {
955 if output_width == 0
956 || output_height == 0
957 || region.2 <= 0.0
958 || region.3 <= 0.0
959 || screenshot.width == 0
960 || screenshot.height == 0
961 {
962 return None;
963 }
964
965 let mut pixels = Vec::with_capacity((output_width * output_height * 4) as usize);
966 for y in 0..output_height {
967 for x in 0..output_width {
968 let sample_x = region.0 + ((x as f32 + 0.5) * region.2 / output_width as f32);
969 let sample_y = region.1 + ((y as f32 + 0.5) * region.3 / output_height as f32);
970 pixels.extend_from_slice(&sample_screenshot_pixel_logical(
971 screenshot, sample_x, sample_y,
972 )?);
973 }
974 }
975
976 Some(cranpose::RobotScreenshot {
977 width: output_width,
978 height: output_height,
979 logical_width: output_width as f32,
980 logical_height: output_height as f32,
981 pixels,
982 })
983}
984
985pub fn screenshot_difference_stats(
988 before: &cranpose::RobotScreenshot,
989 after: &cranpose::RobotScreenshot,
990 difference_tolerance: u32,
991) -> Option<ScreenshotDifferenceStats> {
992 if before.width != after.width || before.height != after.height {
993 return None;
994 }
995
996 let mut differing_pixels = 0usize;
997 let mut max_difference = 0u32;
998 let mut first_difference = None;
999
1000 for y in 0..before.height {
1001 for x in 0..before.width {
1002 let before_pixel =
1003 screenshot_pixel(before, x, y).expect("screenshot bounds checked by loop");
1004 let after_pixel =
1005 screenshot_pixel(after, x, y).expect("screenshot bounds checked by loop");
1006 let difference = pixel_difference(before_pixel, after_pixel);
1007 if difference > difference_tolerance {
1008 differing_pixels += 1;
1009 max_difference = max_difference.max(difference);
1010 if first_difference.is_none() {
1011 first_difference = Some(ScreenshotPixelDifference {
1012 x,
1013 y,
1014 before: before_pixel,
1015 after: after_pixel,
1016 difference,
1017 });
1018 }
1019 }
1020 }
1021 }
1022
1023 Some(ScreenshotDifferenceStats {
1024 differing_pixels,
1025 max_difference,
1026 first_difference,
1027 })
1028}
1029
1030pub fn changed_pixel_count(
1033 before: &cranpose::RobotScreenshot,
1034 after: &cranpose::RobotScreenshot,
1035 channel_threshold: u8,
1036) -> usize {
1037 if before.width != after.width || before.height != after.height {
1038 return usize::MAX;
1039 }
1040
1041 before
1042 .pixels
1043 .chunks_exact(4)
1044 .zip(after.pixels.chunks_exact(4))
1045 .filter(|(a, b)| {
1046 a[0].abs_diff(b[0]) > channel_threshold
1047 || a[1].abs_diff(b[1]) > channel_threshold
1048 || a[2].abs_diff(b[2]) > channel_threshold
1049 || a[3].abs_diff(b[3]) > channel_threshold
1050 })
1051 .count()
1052}
1053
1054pub fn changed_pixel_count_in_region(
1056 before: &cranpose::RobotScreenshot,
1057 after: &cranpose::RobotScreenshot,
1058 region: (f32, f32, f32, f32),
1059 channel_threshold: u8,
1060) -> usize {
1061 if before.width != after.width
1062 || before.height != after.height
1063 || (screenshot_scale_x(before) - screenshot_scale_x(after)).abs() > f32::EPSILON
1064 || (screenshot_scale_y(before) - screenshot_scale_y(after)).abs() > f32::EPSILON
1065 {
1066 return usize::MAX;
1067 }
1068
1069 let Some((left, top, right, bottom)) = logical_region_to_pixel_bounds(before, region) else {
1070 return 0;
1071 };
1072
1073 let width = before.width as usize;
1074 let mut changed = 0usize;
1075 for y in top..bottom {
1076 for x in left..right {
1077 let idx = ((y as usize) * width + x as usize) * 4;
1078 if before.pixels[idx].abs_diff(after.pixels[idx]) > channel_threshold
1079 || before.pixels[idx + 1].abs_diff(after.pixels[idx + 1]) > channel_threshold
1080 || before.pixels[idx + 2].abs_diff(after.pixels[idx + 2]) > channel_threshold
1081 || before.pixels[idx + 3].abs_diff(after.pixels[idx + 3]) > channel_threshold
1082 {
1083 changed += 1;
1084 }
1085 }
1086 }
1087
1088 changed
1089}
1090
1091fn sample_screenshot_pixel_bilinear(
1092 screenshot: &cranpose::RobotScreenshot,
1093 x: f32,
1094 y: f32,
1095) -> [u8; 4] {
1096 let max_x = screenshot.width.saturating_sub(1) as f32;
1097 let max_y = screenshot.height.saturating_sub(1) as f32;
1098 let source_x = (x - 0.5).clamp(0.0, max_x);
1099 let source_y = (y - 0.5).clamp(0.0, max_y);
1100 let x0 = source_x.floor() as u32;
1101 let y0 = source_y.floor() as u32;
1102 let x1 = (x0 + 1).min(screenshot.width.saturating_sub(1));
1103 let y1 = (y0 + 1).min(screenshot.height.saturating_sub(1));
1104 let tx = source_x - x0 as f32;
1105 let ty = source_y - y0 as f32;
1106 let top_left = screenshot_pixel(screenshot, x0, y0).expect("bilinear x0/y0 in bounds");
1107 let top_right = screenshot_pixel(screenshot, x1, y0).expect("bilinear x1/y0 in bounds");
1108 let bottom_left = screenshot_pixel(screenshot, x0, y1).expect("bilinear x0/y1 in bounds");
1109 let bottom_right = screenshot_pixel(screenshot, x1, y1).expect("bilinear x1/y1 in bounds");
1110
1111 let lerp_channel = |index: usize| {
1112 let top = top_left[index] as f32 * (1.0 - tx) + top_right[index] as f32 * tx;
1113 let bottom = bottom_left[index] as f32 * (1.0 - tx) + bottom_right[index] as f32 * tx;
1114 (top * (1.0 - ty) + bottom * ty).round() as u8
1115 };
1116
1117 [
1118 lerp_channel(0),
1119 lerp_channel(1),
1120 lerp_channel(2),
1121 lerp_channel(3),
1122 ]
1123}
1124
1125fn pixel_difference(before: [u8; 4], after: [u8; 4]) -> u32 {
1126 before
1127 .into_iter()
1128 .zip(after)
1129 .map(|(lhs, rhs)| lhs.abs_diff(rhs) as u32)
1130 .max()
1131 .unwrap_or(0)
1132}
1133
1134pub fn parse_slider_value(text: &str) -> Option<f32> {
1136 text.split_once(':')
1137 .and_then(|(_, value)| value.trim().parse::<f32>().ok())
1138}
1139
1140pub fn scroll_down(robot: &cranpose::Robot, center_x: f32, from_y: f32, to_y: f32) {
1142 let _ = robot.drag(center_x, from_y, center_x, to_y);
1143 std::thread::sleep(std::time::Duration::from_millis(180));
1144 let _ = robot.wait_for_idle();
1145}
1146
1147pub fn scroll_up(robot: &cranpose::Robot, center_x: f32, from_y: f32, to_y: f32) {
1149 let _ = robot.drag(center_x, from_y, center_x, to_y);
1150 std::thread::sleep(std::time::Duration::from_millis(180));
1151 let _ = robot.wait_for_idle();
1152}
1153
1154pub fn y_is_visible(robot: &cranpose::Robot, y: f32) -> bool {
1157 let Some((_, root_y, _, root_h)) = root_bounds(robot) else {
1158 return true;
1159 };
1160 let top = root_y + 28.0;
1161 let bottom = root_y + root_h - 28.0;
1162 y >= top && y <= bottom
1163}
1164
1165#[derive(Clone, Copy, Debug)]
1167pub struct ScrollConfig {
1168 pub center_x: f32,
1169 pub down_from_y: f32,
1170 pub down_to_y: f32,
1171 pub up_from_y: f32,
1172 pub up_to_y: f32,
1173}
1174
1175pub fn scroll_prefix_into_view(
1178 robot: &cranpose::Robot,
1179 prefix: &str,
1180 max_attempts: usize,
1181 cfg: ScrollConfig,
1182) -> Option<(f32, f32, f32, f32, String)> {
1183 for attempt in 0..max_attempts {
1184 if let Some(bounds) = find_text_by_prefix_in_semantics(robot, prefix) {
1185 let center_y = bounds.1 + bounds.3 * 0.5;
1186 if y_is_visible(robot, center_y) {
1187 return Some(bounds);
1188 }
1189 let Some((_, root_y, _, root_h)) = root_bounds(robot) else {
1190 return Some(bounds);
1191 };
1192 let viewport_mid = root_y + root_h * 0.5;
1193 if center_y > viewport_mid {
1194 scroll_down(robot, cfg.center_x, cfg.down_from_y, cfg.down_to_y);
1195 } else {
1196 scroll_up(robot, cfg.center_x, cfg.up_from_y, cfg.up_to_y);
1197 }
1198 } else {
1199 if attempt % 2 == 0 {
1201 scroll_down(robot, cfg.center_x, cfg.down_from_y, cfg.down_to_y);
1202 } else {
1203 scroll_up(robot, cfg.center_x, cfg.up_from_y, cfg.up_to_y);
1204 }
1205 }
1206 }
1207 None
1208}
1209
1210pub fn set_slider_fraction(
1212 robot: &cranpose::Robot,
1213 prefix: &str,
1214 fraction: f32,
1215 slider_width: f32,
1216 slider_touch_offset_y: f32,
1217 cfg: ScrollConfig,
1218) -> Option<f32> {
1219 let (x, y, _w, h, _) = scroll_prefix_into_view(robot, prefix, 18, cfg)?;
1220 let slider_y = y + h + slider_touch_offset_y;
1221 let left_x = x + 2.0;
1222 let target_x = x + slider_width * fraction.clamp(0.0, 1.0);
1223 let _ = robot.drag(left_x, slider_y, target_x, slider_y);
1224 std::thread::sleep(std::time::Duration::from_millis(120));
1225 let _ = robot.wait_for_idle();
1226 find_text_by_prefix_in_semantics(robot, prefix)
1227 .and_then(|(_, _, _, _, t)| parse_slider_value(&t))
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233 use cranpose::{RobotScreenshot, SemanticRect};
1234
1235 fn semantic_element(
1236 role: &str,
1237 text: Option<&str>,
1238 clickable: bool,
1239 bounds: RectBounds,
1240 children: Vec<SemanticElement>,
1241 ) -> SemanticElement {
1242 SemanticElement {
1243 role: role.to_string(),
1244 text: text.map(ToString::to_string),
1245 clickable,
1246 bounds: SemanticRect {
1247 x: bounds.0,
1248 y: bounds.1,
1249 width: bounds.2,
1250 height: bounds.3,
1251 },
1252 children,
1253 }
1254 }
1255
1256 #[test]
1257 fn overflow_axis_direction_detects_horizontal_overflow() {
1258 let root = (0.0, 0.0, 300.0, 200.0);
1259 let target = (320.0, 20.0, 80.0, 30.0);
1260 assert_eq!(
1261 overflow_axis_direction(target, root),
1262 Some((TabAxis::Horizontal, -1.0))
1263 );
1264 }
1265
1266 #[test]
1267 fn overflow_axis_direction_detects_vertical_overflow() {
1268 let root = (0.0, 0.0, 300.0, 200.0);
1269 let target = (20.0, -60.0, 80.0, 30.0);
1270 assert_eq!(
1271 overflow_axis_direction(target, root),
1272 Some((TabAxis::Vertical, 1.0))
1273 );
1274 }
1275
1276 #[test]
1277 fn find_scroll_anchor_prefers_cross_axis_overlap() {
1278 let root = (0.0, 0.0, 300.0, 200.0);
1279 let target = (320.0, 22.0, 80.0, 28.0);
1280
1281 let same_row = semantic_element(
1282 "Layout",
1283 Some("same-row"),
1284 true,
1285 (120.0, 20.0, 80.0, 30.0),
1286 vec![],
1287 );
1288 let other_row = semantic_element(
1289 "Layout",
1290 Some("other-row"),
1291 true,
1292 (120.0, 130.0, 80.0, 30.0),
1293 vec![],
1294 );
1295 let root_elem = semantic_element("Layout", None, false, root, vec![same_row, other_row]);
1296
1297 let anchor = find_scroll_anchor(&[root_elem], target, root, TabAxis::Horizontal)
1298 .expect("expected anchor");
1299
1300 assert_eq!(anchor, (120.0, 20.0, 80.0, 30.0));
1301 }
1302
1303 #[test]
1304 fn find_button_exact_requires_full_text_match() {
1305 let exact_button = semantic_element(
1306 "Button",
1307 None,
1308 true,
1309 (10.0, 20.0, 90.0, 28.0),
1310 vec![
1311 semantic_element(
1312 "Text",
1313 Some("Text"),
1314 false,
1315 (14.0, 24.0, 30.0, 20.0),
1316 vec![],
1317 ),
1318 semantic_element(
1319 "Text",
1320 Some("Text Input"),
1321 false,
1322 (46.0, 24.0, 48.0, 20.0),
1323 vec![],
1324 ),
1325 ],
1326 );
1327 let partial_only_button = semantic_element(
1328 "Button",
1329 None,
1330 true,
1331 (120.0, 20.0, 90.0, 28.0),
1332 vec![semantic_element(
1333 "Text",
1334 Some("Text Input"),
1335 false,
1336 (124.0, 24.0, 48.0, 20.0),
1337 vec![],
1338 )],
1339 );
1340
1341 assert_eq!(
1342 find_button_by(&exact_button, "Text", text_equals),
1343 Some((10.0, 20.0, 90.0, 28.0))
1344 );
1345 assert_eq!(
1346 find_button_by(&partial_only_button, "Text", text_equals),
1347 None
1348 );
1349 }
1350
1351 #[test]
1352 fn screenshot_pixel_reads_expected_value() {
1353 let screenshot = RobotScreenshot {
1354 width: 2,
1355 height: 1,
1356 logical_width: 2.0,
1357 logical_height: 1.0,
1358 pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
1359 };
1360 assert_eq!(screenshot_pixel(&screenshot, 1, 0), Some([5, 6, 7, 8]));
1361 }
1362
1363 #[test]
1364 fn crop_screenshot_extracts_region() {
1365 let screenshot = RobotScreenshot {
1366 width: 3,
1367 height: 2,
1368 logical_width: 3.0,
1369 logical_height: 2.0,
1370 pixels: vec![
1371 1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255, 13, 14, 15, 255, 16, 17,
1372 18, 255,
1373 ],
1374 };
1375 let cropped = crop_screenshot(&screenshot, 1, 0, 2, 2).expect("crop");
1376 assert_eq!(cropped.width, 2);
1377 assert_eq!(cropped.height, 2);
1378 assert_eq!(cropped.logical_width, 2.0);
1379 assert_eq!(cropped.logical_height, 2.0);
1380 assert_eq!(
1381 cropped.pixels,
1382 vec![4, 5, 6, 255, 7, 8, 9, 255, 13, 14, 15, 255, 16, 17, 18, 255]
1383 );
1384 }
1385
1386 #[test]
1387 fn normalize_screenshot_region_preserves_pixel_grid_at_native_size() {
1388 let screenshot = RobotScreenshot {
1389 width: 2,
1390 height: 2,
1391 logical_width: 2.0,
1392 logical_height: 2.0,
1393 pixels: vec![1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255],
1394 };
1395
1396 let normalized =
1397 normalize_screenshot_region(&screenshot, (0.0, 0.0, 2.0, 2.0), 2, 2).expect("norm");
1398
1399 assert_eq!(normalized.width, screenshot.width);
1400 assert_eq!(normalized.height, screenshot.height);
1401 assert_eq!(normalized.logical_width, screenshot.logical_width);
1402 assert_eq!(normalized.logical_height, screenshot.logical_height);
1403 assert_eq!(normalized.pixels, screenshot.pixels);
1404 }
1405
1406 #[test]
1407 fn changed_pixel_count_in_region_uses_logical_coordinates() {
1408 let before = RobotScreenshot {
1409 width: 4,
1410 height: 4,
1411 logical_width: 2.0,
1412 logical_height: 2.0,
1413 pixels: vec![
1414 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255,
1415 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255,
1416 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255,
1417 ],
1418 };
1419 let mut after = before.clone();
1420 for y in 2..4 {
1421 for x in 2..4 {
1422 let idx = ((y * after.width + x) * 4) as usize;
1423 after.pixels[idx] = 255;
1424 }
1425 }
1426
1427 assert_eq!(
1428 changed_pixel_count_in_region(&before, &after, (1.0, 1.0, 1.0, 1.0), 1),
1429 4
1430 );
1431 }
1432
1433 #[test]
1434 fn sample_screenshot_pixel_logical_maps_scaled_capture() {
1435 let screenshot = RobotScreenshot {
1436 width: 4,
1437 height: 4,
1438 logical_width: 2.0,
1439 logical_height: 2.0,
1440 pixels: vec![
1441 1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255, 5, 0, 0, 255, 6, 0, 0, 255,
1442 7, 0, 0, 255, 8, 0, 0, 255, 9, 0, 0, 255, 10, 0, 0, 255, 11, 0, 0, 255, 12, 0, 0,
1443 255, 13, 0, 0, 255, 14, 0, 0, 255, 15, 0, 0, 255, 16, 0, 0, 255,
1444 ],
1445 };
1446
1447 assert_eq!(
1448 sample_screenshot_pixel_logical(&screenshot, 1.25, 1.25),
1449 Some([11, 0, 0, 255])
1450 );
1451 }
1452
1453 #[test]
1454 fn screenshot_difference_stats_reports_first_difference() {
1455 let before = RobotScreenshot {
1456 width: 2,
1457 height: 1,
1458 logical_width: 2.0,
1459 logical_height: 1.0,
1460 pixels: vec![10, 20, 30, 255, 1, 2, 3, 255],
1461 };
1462 let after = RobotScreenshot {
1463 width: 2,
1464 height: 1,
1465 logical_width: 2.0,
1466 logical_height: 1.0,
1467 pixels: vec![10, 20, 30, 255, 4, 8, 3, 200],
1468 };
1469
1470 let stats = screenshot_difference_stats(&before, &after, 3).expect("stats");
1471
1472 assert_eq!(stats.differing_pixels, 1);
1473 assert_eq!(stats.max_difference, 55);
1474 assert_eq!(
1475 stats.first_difference,
1476 Some(ScreenshotPixelDifference {
1477 x: 1,
1478 y: 0,
1479 before: [1, 2, 3, 255],
1480 after: [4, 8, 3, 200],
1481 difference: 55,
1482 })
1483 );
1484 }
1485}