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#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::geometry::Rect;
306
307    fn monitor(index: usize, ox: i32, oy: i32) -> MonitorRecord {
308        MonitorRecord {
309            index,
310            name: format!("Display {index}"),
311            primary: index == 0,
312            origin_px: Point::new(ox, oy),
313            size_px: Size::new(1920, 1080),
314            scale: 2.0,
315        }
316    }
317
318    /// A display identified by name, so tests can express "the same panel,
319    /// enumerated somewhere else".
320    fn panel(index: usize, name: &str, w: i32, h: i32, scale: f64) -> MonitorRecord {
321        MonitorRecord {
322            index,
323            name: name.into(),
324            primary: index == 0,
325            origin_px: Point::new(0, 0),
326            size_px: Size::new(w, h),
327            scale,
328        }
329    }
330
331    #[test]
332    fn a_replug_that_reorders_enumeration_still_finds_the_panel() {
333        // The bug this matcher exists for: same two displays, swapped
334        // enumeration order. Index-based lookup would hand back the wrong
335        // panel — or nothing.
336        let saved = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
337        let live = [
338            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
339            panel(1, "Built-in Retina Display", 3600, 2338, 2.0),
340        ];
341        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(0));
342    }
343
344    #[test]
345    fn nothing_moved_resolves_to_the_recorded_index() {
346        let saved = panel(1, "Built-in Retina Display", 3600, 2338, 2.0);
347        let live = [
348            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
349            panel(1, "Built-in Retina Display", 3600, 2338, 2.0),
350        ];
351        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
352    }
353
354    #[test]
355    fn identical_twins_break_toward_the_recorded_index_then_the_lowest() {
356        let live = [
357            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
358            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
359        ];
360        // The recorded index is present among the twins, so it wins.
361        let saved_one = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
362        assert_eq!(match_monitor(&saved_one, &live), MonitorMatch::Found(1));
363
364        // The recorded index is gone; the tie breaks toward the lowest,
365        // deterministically rather than on enumeration luck.
366        let saved_seven = panel(7, "DELL U2723QE", 3840, 2160, 1.0);
367        assert_eq!(match_monitor(&saved_seven, &live), MonitorMatch::Found(0));
368    }
369
370    #[test]
371    fn the_lowest_index_wins_regardless_of_enumeration_order() {
372        // Candidates are searched in slice order, but the tie-break is on
373        // the recorded index — so a twin listed first does not win by
374        // position alone.
375        let saved = panel(9, "DELL U2723QE", 3840, 2160, 1.0);
376        let live = [
377            panel(3, "DELL U2723QE", 3840, 2160, 1.0),
378            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
379        ];
380        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
381    }
382
383    #[test]
384    fn a_resized_display_is_changed_not_missing() {
385        // Template matching survives movement, not a resolution change —
386        // but the user needs to hear "it changed", not "it is unplugged".
387        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
388        let live = [panel(0, "DELL U2723QE", 2560, 1440, 1.0)];
389        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Changed(0));
390    }
391
392    #[test]
393    fn a_rescaled_display_is_changed_not_missing() {
394        let saved = panel(0, "Built-in Retina Display", 3600, 2338, 2.0);
395        let live = [panel(0, "Built-in Retina Display", 3600, 2338, 1.0)];
396        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Changed(0));
397    }
398
399    #[test]
400    fn an_exact_match_beats_a_changed_one_of_the_same_name() {
401        // Two panels share a name; one still matches the session exactly.
402        // Identity must win over the recorded index.
403        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
404        let live = [
405            panel(0, "DELL U2723QE", 2560, 1440, 1.0),
406            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
407        ];
408        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
409    }
410
411    #[test]
412    fn an_absent_display_is_missing_even_when_something_else_fits() {
413        // Same geometry, different panel: not the display the session used.
414        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
415        let live = [panel(0, "LG UltraFine", 3840, 2160, 1.0)];
416        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Missing);
417    }
418
419    #[test]
420    fn no_displays_at_all_is_missing() {
421        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
422        assert_eq!(match_monitor(&saved, &[]), MonitorMatch::Missing);
423    }
424
425    #[test]
426    fn global_is_origin_plus_local() {
427        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 1);
428        sel.label = "target".into();
429        let file = SessionFile::build(
430            "0.1.0",
431            "2026-07-27T00:00:00Z".into(),
432            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
433            &[sel],
434            &["crop-0-target.png".into()],
435            None,
436        );
437        assert_eq!(
438            file.selections[0].px,
439            Shape::Rect(Rect::new(10, 20, 30, 40))
440        );
441        assert_eq!(
442            file.selections[0].global_px,
443            Shape::Rect(Rect::new(1930, 20, 30, 40))
444        );
445    }
446
447    #[test]
448    fn json_shape_is_stable() {
449        let sel = Selection::new(Shape::Circle { cx: 5, cy: 6, r: 7 }, 0);
450        let file = SessionFile::build(
451            "0.1.0",
452            "2026-07-27T00:00:00Z".into(),
453            vec![monitor(0, 0, 0)],
454            &[sel],
455            &["crop-0.png".into()],
456            None,
457        );
458        let json = serde_json::to_value(&file).unwrap();
459        assert_eq!(json["schema"], 1);
460        assert_eq!(json["app"]["name"], "pixelcoords");
461        assert_eq!(json["selections"][0]["shape"], "circle");
462        assert_eq!(json["selections"][0]["px"]["cx"], 5);
463        assert_eq!(json["selections"][0]["px"]["r"], 7);
464        assert_eq!(json["monitors"][0]["scale"], 2.0);
465    }
466
467    #[test]
468    fn round_trips_through_json() {
469        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
470        let file = SessionFile::build(
471            "0.1.0",
472            "2026-07-27T00:00:00Z".into(),
473            vec![monitor(0, 0, 0)],
474            &[sel],
475            &["c.png".into()],
476            None,
477        );
478        let json = serde_json::to_string(&file).unwrap();
479        let back: SessionFile = serde_json::from_str(&json).unwrap();
480        assert_eq!(back, file);
481    }
482
483    #[test]
484    fn target_yields_window_relative_coords() {
485        let on_target = Selection::new(Shape::Rect(Rect::new(500, 300, 40, 20)), 0);
486        let elsewhere = Selection::new(Shape::Rect(Rect::new(1, 1, 5, 5)), 1);
487        let target = TargetRecord {
488            app: "Notepad".into(),
489            title: "notes.txt".into(),
490            monitor: 0,
491            origin_px: Point::new(400, 250),
492            size_px: Size::new(800, 600),
493        };
494        let file = SessionFile::build(
495            "0.1.0",
496            "2026-07-27T00:00:00Z".into(),
497            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
498            &[on_target, elsewhere],
499            &["a.png".into(), "b.png".into()],
500            Some(target),
501        );
502        assert_eq!(
503            file.selections[0].window_px,
504            Some(Shape::Rect(Rect::new(100, 50, 40, 20)))
505        );
506        assert_eq!(file.selections[1].window_px, None);
507        assert_eq!(file.target.as_ref().unwrap().title, "notes.txt");
508    }
509
510    #[test]
511    fn rotation_is_metadata_for_rects_and_baked_for_triangles() {
512        let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
513        rect_sel.rot_deg = 45;
514        let mut tri_sel = Selection::new(
515            Shape::Triangle {
516                ax: 200,
517                ay: 100,
518                bx: 100,
519                by: 200,
520                cx: 300,
521                cy: 200,
522            },
523            0,
524        );
525        tri_sel.rot_deg = 180;
526        let plain = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
527
528        let file = SessionFile::build(
529            "0.1.0",
530            "2026-07-27T00:00:00Z".into(),
531            vec![monitor(0, 0, 0)],
532            &[rect_sel, tri_sel, plain],
533            &["a.png".into(), "b.png".into(), "c.png".into()],
534            None,
535        );
536        // Rect: axis-aligned px + rot_deg metadata.
537        assert_eq!(
538            file.selections[0].px,
539            Shape::Rect(Rect::new(10, 20, 30, 40))
540        );
541        assert_eq!(file.selections[0].rot_deg, Some(45));
542        // Triangle: rotation baked into vertices, no rot_deg.
543        assert_eq!(file.selections[1].rot_deg, None);
544        assert_eq!(
545            file.selections[1].px,
546            Shape::Triangle {
547                ax: 200,
548                ay: 200,
549                bx: 300,
550                by: 100,
551                cx: 100,
552                cy: 100,
553            }
554        );
555        // Unrotated: no rot_deg key at all in the JSON.
556        let json = serde_json::to_value(&file).unwrap();
557        assert!(json["selections"][2].get("rot_deg").is_none());
558        assert_eq!(json["selections"][0]["rot_deg"], 45);
559    }
560
561    #[test]
562    fn untargeted_session_omits_target_fields_in_json() {
563        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
564        let file = SessionFile::build(
565            "0.1.0",
566            "2026-07-27T00:00:00Z".into(),
567            vec![monitor(0, 0, 0)],
568            &[sel],
569            &["c.png".into()],
570            None,
571        );
572        let json = serde_json::to_value(&file).unwrap();
573        assert!(json.get("target").is_none());
574        assert!(json["selections"][0].get("window_px").is_none());
575    }
576
577    #[test]
578    fn restore_then_rebuild_reproduces_every_selection_record() {
579        // A rotated rect (metadata), a rotated triangle (baked), a circle,
580        // and a label: build -> restore -> build must reproduce the
581        // records exactly, which is what makes resume lossless.
582        let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
583        rect_sel.rot_deg = 45;
584        rect_sel.label = "spun".into();
585        let mut tri_sel = Selection::new(
586            Shape::Triangle {
587                ax: 200,
588                ay: 100,
589                bx: 100,
590                by: 200,
591                cx: 300,
592                cy: 200,
593            },
594            1,
595        );
596        tri_sel.rot_deg = 90;
597        let circle_sel = Selection::new(Shape::Circle { cx: 9, cy: 9, r: 5 }, 0);
598
599        let monitors = vec![monitor(0, 0, 0), monitor(1, 1920, 0)];
600        let crops: Vec<String> = vec!["a.png".into(), "b.png".into(), "c.png".into()];
601        let first = SessionFile::build(
602            "test",
603            "t".into(),
604            monitors.clone(),
605            &[rect_sel, tri_sel, circle_sel],
606            &crops,
607            None,
608        );
609        let (restored, _) = restore_selections(&first);
610        let second = SessionFile::build("test", "t".into(), monitors, &restored, &crops, None);
611        assert_eq!(first.selections, second.selections);
612    }
613
614    #[test]
615    fn provenance_is_optional_and_survives_round_trips() {
616        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
617        let file = SessionFile::build(
618            "test",
619            "t".into(),
620            vec![monitor(0, 0, 0)],
621            &[sel],
622            &["c.png".into()],
623            None,
624        )
625        .with_meta(
626            Some("macos".into()),
627            Some(CaptureKind::Desktop),
628            Some("microsoft teams".into()),
629        );
630        let json = serde_json::to_value(&file).unwrap();
631        assert_eq!(json["platform"], "macos");
632        assert_eq!(json["capture"], "desktop");
633        assert_eq!(json["name"], "microsoft teams");
634        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
635        assert_eq!(back, file);
636
637        // A session written before these fields existed still parses,
638        // and one built without them omits the keys entirely.
639        let old = r#"{"schema":1,"app":{"name":"pixelcoords","version":"0"},
640            "created_utc":"t","monitors":[],"selections":[]}"#;
641        let parsed: SessionFile = serde_json::from_str(old).unwrap();
642        assert_eq!(parsed.platform, None);
643        assert_eq!(parsed.capture, None);
644        assert_eq!(parsed.name, None);
645        let bare = SessionFile::build("test", "t".into(), vec![], &[], &[], None);
646        let json = serde_json::to_value(&bare).unwrap();
647        assert!(json.get("platform").is_none());
648        assert!(json.get("capture").is_none());
649        assert!(json.get("name").is_none());
650    }
651
652    #[test]
653    fn untagged_shape_deserializes_by_fields() {
654        let rect: Shape = serde_json::from_str(r#"{"x":1,"y":2,"w":3,"h":4}"#).unwrap();
655        assert_eq!(rect, Shape::Rect(Rect::new(1, 2, 3, 4)));
656        let circle: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"r":3}"#).unwrap();
657        assert_eq!(circle, Shape::Circle { cx: 1, cy: 2, r: 3 });
658        let ellipse: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"rx":3,"ry":4}"#).unwrap();
659        assert_eq!(
660            ellipse,
661            Shape::Ellipse {
662                cx: 1,
663                cy: 2,
664                rx: 3,
665                ry: 4,
666            }
667        );
668    }
669
670    #[test]
671    fn poly_records_serialize_their_vertices_and_round_trip() {
672        let sel = Selection::new(
673            Shape::Poly {
674                points: vec![Point::new(1, 2), Point::new(9, 2), Point::new(5, 9)],
675            },
676            0,
677        );
678        let file = SessionFile::build(
679            "test",
680            "t".into(),
681            vec![monitor(0, 0, 0)],
682            &[sel],
683            &["c.png".into()],
684            None,
685        );
686        let json = serde_json::to_value(&file).unwrap();
687        assert_eq!(json["selections"][0]["shape"], "poly");
688        assert_eq!(json["selections"][0]["px"]["points"][2]["x"], 5);
689        assert_eq!(
690            json["selections"][0].get("rot_deg"),
691            None,
692            "poly rotation is baked, never metadata"
693        );
694        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
695        assert_eq!(back, file);
696    }
697
698    #[test]
699    fn ellipse_records_carry_rotation_metadata_like_rects() {
700        let mut sel = Selection::new(
701            Shape::Ellipse {
702                cx: 50,
703                cy: 40,
704                rx: 20,
705                ry: 10,
706            },
707            0,
708        );
709        sel.rot_deg = 30;
710        let file = SessionFile::build(
711            "test",
712            "t".into(),
713            vec![monitor(0, 0, 0)],
714            &[sel],
715            &["c.png".into()],
716            None,
717        );
718        assert_eq!(file.selections[0].rot_deg, Some(30));
719        let json = serde_json::to_value(&file).unwrap();
720        assert_eq!(json["selections"][0]["shape"], "ellipse");
721        assert_eq!(json["selections"][0]["px"]["rx"], 20);
722        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
723        assert_eq!(back, file);
724    }
725}