Skip to main content

pixelcoords_core/
verdict.rs

1//! Point-in-region verdicts against a saved session — the logic behind
2//! `pixelcoords assert`.
3//!
4//! A verdict answers "does this point land inside a marked region?" for a
5//! point expressed in any of the session's coordinate spaces. It exists so
6//! automation — a computer-use agent, a click script under test — can be
7//! scored against regions a human marked once, without reimplementing the
8//! session's geometry.
9
10use serde::Serialize;
11use thiserror::Error;
12
13use crate::geometry::{Point, Rect, Shape, ToolKind};
14use crate::session::{SelectionRecord, SessionFile};
15
16pub const VERDICT_SCHEMA_VERSION: u32 = 1;
17
18/// The coordinate space an incoming point is expressed in. Each maps to the
19/// coordinates the session already stores; nothing is derived at test time.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PointSpace {
22    /// Global desktop physical pixels (`global_px`).
23    Global,
24    /// Monitor-local physical pixels (`px`) of the given monitor index.
25    Monitor(usize),
26    /// Physical pixels relative to the target window's top-left
27    /// (`window_px`); needs a `--target` session.
28    Window,
29}
30
31impl PointSpace {
32    pub const fn label(self) -> &'static str {
33        match self {
34            Self::Global => "global",
35            Self::Monitor(_) => "monitor",
36            Self::Window => "window",
37        }
38    }
39}
40
41#[derive(Debug, Error, PartialEq, Eq)]
42pub enum VerdictError {
43    #[error(
44        "the session has no target window — window-relative points need a \
45         session captured with --target"
46    )]
47    NoTarget,
48    #[error("monitor {requested} is not in this session; it has monitors {available:?}")]
49    UnknownMonitor {
50        requested: usize,
51        available: Vec<usize>,
52    },
53    #[error("no selection is labeled {requested:?}; labels in this space: {available:?}")]
54    UnknownLabel {
55        requested: String,
56        available: Vec<String>,
57    },
58    #[error("the session has no selections in {0} space to test against")]
59    NoCandidates(&'static str),
60}
61
62/// The result of testing one point, ready to serialize as the `assert`
63/// subcommand's JSON output.
64#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct Verdict {
66    pub schema: u32,
67    pub point: Point,
68    pub space: &'static str,
69    /// The monitor index the point is local to; present only in monitor
70    /// space.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub monitor: Option<usize>,
73    pub hit: bool,
74    /// Every region containing the point, in session (stacking) order —
75    /// last is topmost. A miss against `--label` still lists what the
76    /// point *did* land in.
77    pub contained_in: Vec<RegionRef>,
78    /// Present on a miss: the closest relevant region, for partial credit.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub nearest: Option<Nearest>,
81}
82
83/// A selection referenced by its position in the session file.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct RegionRef {
86    pub index: usize,
87    pub label: String,
88    pub shape: ToolKind,
89    pub monitor: usize,
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize)]
93pub struct Nearest {
94    pub region: RegionRef,
95    /// Distance in pixels from the point to the region's rotated bounding
96    /// box. 0 means inside the bbox but outside the shape itself.
97    pub bbox_distance_px: f64,
98}
99
100/// One selection viewed through the requested coordinate space.
101struct Candidate<'a> {
102    index: usize,
103    record: &'a SelectionRecord,
104    shape: Shape,
105}
106
107impl Candidate<'_> {
108    fn rot_deg(&self) -> i32 {
109        self.record.rot_deg.unwrap_or(0)
110    }
111
112    fn region_ref(&self) -> RegionRef {
113        RegionRef {
114            index: self.index,
115            label: self.record.label.clone(),
116            shape: self.record.shape,
117            monitor: self.record.monitor,
118        }
119    }
120}
121
122/// Test `point` against the session's regions. `label` restricts what
123/// counts as a hit to selections carrying that label (ASCII
124/// case-insensitive, matching the window matcher's behavior).
125pub fn assess(
126    session: &SessionFile,
127    point: Point,
128    space: PointSpace,
129    label: Option<&str>,
130) -> Result<Verdict, VerdictError> {
131    let candidates = candidates(session, space)?;
132    if candidates.is_empty() {
133        return Err(VerdictError::NoCandidates(space.label()));
134    }
135    if let Some(wanted) = label {
136        let known = candidates
137            .iter()
138            .any(|c| c.record.label.eq_ignore_ascii_case(wanted));
139        if !known {
140            return Err(VerdictError::UnknownLabel {
141                requested: wanted.to_string(),
142                available: distinct_labels(&candidates),
143            });
144        }
145    }
146
147    let contained: Vec<&Candidate> = candidates
148        .iter()
149        .filter(|c| c.shape.hit_test_rotated(c.rot_deg(), point))
150        .collect();
151    let hit = label.map_or(!contained.is_empty(), |wanted| {
152        contained
153            .iter()
154            .any(|c| c.record.label.eq_ignore_ascii_case(wanted))
155    });
156    let nearest = if hit {
157        None
158    } else {
159        nearest_relevant(&candidates, point, label)
160    };
161    Ok(Verdict {
162        schema: VERDICT_SCHEMA_VERSION,
163        point,
164        space: space.label(),
165        monitor: match space {
166            PointSpace::Monitor(index) => Some(index),
167            _ => None,
168        },
169        hit,
170        contained_in: contained.iter().map(|c| c.region_ref()).collect(),
171        nearest,
172    })
173}
174
175/// The selections testable in `space`, each carrying the shape already
176/// stored for that space.
177fn candidates(
178    session: &SessionFile,
179    space: PointSpace,
180) -> Result<Vec<Candidate<'_>>, VerdictError> {
181    let records = session.selections.iter().enumerate();
182    match space {
183        PointSpace::Global => Ok(records
184            .map(|(index, record)| Candidate {
185                index,
186                record,
187                shape: record.global_px.clone(),
188            })
189            .collect()),
190        PointSpace::Monitor(wanted) => {
191            let available: Vec<usize> = session.monitors.iter().map(|m| m.index).collect();
192            if !available.contains(&wanted) {
193                return Err(VerdictError::UnknownMonitor {
194                    requested: wanted,
195                    available,
196                });
197            }
198            Ok(records
199                .filter(|(_, record)| record.monitor == wanted)
200                .map(|(index, record)| Candidate {
201                    index,
202                    record,
203                    shape: record.px.clone(),
204                })
205                .collect())
206        }
207        PointSpace::Window => {
208            if session.target.is_none() {
209                return Err(VerdictError::NoTarget);
210            }
211            Ok(records
212                .filter_map(|(index, record)| {
213                    record.window_px.clone().map(|shape| Candidate {
214                        index,
215                        record,
216                        shape,
217                    })
218                })
219                .collect())
220        }
221    }
222}
223
224/// The labels a `--label` filter could have matched, deduplicated in
225/// session order; unlabeled selections contribute nothing.
226fn distinct_labels(candidates: &[Candidate]) -> Vec<String> {
227    let mut labels: Vec<String> = Vec::new();
228    for c in candidates {
229        if c.record.label.is_empty() {
230            continue;
231        }
232        if labels
233            .iter()
234            .any(|l| l.eq_ignore_ascii_case(&c.record.label))
235        {
236            continue;
237        }
238        labels.push(c.record.label.clone());
239    }
240    labels
241}
242
243/// The closest region the miss was measured against: the labeled ones when
244/// a label was requested, otherwise all of them.
245fn nearest_relevant(
246    candidates: &[Candidate],
247    point: Point,
248    label: Option<&str>,
249) -> Option<Nearest> {
250    candidates
251        .iter()
252        .filter(|c| label.is_none_or(|wanted| c.record.label.eq_ignore_ascii_case(wanted)))
253        .map(|c| Nearest {
254            region: c.region_ref(),
255            bbox_distance_px: bbox_distance(c.shape.rotated_bbox(c.rot_deg()), point),
256        })
257        .min_by(|a, b| a.bbox_distance_px.total_cmp(&b.bbox_distance_px))
258}
259
260/// Euclidean distance from `p` to the nearest pixel of `rect`, 0 inside.
261/// i64/f64 throughout so extreme deserialized coordinates cannot overflow.
262fn bbox_distance(rect: Rect, p: Point) -> f64 {
263    let dx = axis_distance(p.x, rect.x, rect.w);
264    let dy = axis_distance(p.y, rect.y, rect.h);
265    f64::hypot(dx as f64, dy as f64)
266}
267
268/// Distance from `v` to the half-open interval `[start, start + len)` on
269/// one axis, 0 inside — the same inclusion rule as `Rect::contains`.
270fn axis_distance(v: i32, start: i32, len: i32) -> i64 {
271    let v = i64::from(v);
272    let start = i64::from(start);
273    let end = start + i64::from(len) - 1;
274    if v < start {
275        return start - v;
276    }
277    if v > end {
278        return v - end;
279    }
280    0
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::geometry::{Rect, Size};
287    use crate::selection::Selection;
288    use crate::session::{MonitorRecord, TargetRecord};
289
290    fn monitor(index: usize, ox: i32, oy: i32) -> MonitorRecord {
291        MonitorRecord {
292            index,
293            name: format!("Display {index}"),
294            primary: index == 0,
295            origin_px: Point::new(ox, oy),
296            size_px: Size::new(1920, 1080),
297            scale: 2.0,
298        }
299    }
300
301    fn labeled(shape: Shape, monitor: usize, label: &str) -> Selection {
302        let mut sel = Selection::new(shape, monitor);
303        sel.label = label.into();
304        sel
305    }
306
307    fn session(selections: &[Selection], target: Option<TargetRecord>) -> SessionFile {
308        let crops: Vec<String> = (0..selections.len()).map(|i| format!("c{i}.png")).collect();
309        SessionFile::build(
310            "test",
311            "2026-07-27T00:00:00Z".into(),
312            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
313            selections,
314            &crops,
315            target,
316        )
317    }
318
319    #[test]
320    fn global_space_hits_through_the_monitor_origin() {
321        // Local (10, 20) on monitor 1 sits at global (1930, 20).
322        let file = session(
323            &[labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 1, "submit")],
324            None,
325        );
326        let hit = assess(&file, Point::new(1935, 25), PointSpace::Global, None).unwrap();
327        assert!(hit.hit);
328        assert_eq!(hit.contained_in[0].label, "submit");
329        assert!(hit.nearest.is_none());
330        let miss = assess(&file, Point::new(15, 25), PointSpace::Global, None).unwrap();
331        assert!(!miss.hit);
332    }
333
334    #[test]
335    fn monitor_space_tests_local_pixels_of_that_monitor_only() {
336        let file = session(
337            &[
338                labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 1, "right"),
339                labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 0, "left"),
340            ],
341            None,
342        );
343        let v = assess(&file, Point::new(15, 25), PointSpace::Monitor(1), None).unwrap();
344        assert!(v.hit);
345        assert_eq!(v.monitor, Some(1));
346        // Both monitors hold an identical local rect; only monitor 1's is
347        // eligible, so exactly one region contains the point.
348        assert_eq!(v.contained_in.len(), 1);
349        assert_eq!(v.contained_in[0].label, "right");
350    }
351
352    #[test]
353    fn unknown_monitor_is_an_error_naming_the_real_ones() {
354        let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
355        let err = assess(&file, Point::new(0, 0), PointSpace::Monitor(7), None).unwrap_err();
356        assert_eq!(
357            err,
358            VerdictError::UnknownMonitor {
359                requested: 7,
360                available: vec![0, 1],
361            }
362        );
363    }
364
365    #[test]
366    fn monitor_with_no_selections_is_no_candidates_not_a_miss() {
367        let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
368        let err = assess(&file, Point::new(2, 2), PointSpace::Monitor(1), None).unwrap_err();
369        assert_eq!(err, VerdictError::NoCandidates("monitor"));
370    }
371
372    #[test]
373    fn window_space_needs_a_target_session() {
374        let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
375        let err = assess(&file, Point::new(2, 2), PointSpace::Window, None).unwrap_err();
376        assert_eq!(err, VerdictError::NoTarget);
377    }
378
379    #[test]
380    fn window_space_uses_window_relative_coords_and_skips_other_monitors() {
381        let target = TargetRecord {
382            app: "Editor".into(),
383            title: "main.rs".into(),
384            monitor: 0,
385            origin_px: Point::new(400, 250),
386            size_px: Size::new(800, 600),
387        };
388        let file = session(
389            &[
390                labeled(Shape::Rect(Rect::new(500, 300, 40, 20)), 0, "on target"),
391                labeled(Shape::Rect(Rect::new(1, 1, 5, 5)), 1, "elsewhere"),
392            ],
393            Some(target),
394        );
395        // Window-relative: the rect sits at (100, 50) from the window origin.
396        let v = assess(&file, Point::new(110, 55), PointSpace::Window, None).unwrap();
397        assert!(v.hit);
398        assert_eq!(v.contained_in.len(), 1);
399        assert_eq!(v.contained_in[0].label, "on target");
400    }
401
402    #[test]
403    fn label_filter_is_case_insensitive_and_sees_through_overlap() {
404        let file = session(
405            &[
406                labeled(Shape::Rect(Rect::new(0, 0, 100, 100)), 0, "Cancel"),
407                labeled(Shape::Rect(Rect::new(200, 0, 50, 50)), 0, "Submit"),
408            ],
409            None,
410        );
411        let v = assess(
412            &file,
413            Point::new(10, 10),
414            PointSpace::Global,
415            Some("SUBMIT"),
416        )
417        .unwrap();
418        // The point is inside "Cancel", so the verdict is a labeled miss
419        // that still reports what was actually hit — and how far the
420        // wanted region is.
421        assert!(!v.hit);
422        assert_eq!(v.contained_in.len(), 1);
423        assert_eq!(v.contained_in[0].label, "Cancel");
424        let nearest = v.nearest.unwrap();
425        assert_eq!(nearest.region.label, "Submit");
426        assert!(nearest.bbox_distance_px > 0.0);
427
428        let hit = assess(
429            &file,
430            Point::new(210, 10),
431            PointSpace::Global,
432            Some("submit"),
433        )
434        .unwrap();
435        assert!(hit.hit);
436    }
437
438    #[test]
439    fn unknown_label_is_an_error_listing_the_real_ones() {
440        let file = session(
441            &[
442                labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "Submit"),
443                labeled(Shape::Rect(Rect::new(9, 9, 5, 5)), 0, "submit"),
444                labeled(Shape::Rect(Rect::new(20, 20, 5, 5)), 0, ""),
445            ],
446            None,
447        );
448        let err = assess(&file, Point::new(0, 0), PointSpace::Global, Some("send")).unwrap_err();
449        // Case-insensitive duplicates collapse; the unlabeled one is not
450        // offered.
451        assert_eq!(
452            err,
453            VerdictError::UnknownLabel {
454                requested: "send".into(),
455                available: vec!["Submit".into()],
456            }
457        );
458    }
459
460    #[test]
461    fn empty_session_is_no_candidates() {
462        let file = session(&[], None);
463        let err = assess(&file, Point::new(0, 0), PointSpace::Global, None).unwrap_err();
464        assert_eq!(err, VerdictError::NoCandidates("global"));
465    }
466
467    #[test]
468    fn rotated_rect_is_tested_as_the_user_saw_it() {
469        // A wide rect rotated 90° stands tall: x 25..35, y -5..35 visually.
470        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
471        sel.rot_deg = 90;
472        let file = session(&[sel], None);
473        let inside_rotated = Point::new(28, 27);
474        let v = assess(&file, inside_rotated, PointSpace::Global, None).unwrap();
475        assert!(v.hit, "point inside the rotated silhouette must hit");
476        let inside_unrotated_only = Point::new(45, 15);
477        let v = assess(&file, inside_unrotated_only, PointSpace::Global, None).unwrap();
478        assert!(
479            !v.hit,
480            "the axis-aligned box no longer applies once rotated"
481        );
482    }
483
484    #[test]
485    fn circle_and_triangle_hits_use_their_own_geometry() {
486        let file = session(
487            &[
488                labeled(
489                    Shape::Circle {
490                        cx: 50,
491                        cy: 50,
492                        r: 10,
493                    },
494                    0,
495                    "dot",
496                ),
497                labeled(
498                    Shape::Triangle {
499                        ax: 200,
500                        ay: 100,
501                        bx: 150,
502                        by: 200,
503                        cx: 250,
504                        cy: 200,
505                    },
506                    0,
507                    "tri",
508                ),
509            ],
510            None,
511        );
512        // On the circle's rim (inclusive), inside the triangle, and in each
513        // shape's bbox corner — which its geometry excludes.
514        assert!(
515            assess(&file, Point::new(60, 50), PointSpace::Global, None)
516                .unwrap()
517                .hit
518        );
519        assert!(
520            assess(&file, Point::new(200, 150), PointSpace::Global, None)
521                .unwrap()
522                .hit
523        );
524        let circle_corner = assess(&file, Point::new(41, 41), PointSpace::Global, None).unwrap();
525        assert!(!circle_corner.hit);
526        let tri_corner = assess(&file, Point::new(151, 101), PointSpace::Global, None).unwrap();
527        assert!(!tri_corner.hit);
528    }
529
530    #[test]
531    fn overlapping_hits_come_back_in_stacking_order() {
532        let file = session(
533            &[
534                labeled(Shape::Rect(Rect::new(0, 0, 100, 100)), 0, "below"),
535                labeled(Shape::Rect(Rect::new(0, 0, 50, 50)), 0, "above"),
536            ],
537            None,
538        );
539        let v = assess(&file, Point::new(10, 10), PointSpace::Global, None).unwrap();
540        let labels: Vec<&str> = v.contained_in.iter().map(|r| r.label.as_str()).collect();
541        assert_eq!(labels, ["below", "above"]);
542        assert_eq!(v.contained_in[1].index, 1);
543    }
544
545    #[test]
546    fn miss_distance_is_euclidean_to_the_bbox() {
547        let file = session(
548            &[labeled(Shape::Rect(Rect::new(10, 10, 20, 20)), 0, "box")],
549            None,
550        );
551        // 3 left of x=10, 4 above y=10: a 3-4-5 triangle.
552        let v = assess(&file, Point::new(7, 6), PointSpace::Global, None).unwrap();
553        assert!(!v.hit);
554        let nearest = v.nearest.unwrap();
555        assert_eq!(nearest.region.label, "box");
556        assert!((nearest.bbox_distance_px - 5.0).abs() < f64::EPSILON);
557    }
558
559    #[test]
560    fn miss_distance_uses_the_rotated_bbox() {
561        // Rotated 90°, the wide rect's silhouette spans x 25..35 — a point
562        // just right of it measures against that tall box, not the
563        // original wide one.
564        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
565        sel.rot_deg = 90;
566        let file = session(&[sel], None);
567        let v = assess(&file, Point::new(40, 0), PointSpace::Global, None).unwrap();
568        assert!(!v.hit);
569        // The rotated bbox reaches x=35 at y=0; distance is 40-34=6 in x
570        // per half-open inclusion, 0 in y — well under the unrotated
571        // bbox's answer (which would include a 10px y gap).
572        let d = v.nearest.unwrap().bbox_distance_px;
573        assert!(
574            d < 8.0,
575            "distance {d} should measure the rotated silhouette"
576        );
577    }
578
579    #[test]
580    fn verdict_json_shape_is_stable() {
581        let file = session(
582            &[labeled(Shape::Rect(Rect::new(10, 10, 20, 20)), 0, "box")],
583            None,
584        );
585        let hit = assess(&file, Point::new(15, 15), PointSpace::Global, None).unwrap();
586        let json = serde_json::to_value(&hit).unwrap();
587        assert_eq!(json["schema"], 1);
588        assert_eq!(json["space"], "global");
589        assert_eq!(json["hit"], true);
590        assert_eq!(json["point"]["x"], 15);
591        assert_eq!(json["contained_in"][0]["label"], "box");
592        assert!(json.get("monitor").is_none(), "global space has no monitor");
593        assert!(json.get("nearest").is_none(), "hits carry no nearest");
594
595        let miss = assess(&file, Point::new(500, 500), PointSpace::Monitor(0), None).unwrap();
596        let json = serde_json::to_value(&miss).unwrap();
597        assert_eq!(json["space"], "monitor");
598        assert_eq!(json["monitor"], 0);
599        assert_eq!(json["nearest"]["region"]["label"], "box");
600    }
601
602    #[test]
603    fn space_labels_name_their_own_space() {
604        assert_eq!(PointSpace::Global.label(), "global");
605        assert_eq!(PointSpace::Monitor(3).label(), "monitor");
606        assert_eq!(PointSpace::Window.label(), "window");
607    }
608}