1use serde::Serialize;
18use thiserror::Error;
19
20use crate::geometry::{Point, Shape};
21use crate::locate::Delta;
22use crate::session::{SelectionRecord, SessionFile};
23use crate::space::{Origin, Resolved, logical_of};
24
25#[derive(Debug, Error, PartialEq, Eq)]
26pub enum ResolveError {
27 #[error("the session has no selections to resolve")]
28 NoSelections,
29 #[error("no selection is labeled {requested:?}; labels in this session: {available:?}")]
30 UnknownLabel {
31 requested: String,
32 available: Vec<String>,
33 },
34 #[error(
35 "selection {selection} references monitor {monitor}, which the \
36 session does not describe"
37 )]
38 UnknownMonitor { selection: usize, monitor: usize },
39 #[error(
40 "the session has no target window — window-relative points need a \
41 session captured with --target"
42 )]
43 NoTarget,
44 #[error(
45 "selection {selection} ({label:?}) has no window-relative \
46 coordinates: it was marked on a different monitor than the target \
47 window — ask in global or monitor space instead"
48 )]
49 OffTargetMonitor { selection: usize, label: String },
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize)]
54pub struct Resolution {
55 pub index: usize,
57 pub label: String,
58 pub monitor: usize,
59 pub scale: f64,
62 pub space: &'static str,
63 pub units: &'static str,
64 pub point: Point,
74 pub region: Shape,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub score: Option<f64>,
81 #[serde(skip_serializing_if = "Option::is_none")]
84 pub delta: Option<Delta>,
85}
86
87pub fn resolve(
100 session: &SessionFile,
101 label: Option<&str>,
102 origin: Origin,
103 units: Resolved,
104 drift: &dyn Fn(usize) -> Option<(f64, Delta)>,
105) -> Result<Vec<Resolution>, ResolveError> {
106 if session.selections.is_empty() {
107 return Err(ResolveError::NoSelections);
108 }
109 let wanted = crate::session::select_by_label(session, label);
110 if wanted.is_empty() {
111 return Err(ResolveError::UnknownLabel {
112 requested: label.unwrap_or_default().to_string(),
113 available: crate::session::distinct_labels(session.selections.iter()),
114 });
115 }
116 if matches!(origin, Origin::Window) && session.target.is_none() {
117 return Err(ResolveError::NoTarget);
118 }
119 wanted
120 .into_iter()
121 .map(|(index, record)| one(session, index, record, origin, units, drift))
122 .collect()
123}
124
125fn one(
126 session: &SessionFile,
127 index: usize,
128 record: &SelectionRecord,
129 origin: Origin,
130 units: Resolved,
131 drift: &dyn Fn(usize) -> Option<(f64, Delta)>,
132) -> Result<Resolution, ResolveError> {
133 let monitor = session
134 .monitors
135 .iter()
136 .find(|m| m.index == record.monitor)
137 .ok_or(ResolveError::UnknownMonitor {
138 selection: index,
139 monitor: record.monitor,
140 })?;
141
142 let stored = stored_shape(record, index, origin)?;
143 let moved = drift(index);
144
145 let region = match moved {
150 Some((_, d)) => stored.translated(d.dx, d.dy),
151 None => stored,
152 };
153 let physical = region.click_point();
154
155 let (point, region) = match units {
156 Resolved::Physical => (physical, region),
157 Resolved::Logical => (
158 logical_of(physical, monitor.scale),
159 scaled(®ion, monitor.scale),
160 ),
161 };
162
163 Ok(Resolution {
164 index,
165 label: record.label.clone(),
166 monitor: record.monitor,
167 scale: monitor.scale,
168 space: origin.label(),
169 units: units.label(),
170 point,
171 region,
172 score: moved.map(|(score, _)| score),
173 delta: moved.map(|(_, d)| d),
174 })
175}
176
177fn stored_shape(
181 record: &SelectionRecord,
182 index: usize,
183 origin: Origin,
184) -> Result<Shape, ResolveError> {
185 match origin {
186 Origin::Global => Ok(record.global_px.clone()),
187 Origin::Monitor(_) => Ok(record.px.clone()),
188 Origin::Window => record
189 .window_px
190 .clone()
191 .ok_or_else(|| ResolveError::OffTargetMonitor {
192 selection: index,
193 label: record.label.clone(),
194 }),
195 }
196}
197
198fn scaled(shape: &Shape, scale: f64) -> Shape {
201 let p = |x: i32, y: i32| logical_of(Point::new(x, y), scale);
202 match *shape {
203 Shape::Rect(r) => {
204 let origin = p(r.x, r.y);
205 let far = p(r.x + r.w, r.y + r.h);
206 Shape::Rect(crate::geometry::Rect::new(
207 origin.x,
208 origin.y,
209 far.x - origin.x,
210 far.y - origin.y,
211 ))
212 }
213 Shape::Circle { cx, cy, r } => {
214 let c = p(cx, cy);
215 Shape::Circle {
216 cx: c.x,
217 cy: c.y,
218 r: (f64::from(r) / scale).round() as i32,
219 }
220 }
221 Shape::Ellipse { cx, cy, rx, ry } => {
222 let c = p(cx, cy);
223 Shape::Ellipse {
224 cx: c.x,
225 cy: c.y,
226 rx: (f64::from(rx) / scale).round() as i32,
227 ry: (f64::from(ry) / scale).round() as i32,
228 }
229 }
230 Shape::Triangle {
231 ax,
232 ay,
233 bx,
234 by,
235 cx,
236 cy,
237 } => {
238 let (a, b, c) = (p(ax, ay), p(bx, by), p(cx, cy));
239 Shape::Triangle {
240 ax: a.x,
241 ay: a.y,
242 bx: b.x,
243 by: b.y,
244 cx: c.x,
245 cy: c.y,
246 }
247 }
248 Shape::Poly { ref points } => Shape::Poly {
249 points: points.iter().map(|q| p(q.x, q.y)).collect(),
250 },
251 }
252}
253
254impl Resolved {
255 #[must_use]
257 pub const fn label(self) -> &'static str {
258 match self {
259 Self::Physical => "physical",
260 Self::Logical => "logical",
261 }
262 }
263}
264
265#[must_use]
271pub fn all_resolved(results: &[Resolution], relocated: bool) -> bool {
272 !results.is_empty() && (!relocated || results.iter().all(|r| r.score.is_some()))
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::geometry::Rect;
279 use crate::selection::Selection;
280 use crate::session::MonitorRecord;
281
282 fn none(_: usize) -> Option<(f64, Delta)> {
283 None
284 }
285
286 fn mixed_dpi(labels: &[(&str, usize, Rect)]) -> SessionFile {
290 let selections: Vec<Selection> = labels
291 .iter()
292 .map(|&(label, monitor, rect)| {
293 let mut sel = Selection::new(Shape::Rect(rect), monitor);
294 sel.label = label.to_string();
295 sel
296 })
297 .collect();
298 let crops: Vec<String> = (0..labels.len()).map(|i| format!("c{i}.png")).collect();
299 SessionFile::build(
300 "test",
301 "2026-08-01T00:00:00Z".into(),
302 vec![
303 MonitorRecord {
304 index: 0,
305 name: "Left".into(),
306 primary: true,
307 origin_px: Point::new(0, 0),
308 size_px: crate::geometry::Size::new(1920, 1080),
309 scale: 1.0,
310 },
311 MonitorRecord {
312 index: 1,
313 name: "Right Retina".into(),
314 primary: false,
315 origin_px: Point::new(1920, 0),
316 size_px: crate::geometry::Size::new(2560, 1440),
317 scale: 2.0,
318 },
319 ],
320 &selections,
321 &crops,
322 None,
323 )
324 }
325
326 #[test]
327 fn a_physical_answer_is_the_stored_click_point() {
328 let file = mixed_dpi(&[("submit", 0, Rect::new(800, 400, 100, 80))]);
329 let out = resolve(&file, None, Origin::Global, Resolved::Physical, &none).unwrap();
330 assert_eq!(out.len(), 1);
331 assert_eq!(out[0].point, Point::new(850, 440));
332 assert!((out[0].scale - 1.0).abs() < f64::EPSILON);
333 assert_eq!(out[0].units, "physical");
334 assert_eq!(out[0].space, "global");
335 }
336
337 #[test]
338 fn each_selection_converts_through_its_own_monitors_scale() {
339 let file = mixed_dpi(&[
342 ("left", 0, Rect::new(800, 400, 100, 80)),
343 ("right", 1, Rect::new(100, 200, 40, 60)),
344 ]);
345 let out = resolve(&file, None, Origin::Global, Resolved::Logical, &none).unwrap();
346
347 assert_eq!(out[0].point, Point::new(850, 440));
349 assert!((out[0].scale - 1.0).abs() < f64::EPSILON);
350
351 assert!((out[1].scale - 2.0).abs() < f64::EPSILON);
354 assert_eq!(out[1].point, Point::new(1020, 115));
355 }
356
357 #[test]
358 fn monitor_space_answers_in_monitor_local_coordinates() {
359 let file = mixed_dpi(&[("right", 1, Rect::new(100, 200, 40, 60))]);
360 let global = resolve(&file, None, Origin::Global, Resolved::Physical, &none).unwrap();
361 let local = resolve(&file, None, Origin::Monitor(1), Resolved::Physical, &none).unwrap();
362 assert_eq!(global[0].point, Point::new(2040, 230));
363 assert_eq!(local[0].point, Point::new(120, 230), "origin removed");
364 assert_eq!(local[0].space, "monitor");
365 }
366
367 #[test]
368 fn monitor_space_needs_no_index_and_spans_monitors() {
369 let file = mixed_dpi(&[
374 ("left", 0, Rect::new(800, 400, 100, 80)),
375 ("right", 1, Rect::new(100, 200, 40, 60)),
376 ]);
377 let zero = resolve(&file, None, Origin::Monitor(0), Resolved::Physical, &none).unwrap();
378 let one = resolve(&file, None, Origin::Monitor(1), Resolved::Physical, &none).unwrap();
379 assert_eq!(zero, one, "the index carries nothing here");
380 assert_eq!(zero[0].point, Point::new(850, 440), "monitor 0, local");
381 assert_eq!(zero[1].point, Point::new(120, 230), "monitor 1, local");
382 assert_eq!((zero[0].monitor, zero[1].monitor), (0, 1));
383 }
384
385 #[test]
386 fn the_region_travels_in_the_same_units_as_the_point() {
387 let file = mixed_dpi(&[("right", 1, Rect::new(100, 200, 40, 60))]);
388 let out = resolve(&file, None, Origin::Monitor(1), Resolved::Logical, &none).unwrap();
389 let Shape::Rect(r) = out[0].region else {
390 panic!("a rect stays a rect")
391 };
392 assert_eq!((r.x, r.y, r.w, r.h), (50, 100, 20, 30));
393 assert!(
394 r.contains(out[0].point),
395 "the reported point must lie inside the reported region"
396 );
397 }
398
399 #[test]
400 fn drift_is_applied_before_the_units_are_converted() {
401 let file = mixed_dpi(&[("right", 1, Rect::new(100, 200, 40, 60))]);
404 let moved = |_: usize| Some((0.97, Delta { dx: 40, dy: 0 }));
405 let out = resolve(&file, None, Origin::Monitor(1), Resolved::Logical, &moved).unwrap();
406 assert_eq!(out[0].point, Point::new(80, 115));
407 assert_eq!(out[0].delta, Some(Delta { dx: 40, dy: 0 }));
408 assert_eq!(out[0].score, Some(0.97));
409 }
410
411 #[test]
412 fn without_relocation_no_score_or_delta_is_reported() {
413 let file = mixed_dpi(&[("submit", 0, Rect::new(800, 400, 100, 80))]);
414 let out = resolve(&file, None, Origin::Global, Resolved::Physical, &none).unwrap();
415 assert!(out[0].score.is_none() && out[0].delta.is_none());
416
417 let json = serde_json::to_value(&out[0]).unwrap();
418 assert!(json.get("score").is_none() && json.get("delta").is_none());
419 }
420
421 #[test]
422 fn a_label_restricts_the_set_and_an_unknown_one_lists_what_exists() {
423 let file = mixed_dpi(&[
424 ("left", 0, Rect::new(0, 0, 10, 10)),
425 ("right", 1, Rect::new(0, 0, 10, 10)),
426 ]);
427 let out = resolve(
428 &file,
429 Some("RIGHT"),
430 Origin::Global,
431 Resolved::Physical,
432 &none,
433 )
434 .unwrap();
435 assert_eq!(out.len(), 1);
436 assert_eq!(out[0].label, "right");
437
438 let err = resolve(
439 &file,
440 Some("nope"),
441 Origin::Global,
442 Resolved::Physical,
443 &none,
444 )
445 .unwrap_err();
446 assert_eq!(
447 err,
448 ResolveError::UnknownLabel {
449 requested: "nope".into(),
450 available: vec!["left".into(), "right".into()],
451 }
452 );
453 }
454
455 #[test]
456 fn a_selection_off_the_target_monitor_has_no_window_answer() {
457 use crate::session::TargetRecord;
458 let mut on_target = Selection::new(Shape::Rect(Rect::new(110, 60, 20, 20)), 0);
464 on_target.label = "on-target".into();
465 let mut elsewhere = Selection::new(Shape::Rect(Rect::new(10, 10, 20, 20)), 1);
466 elsewhere.label = "elsewhere".into();
467 let file = SessionFile::build(
468 "test",
469 "2026-08-01T00:00:00Z".into(),
470 vec![
471 MonitorRecord {
472 index: 0,
473 name: "Left".into(),
474 primary: true,
475 origin_px: Point::new(0, 0),
476 size_px: crate::geometry::Size::new(1920, 1080),
477 scale: 1.0,
478 },
479 MonitorRecord {
480 index: 1,
481 name: "Right".into(),
482 primary: false,
483 origin_px: Point::new(1920, 0),
484 size_px: crate::geometry::Size::new(1920, 1080),
485 scale: 1.0,
486 },
487 ],
488 &[on_target, elsewhere],
489 &["c0.png".into(), "c1.png".into()],
490 Some(TargetRecord {
491 app: "Editor".into(),
492 title: "main.rs".into(),
493 monitor: 0,
494 origin_px: Point::new(100, 50),
495 size_px: crate::geometry::Size::new(800, 600),
496 }),
497 );
498
499 let ok = resolve(
501 &file,
502 Some("on-target"),
503 Origin::Window,
504 Resolved::Physical,
505 &none,
506 )
507 .unwrap();
508 assert_eq!(ok[0].point, Point::new(20, 20));
509
510 let err = resolve(
511 &file,
512 Some("elsewhere"),
513 Origin::Window,
514 Resolved::Physical,
515 &none,
516 )
517 .unwrap_err();
518 assert_eq!(
519 err,
520 ResolveError::OffTargetMonitor {
521 selection: 1,
522 label: "elsewhere".into(),
523 }
524 );
525 }
526
527 #[test]
528 fn window_space_needs_a_target_session() {
529 let file = mixed_dpi(&[("submit", 0, Rect::new(0, 0, 10, 10))]);
530 assert_eq!(
531 resolve(&file, None, Origin::Window, Resolved::Physical, &none).unwrap_err(),
532 ResolveError::NoTarget
533 );
534 }
535
536 #[test]
537 fn an_empty_session_is_refused_before_the_label_is_considered() {
538 let file = mixed_dpi(&[]);
539 assert_eq!(
540 resolve(
541 &file,
542 Some("anything"),
543 Origin::Global,
544 Resolved::Physical,
545 &none
546 )
547 .unwrap_err(),
548 ResolveError::NoSelections,
549 "an empty session and an unmatched label are different mistakes"
550 );
551 }
552
553 #[test]
554 fn ok_is_true_without_relocation_and_follows_the_scores_with_it() {
555 let file = mixed_dpi(&[("submit", 0, Rect::new(0, 0, 10, 10))]);
556 let still = resolve(&file, None, Origin::Global, Resolved::Physical, &none).unwrap();
557 assert!(all_resolved(&still, false));
558 assert!(
559 !all_resolved(&still, true),
560 "asked to relocate and given no score, the point is not trustworthy"
561 );
562
563 let moved = |_: usize| Some((0.99, Delta { dx: 1, dy: 1 }));
564 let found = resolve(&file, None, Origin::Global, Resolved::Physical, &moved).unwrap();
565 assert!(all_resolved(&found, true));
566 assert!(!all_resolved(&[], false), "nothing resolved is not success");
567 }
568
569 #[test]
570 fn every_shape_kind_converts_within_a_pixel_of_its_own_click_point() {
571 for shape in [
577 Shape::Circle {
578 cx: 100,
579 cy: 200,
580 r: 40,
581 },
582 Shape::Ellipse {
583 cx: 100,
584 cy: 200,
585 rx: 40,
586 ry: 20,
587 },
588 Shape::Triangle {
589 ax: 0,
590 ay: 0,
591 bx: 100,
592 by: 0,
593 cx: 50,
594 cy: 80,
595 },
596 Shape::Poly {
597 points: vec![Point::new(0, 0), Point::new(100, 0), Point::new(100, 100)],
598 },
599 ] {
600 let converted = scaled(&shape, 2.0).click_point();
601 let reported = logical_of(shape.click_point(), 2.0);
602 assert!(
603 (converted.x - reported.x).abs() <= 1 && (converted.y - reported.y).abs() <= 1,
604 "{shape:?}: converted {converted:?} vs reported {reported:?}"
605 );
606 }
607 }
608
609 #[test]
610 fn the_reported_point_is_the_true_interior_point_converted() {
611 let concave = Shape::Poly {
616 points: vec![
617 Point::new(0, 0),
618 Point::new(100, 0),
619 Point::new(100, 20),
620 Point::new(20, 20),
621 Point::new(20, 100),
622 Point::new(0, 100),
623 ],
624 };
625 let mut sel = Selection::new(concave.clone(), 1);
626 sel.label = "L-shape".into();
627 let file = SessionFile::build(
628 "test",
629 "2026-08-01T00:00:00Z".into(),
630 vec![MonitorRecord {
631 index: 1,
632 name: "Retina".into(),
633 primary: true,
634 origin_px: Point::new(0, 0),
635 size_px: crate::geometry::Size::new(2560, 1440),
636 scale: 2.0,
637 }],
638 &[sel],
639 &["c0.png".into()],
640 None,
641 );
642 let out = resolve(&file, None, Origin::Monitor(1), Resolved::Logical, &none).unwrap();
643 assert_eq!(out[0].point, logical_of(concave.click_point(), 2.0));
644 assert!(
645 concave.hit_test(concave.click_point()),
646 "the physical point it derives from is inside the real shape"
647 );
648 }
649}