Skip to main content

pixelcoords_core/
session.rs

1//! The versioned `session.json` schema.
2//!
3//! Coordinates are stored twice per selection: `px` (monitor-local physical
4//! pixels — the authoritative space everything is drawn and cropped in) and
5//! `global_px` (derived: monitor origin + local). Per-monitor `scale` lets
6//! consumers reconstruct logical points; the schema does not pretend there
7//! is a universal logical space.
8
9use serde::{Deserialize, Serialize};
10
11use crate::geometry::{Point, Shape, Size, ToolKind};
12use crate::selection::Selection;
13
14pub const SCHEMA_VERSION: u32 = 1;
15pub const APP_NAME: &str = "pixelcoords";
16
17/// How the session's frames were obtained. Optional in the schema —
18/// sessions written before it existed simply lack it.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum CaptureKind {
22    /// Whole monitors, no window attachment.
23    Desktop,
24    /// Attached to a window via `--target`: the `target` record carries
25    /// the window's identity for re-attachment.
26    Window,
27    /// One window chosen in the desktop portal's picker (`--pick`); the
28    /// portal reveals no window identity, so `target` is a placeholder.
29    Pick,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct SessionFile {
34    pub schema: u32,
35    pub app: AppInfo,
36    pub created_utc: String,
37    /// The OS the session was captured on — what a consumer needs to know
38    /// before re-attaching to the recorded window or coordinates:
39    /// `macos`, `windows`, `linux-x11`, or `linux-wayland`.
40    #[serde(skip_serializing_if = "Option::is_none", default)]
41    pub platform: Option<String>,
42    /// See [`CaptureKind`].
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    pub capture: Option<CaptureKind>,
45    /// A human-friendly session name ("microsoft teams") for pickers and
46    /// listings; the folder name identifies, this describes.
47    #[serde(skip_serializing_if = "Option::is_none", default)]
48    pub name: Option<String>,
49    pub monitors: Vec<MonitorRecord>,
50    /// Present when the session was captured with `--target`: the matched
51    /// window's identity and bounds at freeze time.
52    #[serde(skip_serializing_if = "Option::is_none", default)]
53    pub target: Option<TargetRecord>,
54    pub selections: Vec<SelectionRecord>,
55}
56
57/// The `--target` window as it stood at the instant of the freeze.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct TargetRecord {
60    pub app: String,
61    pub title: String,
62    pub monitor: usize,
63    /// Window origin in that monitor's local physical pixels.
64    pub origin_px: Point,
65    pub size_px: Size,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct AppInfo {
70    pub name: String,
71    pub version: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct MonitorRecord {
76    pub index: usize,
77    pub name: String,
78    pub primary: bool,
79    pub origin_px: Point,
80    pub size_px: Size,
81    pub scale: f64,
82}
83
84/// How a saved [`MonitorRecord`] resolved against the displays attached
85/// now. Both non-`Missing` variants carry an index into the candidate
86/// slice that was searched.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum MonitorMatch {
89    /// Same display, same geometry — safe to relocate against.
90    Found(usize),
91    /// A display of that name is attached, but its size or scale moved.
92    /// Kept distinct from `Missing` so the caller can say *what* changed
93    /// instead of "not attached", which would send the user hunting for a
94    /// cable when the real cause was a resolution change.
95    Changed(usize),
96    /// Nothing attached carries that name.
97    Missing,
98}
99
100/// Resolve a session's monitor against the live enumeration by **identity**
101/// — name, size and scale — rather than by enumeration index.
102///
103/// The index is not stable: it shuffles across replugs, reboots and
104/// dock/undock, so matching on it alone breaks re-attachment for a display
105/// that never actually changed. Everything needed to recognize the panel is
106/// already recorded at capture time; this uses it.
107///
108/// Ties (two of the same model attached at once) break toward the
109/// candidate whose index equals the record's, then toward the lowest index.
110/// Preferring the recorded index first means the common case — nothing
111/// moved, or something *else* was replugged — resolves to the same panel it
112/// did before, rather than to whichever twin happens to enumerate first.
113pub fn match_monitor(record: &MonitorRecord, candidates: &[MonitorRecord]) -> MonitorMatch {
114    // Within a pool of equally valid candidates: the one that also carries
115    // the recorded index, else the lowest index. Empty pool yields None,
116    // which is what lets the two calls below fall through in order.
117    let best = |pool: &[usize]| -> Option<usize> {
118        pool.iter()
119            .copied()
120            .find(|&i| candidates[i].index == record.index)
121            .or_else(|| pool.iter().copied().min_by_key(|&i| candidates[i].index))
122    };
123
124    let named: Vec<usize> = candidates
125        .iter()
126        .enumerate()
127        .filter(|(_, c)| c.name == record.name)
128        .map(|(i, _)| i)
129        .collect();
130    if named.is_empty() {
131        return MonitorMatch::Missing;
132    }
133    let exact: Vec<usize> = named
134        .iter()
135        .copied()
136        .filter(|&i| {
137            let c = &candidates[i];
138            // Scale is a float off the platform API; compare it the way the
139            // rest of this codebase does rather than with `==`.
140            c.size_px == record.size_px && (c.scale - record.scale).abs() < f64::EPSILON
141        })
142        .collect();
143    if let Some(i) = best(&exact) {
144        return MonitorMatch::Found(i);
145    }
146    best(&named).map_or(MonitorMatch::Missing, MonitorMatch::Changed)
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct SelectionRecord {
151    pub shape: ToolKind,
152    pub label: String,
153    pub monitor: usize,
154    pub px: Shape,
155    pub global_px: Shape,
156    /// Rotation in degrees (clockwise, `1..360`) about the bbox center of
157    /// `px`. Absent means unrotated. Never present for triangles — their
158    /// rotation is baked into the stored vertices — nor circles.
159    #[serde(skip_serializing_if = "Option::is_none", default)]
160    pub rot_deg: Option<i32>,
161    /// Coordinates relative to the target window's top-left; present only
162    /// in `--target` sessions, for selections on the target's monitor.
163    /// Negative values mean the selection lies outside the window.
164    #[serde(skip_serializing_if = "Option::is_none", default)]
165    pub window_px: Option<Shape>,
166    /// File name of this selection's PNG crop, relative to the session dir.
167    pub crop: String,
168}
169
170impl SessionFile {
171    /// Assemble a session. `created_utc` is supplied by the caller (this
172    /// crate has no clock); `crops` pairs 1:1 with `selections`.
173    pub fn build(
174        app_version: &str,
175        created_utc: String,
176        monitors: Vec<MonitorRecord>,
177        selections: &[Selection],
178        crops: &[String],
179        target: Option<TargetRecord>,
180    ) -> Self {
181        assert_eq!(selections.len(), crops.len(), "one crop name per selection");
182        let records = selections
183            .iter()
184            .zip(crops)
185            .map(|(s, crop)| {
186                let origin = monitors
187                    .iter()
188                    .find(|m| m.index == s.monitor)
189                    .map_or(Point::new(0, 0), |m| m.origin_px);
190                // Triangles bake rotation into their vertices (exact);
191                // rects keep an axis-aligned box plus rot_deg metadata.
192                let shape = s.shape.with_rotation_baked(s.rot_deg);
193                let rot_deg = match shape {
194                    Shape::Rect(_) | Shape::Ellipse { .. } => {
195                        Some(crate::geometry::normalize_deg(s.rot_deg)).filter(|d| *d != 0)
196                    }
197                    _ => None,
198                };
199                let window_px = target
200                    .as_ref()
201                    .filter(|t| t.monitor == s.monitor)
202                    .map(|t| shape.translated(-t.origin_px.x, -t.origin_px.y));
203                SelectionRecord {
204                    shape: shape.kind(),
205                    label: s.label.clone(),
206                    monitor: s.monitor,
207                    global_px: shape.translated(origin.x, origin.y),
208                    px: shape,
209                    rot_deg,
210                    window_px,
211                    crop: crop.clone(),
212                }
213            })
214            .collect();
215        Self {
216            schema: SCHEMA_VERSION,
217            app: AppInfo {
218                name: APP_NAME.to_string(),
219                version: app_version.to_string(),
220            },
221            created_utc,
222            platform: None,
223            capture: None,
224            name: None,
225            monitors,
226            target,
227            selections: records,
228        }
229    }
230
231    /// Stamp provenance onto a built session. A resumed session passes
232    /// through what it loaded, so a file edited on another machine keeps
233    /// saying where it was captured.
234    #[must_use]
235    pub fn with_meta(
236        mut self,
237        platform: Option<String>,
238        capture: Option<CaptureKind>,
239        name: Option<String>,
240    ) -> Self {
241        self.platform = platform;
242        self.capture = capture;
243        self.name = name;
244        self
245    }
246}
247
248/// Rebuild editable selections from a saved session — the inverse of
249/// [`SessionFile::build`]. Shapes come back in monitor-local px; rects
250/// reclaim their `rot_deg` metadata, triangles keep rotation baked in
251/// their vertices (their records never carry `rot_deg`), and circles are
252/// rotation-free. Feed the result to `SelectionSet::seed`.
253///
254/// In a target session, selections whose shape falls outside the window's
255/// rect are **dropped**. Older builds recorded whatever the user marked
256/// on the whole monitor, including junk outside the window, and their
257/// stored `window_px` came out with negative coordinates. This build
258/// refuses to let a user act on those, so a resumed session should not
259/// bring them back. The dropped labels are returned so the caller can
260/// tell the user what happened.
261pub fn restore_selections(file: &SessionFile) -> (Vec<Selection>, Vec<String>) {
262    let target_rect = file.target.as_ref().map(|t| {
263        (
264            t.monitor,
265            crate::geometry::Rect::new(0, 0, t.size_px.w, t.size_px.h),
266        )
267    });
268    let mut kept = Vec::with_capacity(file.selections.len());
269    let mut dropped = Vec::new();
270    for record in &file.selections {
271        // Compare against `window_px` (already translated to window-local)
272        // rather than reconstructing coordinates from `px`; the two must
273        // agree for a valid record, and window_px is the primary frame in
274        // a target session.
275        if let Some((monitor, rect)) = target_rect
276            && record.monitor == monitor
277        {
278            let Some(shape) = &record.window_px else {
279                dropped.push(record.label.clone());
280                continue;
281            };
282            let bbox = shape.bbox();
283            let inside = bbox.x >= rect.x
284                && bbox.y >= rect.y
285                && bbox.x + bbox.w <= rect.x + rect.w
286                && bbox.y + bbox.h <= rect.y + rect.h;
287            if !inside {
288                dropped.push(record.label.clone());
289                continue;
290            }
291        }
292        kept.push(Selection {
293            shape: record.px.clone(),
294            label: record.label.clone(),
295            monitor: record.monitor,
296            rot_deg: record.rot_deg.unwrap_or(0),
297        });
298    }
299    (kept, dropped)
300}
301
302/// The selections a `--label` restricts to, paired with their index in
303/// the session — the identity every report row carries. `None` selects
304/// everything. Matching is ASCII case-insensitive, as the window matcher
305/// is.
306///
307/// An empty result is the caller's to report: each command refuses in its
308/// own error type, and only the caller knows whether an empty *session*
309/// or an unmatched *label* is the cause. Pair it with `distinct_labels`
310/// to say what the session does carry.
311pub fn select_by_label<'a>(
312    session: &'a SessionFile,
313    label: Option<&str>,
314) -> Vec<(usize, &'a SelectionRecord)> {
315    session
316        .selections
317        .iter()
318        .enumerate()
319        .filter(|(_, record)| label.is_none_or(|want| record.label.eq_ignore_ascii_case(want)))
320        .collect()
321}
322
323/// The labels a `--label` could have matched, in session order,
324/// deduplicated ASCII case-insensitively; unlabeled selections contribute
325/// nothing.
326///
327/// Takes an iterator rather than the session because the caller decides
328/// what "could have matched" means: `assert` lists labels among its
329/// *space-filtered* candidates, since a monitor-space question cannot be
330/// answered by a selection on another monitor.
331pub fn distinct_labels<'a>(records: impl Iterator<Item = &'a SelectionRecord>) -> Vec<String> {
332    let mut labels: Vec<String> = Vec::new();
333    for record in records {
334        if record.label.is_empty() {
335            continue;
336        }
337        if labels.iter().any(|l| l.eq_ignore_ascii_case(&record.label)) {
338            continue;
339        }
340        labels.push(record.label.clone());
341    }
342    labels
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::geometry::Rect;
349
350    fn monitor(index: usize, ox: i32, oy: i32) -> MonitorRecord {
351        MonitorRecord {
352            index,
353            name: format!("Display {index}"),
354            primary: index == 0,
355            origin_px: Point::new(ox, oy),
356            size_px: Size::new(1920, 1080),
357            scale: 2.0,
358        }
359    }
360
361    /// A display identified by name, so tests can express "the same panel,
362    /// enumerated somewhere else".
363    fn panel(index: usize, name: &str, w: i32, h: i32, scale: f64) -> MonitorRecord {
364        MonitorRecord {
365            index,
366            name: name.into(),
367            primary: index == 0,
368            origin_px: Point::new(0, 0),
369            size_px: Size::new(w, h),
370            scale,
371        }
372    }
373
374    #[test]
375    fn a_replug_that_reorders_enumeration_still_finds_the_panel() {
376        // The bug this matcher exists for: same two displays, swapped
377        // enumeration order. Index-based lookup would hand back the wrong
378        // panel — or nothing.
379        let saved = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
380        let live = [
381            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
382            panel(1, "Built-in Retina Display", 3600, 2338, 2.0),
383        ];
384        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(0));
385    }
386
387    #[test]
388    fn nothing_moved_resolves_to_the_recorded_index() {
389        let saved = panel(1, "Built-in Retina Display", 3600, 2338, 2.0);
390        let live = [
391            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
392            panel(1, "Built-in Retina Display", 3600, 2338, 2.0),
393        ];
394        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
395    }
396
397    #[test]
398    fn identical_twins_break_toward_the_recorded_index_then_the_lowest() {
399        let live = [
400            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
401            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
402        ];
403        // The recorded index is present among the twins, so it wins.
404        let saved_one = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
405        assert_eq!(match_monitor(&saved_one, &live), MonitorMatch::Found(1));
406
407        // The recorded index is gone; the tie breaks toward the lowest,
408        // deterministically rather than on enumeration luck.
409        let saved_seven = panel(7, "DELL U2723QE", 3840, 2160, 1.0);
410        assert_eq!(match_monitor(&saved_seven, &live), MonitorMatch::Found(0));
411    }
412
413    #[test]
414    fn the_lowest_index_wins_regardless_of_enumeration_order() {
415        // Candidates are searched in slice order, but the tie-break is on
416        // the recorded index — so a twin listed first does not win by
417        // position alone.
418        let saved = panel(9, "DELL U2723QE", 3840, 2160, 1.0);
419        let live = [
420            panel(3, "DELL U2723QE", 3840, 2160, 1.0),
421            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
422        ];
423        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
424    }
425
426    #[test]
427    fn a_resized_display_is_changed_not_missing() {
428        // Template matching survives movement, not a resolution change —
429        // but the user needs to hear "it changed", not "it is unplugged".
430        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
431        let live = [panel(0, "DELL U2723QE", 2560, 1440, 1.0)];
432        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Changed(0));
433    }
434
435    #[test]
436    fn a_rescaled_display_is_changed_not_missing() {
437        let saved = panel(0, "Built-in Retina Display", 3600, 2338, 2.0);
438        let live = [panel(0, "Built-in Retina Display", 3600, 2338, 1.0)];
439        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Changed(0));
440    }
441
442    #[test]
443    fn an_exact_match_beats_a_changed_one_of_the_same_name() {
444        // Two panels share a name; one still matches the session exactly.
445        // Identity must win over the recorded index.
446        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
447        let live = [
448            panel(0, "DELL U2723QE", 2560, 1440, 1.0),
449            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
450        ];
451        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
452    }
453
454    #[test]
455    fn an_absent_display_is_missing_even_when_something_else_fits() {
456        // Same geometry, different panel: not the display the session used.
457        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
458        let live = [panel(0, "LG UltraFine", 3840, 2160, 1.0)];
459        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Missing);
460    }
461
462    #[test]
463    fn no_displays_at_all_is_missing() {
464        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
465        assert_eq!(match_monitor(&saved, &[]), MonitorMatch::Missing);
466    }
467
468    /// A session of labeled rects on monitor 0, in the given order.
469    fn labeled(labels: &[&str]) -> SessionFile {
470        let selections: Vec<Selection> = labels
471            .iter()
472            .map(|label| {
473                let mut sel = Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0);
474                sel.label = (*label).to_string();
475                sel
476            })
477            .collect();
478        let crops: Vec<String> = (0..labels.len()).map(|i| format!("crop-{i}.png")).collect();
479        SessionFile::build(
480            "test",
481            "2026-07-27T00:00:00Z".into(),
482            vec![monitor(0, 0, 0)],
483            &selections,
484            &crops,
485            None,
486        )
487    }
488
489    #[test]
490    fn select_by_label_keeps_session_indices() {
491        let file = labeled(&["submit", "cancel", "submit"]);
492
493        let all = select_by_label(&file, None);
494        assert_eq!(all.len(), 3, "no label selects everything");
495        assert_eq!(all.iter().map(|(i, _)| *i).collect::<Vec<_>>(), [0, 1, 2]);
496
497        // The index is the record's identity in the file, not its position
498        // in the filtered result — every report row is keyed by it.
499        let some = select_by_label(&file, Some("submit"));
500        assert_eq!(some.iter().map(|(i, _)| *i).collect::<Vec<_>>(), [0, 2]);
501    }
502
503    #[test]
504    fn select_by_label_matches_case_insensitively_and_can_come_up_empty() {
505        let file = labeled(&["Submit"]);
506        assert_eq!(select_by_label(&file, Some("SUBMIT")).len(), 1);
507        assert!(
508            select_by_label(&file, Some("nope")).is_empty(),
509            "an unmatched label is an empty result, not an error — the \
510             caller decides how to refuse"
511        );
512    }
513
514    #[test]
515    fn distinct_labels_dedupes_case_insensitively_and_drops_blanks() {
516        let file = labeled(&["submit", "", "SUBMIT", "cancel"]);
517        assert_eq!(
518            distinct_labels(file.selections.iter()),
519            ["submit", "cancel"],
520            "first spelling wins, session order is kept, unlabeled \
521             selections contribute nothing"
522        );
523    }
524
525    #[test]
526    fn distinct_labels_reports_only_what_it_is_given() {
527        // The iterator is the point: a monitor-space question lists the
528        // labels on *that* monitor, not every label in the session.
529        let file = labeled(&["submit", "cancel"]);
530        let first_only = distinct_labels(file.selections.iter().take(1));
531        assert_eq!(first_only, ["submit"]);
532    }
533
534    #[test]
535    fn global_is_origin_plus_local() {
536        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 1);
537        sel.label = "target".into();
538        let file = SessionFile::build(
539            "0.1.0",
540            "2026-07-27T00:00:00Z".into(),
541            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
542            &[sel],
543            &["crop-0-target.png".into()],
544            None,
545        );
546        assert_eq!(
547            file.selections[0].px,
548            Shape::Rect(Rect::new(10, 20, 30, 40))
549        );
550        assert_eq!(
551            file.selections[0].global_px,
552            Shape::Rect(Rect::new(1930, 20, 30, 40))
553        );
554    }
555
556    #[test]
557    fn json_shape_is_stable() {
558        let sel = Selection::new(Shape::Circle { cx: 5, cy: 6, r: 7 }, 0);
559        let file = SessionFile::build(
560            "0.1.0",
561            "2026-07-27T00:00:00Z".into(),
562            vec![monitor(0, 0, 0)],
563            &[sel],
564            &["crop-0.png".into()],
565            None,
566        );
567        let json = serde_json::to_value(&file).unwrap();
568        assert_eq!(json["schema"], 1);
569        assert_eq!(json["app"]["name"], "pixelcoords");
570        assert_eq!(json["selections"][0]["shape"], "circle");
571        assert_eq!(json["selections"][0]["px"]["cx"], 5);
572        assert_eq!(json["selections"][0]["px"]["r"], 7);
573        assert_eq!(json["monitors"][0]["scale"], 2.0);
574    }
575
576    #[test]
577    fn round_trips_through_json() {
578        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
579        let file = SessionFile::build(
580            "0.1.0",
581            "2026-07-27T00:00:00Z".into(),
582            vec![monitor(0, 0, 0)],
583            &[sel],
584            &["c.png".into()],
585            None,
586        );
587        let json = serde_json::to_string(&file).unwrap();
588        let back: SessionFile = serde_json::from_str(&json).unwrap();
589        assert_eq!(back, file);
590    }
591
592    #[test]
593    fn target_yields_window_relative_coords() {
594        let on_target = Selection::new(Shape::Rect(Rect::new(500, 300, 40, 20)), 0);
595        let elsewhere = Selection::new(Shape::Rect(Rect::new(1, 1, 5, 5)), 1);
596        let target = TargetRecord {
597            app: "Notepad".into(),
598            title: "notes.txt".into(),
599            monitor: 0,
600            origin_px: Point::new(400, 250),
601            size_px: Size::new(800, 600),
602        };
603        let file = SessionFile::build(
604            "0.1.0",
605            "2026-07-27T00:00:00Z".into(),
606            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
607            &[on_target, elsewhere],
608            &["a.png".into(), "b.png".into()],
609            Some(target),
610        );
611        assert_eq!(
612            file.selections[0].window_px,
613            Some(Shape::Rect(Rect::new(100, 50, 40, 20)))
614        );
615        assert_eq!(file.selections[1].window_px, None);
616        assert_eq!(file.target.as_ref().unwrap().title, "notes.txt");
617    }
618
619    #[test]
620    fn rotation_is_metadata_for_rects_and_baked_for_triangles() {
621        let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
622        rect_sel.rot_deg = 45;
623        let mut tri_sel = Selection::new(
624            Shape::Triangle {
625                ax: 200,
626                ay: 100,
627                bx: 100,
628                by: 200,
629                cx: 300,
630                cy: 200,
631            },
632            0,
633        );
634        tri_sel.rot_deg = 180;
635        let plain = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
636
637        let file = SessionFile::build(
638            "0.1.0",
639            "2026-07-27T00:00:00Z".into(),
640            vec![monitor(0, 0, 0)],
641            &[rect_sel, tri_sel, plain],
642            &["a.png".into(), "b.png".into(), "c.png".into()],
643            None,
644        );
645        // Rect: axis-aligned px + rot_deg metadata.
646        assert_eq!(
647            file.selections[0].px,
648            Shape::Rect(Rect::new(10, 20, 30, 40))
649        );
650        assert_eq!(file.selections[0].rot_deg, Some(45));
651        // Triangle: rotation baked into vertices, no rot_deg.
652        assert_eq!(file.selections[1].rot_deg, None);
653        assert_eq!(
654            file.selections[1].px,
655            Shape::Triangle {
656                ax: 200,
657                ay: 200,
658                bx: 300,
659                by: 100,
660                cx: 100,
661                cy: 100,
662            }
663        );
664        // Unrotated: no rot_deg key at all in the JSON.
665        let json = serde_json::to_value(&file).unwrap();
666        assert!(json["selections"][2].get("rot_deg").is_none());
667        assert_eq!(json["selections"][0]["rot_deg"], 45);
668    }
669
670    #[test]
671    fn untargeted_session_omits_target_fields_in_json() {
672        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
673        let file = SessionFile::build(
674            "0.1.0",
675            "2026-07-27T00:00:00Z".into(),
676            vec![monitor(0, 0, 0)],
677            &[sel],
678            &["c.png".into()],
679            None,
680        );
681        let json = serde_json::to_value(&file).unwrap();
682        assert!(json.get("target").is_none());
683        assert!(json["selections"][0].get("window_px").is_none());
684    }
685
686    #[test]
687    fn restore_then_rebuild_reproduces_every_selection_record() {
688        // A rotated rect (metadata), a rotated triangle (baked), a circle,
689        // and a label: build -> restore -> build must reproduce the
690        // records exactly, which is what makes resume lossless.
691        let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
692        rect_sel.rot_deg = 45;
693        rect_sel.label = "spun".into();
694        let mut tri_sel = Selection::new(
695            Shape::Triangle {
696                ax: 200,
697                ay: 100,
698                bx: 100,
699                by: 200,
700                cx: 300,
701                cy: 200,
702            },
703            1,
704        );
705        tri_sel.rot_deg = 90;
706        let circle_sel = Selection::new(Shape::Circle { cx: 9, cy: 9, r: 5 }, 0);
707
708        let monitors = vec![monitor(0, 0, 0), monitor(1, 1920, 0)];
709        let crops: Vec<String> = vec!["a.png".into(), "b.png".into(), "c.png".into()];
710        let first = SessionFile::build(
711            "test",
712            "t".into(),
713            monitors.clone(),
714            &[rect_sel, tri_sel, circle_sel],
715            &crops,
716            None,
717        );
718        let (restored, _) = restore_selections(&first);
719        let second = SessionFile::build("test", "t".into(), monitors, &restored, &crops, None);
720        assert_eq!(first.selections, second.selections);
721    }
722
723    #[test]
724    fn provenance_is_optional_and_survives_round_trips() {
725        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
726        let file = SessionFile::build(
727            "test",
728            "t".into(),
729            vec![monitor(0, 0, 0)],
730            &[sel],
731            &["c.png".into()],
732            None,
733        )
734        .with_meta(
735            Some("macos".into()),
736            Some(CaptureKind::Desktop),
737            Some("microsoft teams".into()),
738        );
739        let json = serde_json::to_value(&file).unwrap();
740        assert_eq!(json["platform"], "macos");
741        assert_eq!(json["capture"], "desktop");
742        assert_eq!(json["name"], "microsoft teams");
743        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
744        assert_eq!(back, file);
745
746        // A session written before these fields existed still parses,
747        // and one built without them omits the keys entirely.
748        let old = r#"{"schema":1,"app":{"name":"pixelcoords","version":"0"},
749            "created_utc":"t","monitors":[],"selections":[]}"#;
750        let parsed: SessionFile = serde_json::from_str(old).unwrap();
751        assert_eq!(parsed.platform, None);
752        assert_eq!(parsed.capture, None);
753        assert_eq!(parsed.name, None);
754        let bare = SessionFile::build("test", "t".into(), vec![], &[], &[], None);
755        let json = serde_json::to_value(&bare).unwrap();
756        assert!(json.get("platform").is_none());
757        assert!(json.get("capture").is_none());
758        assert!(json.get("name").is_none());
759    }
760
761    #[test]
762    fn untagged_shape_deserializes_by_fields() {
763        let rect: Shape = serde_json::from_str(r#"{"x":1,"y":2,"w":3,"h":4}"#).unwrap();
764        assert_eq!(rect, Shape::Rect(Rect::new(1, 2, 3, 4)));
765        let circle: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"r":3}"#).unwrap();
766        assert_eq!(circle, Shape::Circle { cx: 1, cy: 2, r: 3 });
767        let ellipse: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"rx":3,"ry":4}"#).unwrap();
768        assert_eq!(
769            ellipse,
770            Shape::Ellipse {
771                cx: 1,
772                cy: 2,
773                rx: 3,
774                ry: 4,
775            }
776        );
777    }
778
779    #[test]
780    fn poly_records_serialize_their_vertices_and_round_trip() {
781        let sel = Selection::new(
782            Shape::Poly {
783                points: vec![Point::new(1, 2), Point::new(9, 2), Point::new(5, 9)],
784            },
785            0,
786        );
787        let file = SessionFile::build(
788            "test",
789            "t".into(),
790            vec![monitor(0, 0, 0)],
791            &[sel],
792            &["c.png".into()],
793            None,
794        );
795        let json = serde_json::to_value(&file).unwrap();
796        assert_eq!(json["selections"][0]["shape"], "poly");
797        assert_eq!(json["selections"][0]["px"]["points"][2]["x"], 5);
798        assert_eq!(
799            json["selections"][0].get("rot_deg"),
800            None,
801            "poly rotation is baked, never metadata"
802        );
803        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
804        assert_eq!(back, file);
805    }
806
807    #[test]
808    fn ellipse_records_carry_rotation_metadata_like_rects() {
809        let mut sel = Selection::new(
810            Shape::Ellipse {
811                cx: 50,
812                cy: 40,
813                rx: 20,
814                ry: 10,
815            },
816            0,
817        );
818        sel.rot_deg = 30;
819        let file = SessionFile::build(
820            "test",
821            "t".into(),
822            vec![monitor(0, 0, 0)],
823            &[sel],
824            &["c.png".into()],
825            None,
826        );
827        assert_eq!(file.selections[0].rot_deg, Some(30));
828        let json = serde_json::to_value(&file).unwrap();
829        assert_eq!(json["selections"][0]["shape"], "ellipse");
830        assert_eq!(json["selections"][0]["px"]["rx"], 20);
831        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
832        assert_eq!(back, file);
833    }
834}