1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PointSpace {
22 Global,
24 Monitor(usize),
26 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#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct Verdict {
66 pub schema: u32,
67 pub point: Point,
68 pub space: &'static str,
69 #[serde(skip_serializing_if = "Option::is_none")]
72 pub monitor: Option<usize>,
73 pub hit: bool,
74 pub contained_in: Vec<RegionRef>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub nearest: Option<Nearest>,
81}
82
83#[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 pub bbox_distance_px: f64,
98}
99
100struct 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
122pub 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
175fn 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
224fn 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
243fn 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
260fn 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
268fn 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 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 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 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 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 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 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 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 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 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 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}