1use serde::Serialize;
11use thiserror::Error;
12
13use crate::geometry::{Point, Rect, Shape, ToolKind};
14use crate::session::{SelectionRecord, SessionFile};
15use crate::space::Origin;
16
17#[derive(Debug, Error, PartialEq, Eq)]
18pub enum VerdictError {
19 #[error(
20 "the session has no target window — window-relative points need a \
21 session captured with --target"
22 )]
23 NoTarget,
24 #[error("monitor {requested} is not in this session; it has monitors {available:?}")]
25 UnknownMonitor {
26 requested: usize,
27 available: Vec<usize>,
28 },
29 #[error("no selection is labeled {requested:?}; labels in this space: {available:?}")]
30 UnknownLabel {
31 requested: String,
32 available: Vec<String>,
33 },
34 #[error("the session has no selections in {0} space to test against")]
35 NoCandidates(&'static str),
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize)]
46pub struct Verdict {
47 #[serde(skip_serializing_if = "Option::is_none")]
52 pub line: Option<usize>,
53 pub point: Point,
54 pub space: &'static str,
55 #[serde(skip_serializing_if = "Option::is_none")]
58 pub monitor: Option<usize>,
59 pub hit: bool,
60 pub contained_in: Vec<RegionRef>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub nearest: Option<Nearest>,
67}
68
69impl Verdict {
70 #[must_use]
72 pub fn at_line(mut self, line: usize) -> Self {
73 self.line = Some(line);
74 self
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80pub struct RegionRef {
81 pub index: usize,
82 pub label: String,
83 pub shape: ToolKind,
84 pub monitor: usize,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct Nearest {
89 pub region: RegionRef,
90 pub bbox_distance_px: f64,
93}
94
95struct Candidate<'a> {
97 index: usize,
98 record: &'a SelectionRecord,
99 shape: Shape,
100}
101
102impl Candidate<'_> {
103 fn rot_deg(&self) -> i32 {
104 self.record.rot_deg.unwrap_or(0)
105 }
106
107 fn region_ref(&self) -> RegionRef {
108 RegionRef {
109 index: self.index,
110 label: self.record.label.clone(),
111 shape: self.record.shape,
112 monitor: self.record.monitor,
113 }
114 }
115}
116
117pub fn assess(
125 session: &SessionFile,
126 point: Point,
127 space: Origin,
128 expect: Option<&str>,
129) -> Result<Verdict, VerdictError> {
130 let candidates = candidates(session, space)?;
131 if candidates.is_empty() {
132 return Err(VerdictError::NoCandidates(space.label()));
133 }
134 if let Some(wanted) = expect {
135 let known = candidates
136 .iter()
137 .any(|c| c.record.label.eq_ignore_ascii_case(wanted));
138 if !known {
139 return Err(VerdictError::UnknownLabel {
140 requested: wanted.to_string(),
141 available: candidate_labels(&candidates),
142 });
143 }
144 }
145
146 let contained: Vec<&Candidate> = candidates
147 .iter()
148 .filter(|c| c.shape.hit_test_rotated(c.rot_deg(), point))
149 .collect();
150 let hit = expect.map_or(!contained.is_empty(), |wanted| {
151 contained
152 .iter()
153 .any(|c| c.record.label.eq_ignore_ascii_case(wanted))
154 });
155 let nearest = if hit {
156 None
157 } else {
158 nearest_relevant(&candidates, point, expect)
159 };
160 Ok(Verdict {
161 line: None,
162 point,
163 space: space.label(),
164 monitor: match space {
165 Origin::Monitor(index) => Some(index),
166 _ => None,
167 },
168 hit,
169 contained_in: contained.iter().map(|c| c.region_ref()).collect(),
170 nearest,
171 })
172}
173
174fn candidates(session: &SessionFile, space: Origin) -> Result<Vec<Candidate<'_>>, VerdictError> {
177 let records = session.selections.iter().enumerate();
178 match space {
179 Origin::Global => Ok(records
180 .map(|(index, record)| Candidate {
181 index,
182 record,
183 shape: record.global_px.clone(),
184 })
185 .collect()),
186 Origin::Monitor(wanted) => {
187 let available: Vec<usize> = session.monitors.iter().map(|m| m.index).collect();
188 if !available.contains(&wanted) {
189 return Err(VerdictError::UnknownMonitor {
190 requested: wanted,
191 available,
192 });
193 }
194 Ok(records
195 .filter(|(_, record)| record.monitor == wanted)
196 .map(|(index, record)| Candidate {
197 index,
198 record,
199 shape: record.px.clone(),
200 })
201 .collect())
202 }
203 Origin::Window => {
204 if session.target.is_none() {
205 return Err(VerdictError::NoTarget);
206 }
207 Ok(records
208 .filter_map(|(index, record)| {
209 record.window_px.clone().map(|shape| Candidate {
210 index,
211 record,
212 shape,
213 })
214 })
215 .collect())
216 }
217 }
218}
219
220fn candidate_labels(candidates: &[Candidate]) -> Vec<String> {
224 crate::session::distinct_labels(candidates.iter().map(|c| c.record))
225}
226
227fn nearest_relevant(
230 candidates: &[Candidate],
231 point: Point,
232 expect: Option<&str>,
233) -> Option<Nearest> {
234 candidates
235 .iter()
236 .filter(|c| expect.is_none_or(|wanted| c.record.label.eq_ignore_ascii_case(wanted)))
237 .map(|c| Nearest {
238 region: c.region_ref(),
239 bbox_distance_px: bbox_distance(c.shape.rotated_bbox(c.rot_deg()), point),
240 })
241 .min_by(|a, b| a.bbox_distance_px.total_cmp(&b.bbox_distance_px))
242}
243
244fn bbox_distance(rect: Rect, p: Point) -> f64 {
247 let dx = axis_distance(p.x, rect.x, rect.w);
248 let dy = axis_distance(p.y, rect.y, rect.h);
249 f64::hypot(dx as f64, dy as f64)
250}
251
252fn axis_distance(v: i32, start: i32, len: i32) -> i64 {
255 let v = i64::from(v);
256 let start = i64::from(start);
257 let end = start + i64::from(len) - 1;
258 if v < start {
259 return start - v;
260 }
261 if v > end {
262 return v - end;
263 }
264 0
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::geometry::{Rect, Size};
271 use crate::selection::Selection;
272 use crate::session::{MonitorRecord, TargetRecord};
273
274 fn monitor(index: usize, ox: i32, oy: i32) -> MonitorRecord {
275 MonitorRecord {
276 index,
277 name: format!("Display {index}"),
278 primary: index == 0,
279 origin_px: Point::new(ox, oy),
280 size_px: Size::new(1920, 1080),
281 scale: 2.0,
282 }
283 }
284
285 fn labeled(shape: Shape, monitor: usize, label: &str) -> Selection {
286 let mut sel = Selection::new(shape, monitor);
287 sel.label = label.into();
288 sel
289 }
290
291 fn session(selections: &[Selection], target: Option<TargetRecord>) -> SessionFile {
292 let crops: Vec<String> = (0..selections.len()).map(|i| format!("c{i}.png")).collect();
293 SessionFile::build(
294 "test",
295 "2026-07-27T00:00:00Z".into(),
296 vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
297 selections,
298 &crops,
299 target,
300 )
301 }
302
303 #[test]
304 fn global_space_hits_through_the_monitor_origin() {
305 let file = session(
307 &[labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 1, "submit")],
308 None,
309 );
310 let hit = assess(&file, Point::new(1935, 25), Origin::Global, None).unwrap();
311 assert!(hit.hit);
312 assert_eq!(hit.contained_in[0].label, "submit");
313 assert!(hit.nearest.is_none());
314 let miss = assess(&file, Point::new(15, 25), Origin::Global, None).unwrap();
315 assert!(!miss.hit);
316 }
317
318 #[test]
319 fn monitor_space_tests_local_pixels_of_that_monitor_only() {
320 let file = session(
321 &[
322 labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 1, "right"),
323 labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 0, "left"),
324 ],
325 None,
326 );
327 let v = assess(&file, Point::new(15, 25), Origin::Monitor(1), None).unwrap();
328 assert!(v.hit);
329 assert_eq!(v.monitor, Some(1));
330 assert_eq!(v.contained_in.len(), 1);
333 assert_eq!(v.contained_in[0].label, "right");
334 }
335
336 #[test]
337 fn unknown_monitor_is_an_error_naming_the_real_ones() {
338 let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
339 let err = assess(&file, Point::new(0, 0), Origin::Monitor(7), None).unwrap_err();
340 assert_eq!(
341 err,
342 VerdictError::UnknownMonitor {
343 requested: 7,
344 available: vec![0, 1],
345 }
346 );
347 }
348
349 #[test]
350 fn monitor_with_no_selections_is_no_candidates_not_a_miss() {
351 let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
352 let err = assess(&file, Point::new(2, 2), Origin::Monitor(1), None).unwrap_err();
353 assert_eq!(err, VerdictError::NoCandidates("monitor"));
354 }
355
356 #[test]
357 fn window_space_needs_a_target_session() {
358 let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
359 let err = assess(&file, Point::new(2, 2), Origin::Window, None).unwrap_err();
360 assert_eq!(err, VerdictError::NoTarget);
361 }
362
363 #[test]
364 fn window_space_uses_window_relative_coords_and_skips_other_monitors() {
365 let target = TargetRecord {
366 app: "Editor".into(),
367 title: "main.rs".into(),
368 monitor: 0,
369 origin_px: Point::new(400, 250),
370 size_px: Size::new(800, 600),
371 };
372 let file = session(
373 &[
374 labeled(Shape::Rect(Rect::new(500, 300, 40, 20)), 0, "on target"),
375 labeled(Shape::Rect(Rect::new(1, 1, 5, 5)), 1, "elsewhere"),
376 ],
377 Some(target),
378 );
379 let v = assess(&file, Point::new(110, 55), Origin::Window, None).unwrap();
381 assert!(v.hit);
382 assert_eq!(v.contained_in.len(), 1);
383 assert_eq!(v.contained_in[0].label, "on target");
384 }
385
386 #[test]
387 fn label_filter_is_case_insensitive_and_sees_through_overlap() {
388 let file = session(
389 &[
390 labeled(Shape::Rect(Rect::new(0, 0, 100, 100)), 0, "Cancel"),
391 labeled(Shape::Rect(Rect::new(200, 0, 50, 50)), 0, "Submit"),
392 ],
393 None,
394 );
395 let v = assess(&file, Point::new(10, 10), Origin::Global, Some("SUBMIT")).unwrap();
396 assert!(!v.hit);
400 assert_eq!(v.contained_in.len(), 1);
401 assert_eq!(v.contained_in[0].label, "Cancel");
402 let nearest = v.nearest.unwrap();
403 assert_eq!(nearest.region.label, "Submit");
404 assert!(nearest.bbox_distance_px > 0.0);
405
406 let hit = assess(&file, Point::new(210, 10), Origin::Global, Some("submit")).unwrap();
407 assert!(hit.hit);
408 }
409
410 #[test]
411 fn unknown_label_is_an_error_listing_the_real_ones() {
412 let file = session(
413 &[
414 labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "Submit"),
415 labeled(Shape::Rect(Rect::new(9, 9, 5, 5)), 0, "submit"),
416 labeled(Shape::Rect(Rect::new(20, 20, 5, 5)), 0, ""),
417 ],
418 None,
419 );
420 let err = assess(&file, Point::new(0, 0), Origin::Global, Some("send")).unwrap_err();
421 assert_eq!(
424 err,
425 VerdictError::UnknownLabel {
426 requested: "send".into(),
427 available: vec!["Submit".into()],
428 }
429 );
430 }
431
432 #[test]
433 fn empty_session_is_no_candidates() {
434 let file = session(&[], None);
435 let err = assess(&file, Point::new(0, 0), Origin::Global, None).unwrap_err();
436 assert_eq!(err, VerdictError::NoCandidates("global"));
437 }
438
439 #[test]
440 fn rotated_rect_is_tested_as_the_user_saw_it() {
441 let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
443 sel.rot_deg = 90;
444 let file = session(&[sel], None);
445 let inside_rotated = Point::new(28, 27);
446 let v = assess(&file, inside_rotated, Origin::Global, None).unwrap();
447 assert!(v.hit, "point inside the rotated silhouette must hit");
448 let inside_unrotated_only = Point::new(45, 15);
449 let v = assess(&file, inside_unrotated_only, Origin::Global, None).unwrap();
450 assert!(
451 !v.hit,
452 "the axis-aligned box no longer applies once rotated"
453 );
454 }
455
456 #[test]
457 fn circle_and_triangle_hits_use_their_own_geometry() {
458 let file = session(
459 &[
460 labeled(
461 Shape::Circle {
462 cx: 50,
463 cy: 50,
464 r: 10,
465 },
466 0,
467 "dot",
468 ),
469 labeled(
470 Shape::Triangle {
471 ax: 200,
472 ay: 100,
473 bx: 150,
474 by: 200,
475 cx: 250,
476 cy: 200,
477 },
478 0,
479 "tri",
480 ),
481 ],
482 None,
483 );
484 assert!(
487 assess(&file, Point::new(60, 50), Origin::Global, None)
488 .unwrap()
489 .hit
490 );
491 assert!(
492 assess(&file, Point::new(200, 150), Origin::Global, None)
493 .unwrap()
494 .hit
495 );
496 let circle_corner = assess(&file, Point::new(41, 41), Origin::Global, None).unwrap();
497 assert!(!circle_corner.hit);
498 let tri_corner = assess(&file, Point::new(151, 101), Origin::Global, None).unwrap();
499 assert!(!tri_corner.hit);
500 }
501
502 #[test]
503 fn overlapping_hits_come_back_in_stacking_order() {
504 let file = session(
505 &[
506 labeled(Shape::Rect(Rect::new(0, 0, 100, 100)), 0, "below"),
507 labeled(Shape::Rect(Rect::new(0, 0, 50, 50)), 0, "above"),
508 ],
509 None,
510 );
511 let v = assess(&file, Point::new(10, 10), Origin::Global, None).unwrap();
512 let labels: Vec<&str> = v.contained_in.iter().map(|r| r.label.as_str()).collect();
513 assert_eq!(labels, ["below", "above"]);
514 assert_eq!(v.contained_in[1].index, 1);
515 }
516
517 #[test]
518 fn miss_distance_is_euclidean_to_the_bbox() {
519 let file = session(
520 &[labeled(Shape::Rect(Rect::new(10, 10, 20, 20)), 0, "box")],
521 None,
522 );
523 let v = assess(&file, Point::new(7, 6), Origin::Global, None).unwrap();
525 assert!(!v.hit);
526 let nearest = v.nearest.unwrap();
527 assert_eq!(nearest.region.label, "box");
528 assert!((nearest.bbox_distance_px - 5.0).abs() < f64::EPSILON);
529 }
530
531 #[test]
532 fn miss_distance_uses_the_rotated_bbox() {
533 let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
537 sel.rot_deg = 90;
538 let file = session(&[sel], None);
539 let v = assess(&file, Point::new(40, 0), Origin::Global, None).unwrap();
540 assert!(!v.hit);
541 let d = v.nearest.unwrap().bbox_distance_px;
545 assert!(
546 d < 8.0,
547 "distance {d} should measure the rotated silhouette"
548 );
549 }
550
551 #[test]
552 fn a_line_number_is_added_without_disturbing_the_answer() {
553 let file = session(
554 &[labeled(Shape::Rect(Rect::new(10, 10, 20, 20)), 0, "box")],
555 None,
556 );
557 let bare = assess(&file, Point::new(15, 15), Origin::Global, None).unwrap();
558 assert_eq!(bare.line, None, "a single point has no line to report");
559
560 let tagged = bare.clone().at_line(7);
561 assert_eq!(tagged.line, Some(7));
562 assert_eq!(
563 Verdict {
564 line: None,
565 ..tagged
566 },
567 bare,
568 "tagging changes the line and nothing else"
569 );
570 }
571
572 #[test]
573 fn verdict_json_shape_is_stable() {
574 let file = session(
575 &[labeled(Shape::Rect(Rect::new(10, 10, 20, 20)), 0, "box")],
576 None,
577 );
578 let hit = assess(&file, Point::new(15, 15), Origin::Global, None).unwrap();
579 let json = serde_json::to_value(&hit).unwrap();
580 assert!(
581 json.get("schema").is_none(),
582 "a verdict is a row; report::Report versions the document"
583 );
584 assert_eq!(json["space"], "global");
585 assert_eq!(json["hit"], true);
586 assert_eq!(json["point"]["x"], 15);
587 assert_eq!(json["contained_in"][0]["label"], "box");
588 assert!(json.get("monitor").is_none(), "global space has no monitor");
589 assert!(json.get("nearest").is_none(), "hits carry no nearest");
590
591 let miss = assess(&file, Point::new(500, 500), Origin::Monitor(0), None).unwrap();
592 let json = serde_json::to_value(&miss).unwrap();
593 assert_eq!(json["space"], "monitor");
594 assert_eq!(json["monitor"], 0);
595 assert_eq!(json["nearest"]["region"]["label"], "box");
596 }
597
598 #[test]
599 fn space_labels_name_their_own_space() {
600 assert_eq!(Origin::Global.label(), "global");
601 assert_eq!(Origin::Monitor(3).label(), "monitor");
602 assert_eq!(Origin::Window.label(), "window");
603 }
604}