Skip to main content

pixelcoords_core/
resolve.rs

1//! "Where do I click for this label, right now?" — the logic behind
2//! `pixelcoords resolve`.
3//!
4//! Every piece of this already existed, in pieces each consumer had to
5//! reassemble: the session holds the region and its monitor's scale,
6//! `geometry::click_point` finds an interior point to aim at, `locate`
7//! corrects for drift, and `emit` knows which unit each platform's input
8//! APIs expect. A consumer that wanted the one thing every consumer wants
9//! had to call `find`, parse a bbox, pull in this crate for the click
10//! point, then redo the logical/physical conversion `emit` already knew.
11//!
12//! Each reassembly is a chance to get DPI wrong, and it pushes geometry
13//! into consumers — which is what this crate exists to prevent. `emit`
14//! stays what it is, ready-to-paste code for humans; `resolve` is the
15//! machine answer underneath it.
16
17use 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/// Where to act for one selection, and what the answer is measured in.
53#[derive(Debug, Clone, PartialEq, Serialize)]
54pub struct Resolution {
55    /// Index into `session.selections` — this row's identity.
56    pub index: usize,
57    pub label: String,
58    pub monitor: usize,
59    /// The monitor's DPI factor, so a consumer can check the conversion
60    /// rather than trusting it.
61    pub scale: f64,
62    pub space: &'static str,
63    pub units: &'static str,
64    /// The point to act on.
65    ///
66    /// In logical units this is the *physical* interior point converted,
67    /// not an interior point of the converted region. The two can differ
68    /// by a pixel, and this is the one that matters: a consumer clicks in
69    /// logical points and the window server maps that back to physical,
70    /// landing inside the region a human actually marked. Deriving it
71    /// from the rounded-down shape would optimize for a number that
72    /// nothing clicks.
73    pub point: Point,
74    /// The region it came from, in the same space and units — so a caller
75    /// can draw what it is about to click. Converted independently of
76    /// `point`, so on a scaled display the two may round differently.
77    pub region: Shape,
78    /// Match score from the relocation pass; absent without `--relocate`.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub score: Option<f64>,
81    /// How far the region moved since the session was saved; absent
82    /// without `--relocate`, and absent with it when nothing moved.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub delta: Option<Delta>,
85}
86
87/// The click points for a session's selections, or the labeled subset.
88///
89/// Pure session math: no capture, no permission, no clock. `drift` is
90/// consulted per selection index when the caller has relocated first —
91/// its `Delta` is applied in the session's own physical pixels, before
92/// any unit conversion, because that is the space the match reported in.
93///
94/// **`Origin::Monitor`'s index is ignored.** Every selection is reported
95/// in its own monitor's coordinates and every row carries `monitor`, so
96/// there is nothing for an index to disambiguate. That is the opposite of
97/// [`crate::verdict::assess`], which is handed one point and must be told
98/// which monitor it belongs to.
99pub 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    // Drift is measured in monitor-local physical pixels — the space the
146    // capture was matched in — so it is applied before the origin is
147    // reinterpreted or the units are converted. Doing it after would
148    // scale the delta by the DPI factor a second time.
149    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(&region, 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
177/// The shape the session already stores for this origin — nothing is
178/// derived here, so a resolved point is the same pixel `assert` tests
179/// against.
180fn 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
198/// A shape's every coordinate through `logical_of`, so the reported
199/// region is in the same units as the point inside it.
200fn 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    /// The name these units carry in JSON output.
256    #[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/// Every selection resolved — the aggregate `resolve` reports as `ok`.
266///
267/// Without relocation this is always true: session math cannot fail once
268/// the labels resolved. With it, a region that was not found
269/// unambiguously has no trustworthy point, and saying so is the point.
270#[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    /// Two monitors: index 0 at scale 1, index 1 at scale 2 and offset,
287    /// so a mixed-DPI conversion is the default case rather than a
288    /// special one.
289    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        // The trap this command exists to remove: one desktop, two DPI
340        // factors, and a consumer that divides everything by one of them.
341        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        // Monitor 0 is scale 1: logical == physical.
348        assert_eq!(out[0].point, Point::new(850, 440));
349        assert!((out[0].scale - 1.0).abs() < f64::EPSILON);
350
351        // Monitor 1 is scale 2 at global origin (1920, 0): the stored
352        // global click point is (2040, 230), halved to (1020, 115).
353        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        // Each selection comes back in its own monitor's coordinates, so
370        // a two-monitor session is answerable without naming one — the
371        // index in `Origin::Monitor` is not consulted, and passing a
372        // different one changes nothing.
373        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        // A 40px physical move on a scale-2 display is 20 logical points.
402        // Converting first and translating after would report 40.
403        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        // A target session records window-relative coordinates only for
459        // selections on the target's own monitor (`SelectionRecord::
460        // window_px`). One marked on the *other* display has no window
461        // answer at all, and inventing one would be a coordinate pointing
462        // nowhere — so it is named instead.
463        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        // On the target's monitor, window space subtracts the window origin.
500        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        // `point` is the physical click point converted, *not* the click
572        // point of the converted region — see the note on `Resolution`.
573        // The two are within a pixel of each other, and this pins that:
574        // a larger gap would mean `scaled` had distorted the shape rather
575        // than just rounded it.
576        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        // The distinction that matters when a consumer clicks: the point
612        // must map back to a pixel inside the *real* region, so it is the
613        // physical interior point converted, not an interior point of the
614        // rounded-down shape.
615        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}