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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct SelectionRecord {
86    pub shape: ToolKind,
87    pub label: String,
88    pub monitor: usize,
89    pub px: Shape,
90    pub global_px: Shape,
91    /// Rotation in degrees (clockwise, `1..360`) about the bbox center of
92    /// `px`. Absent means unrotated. Never present for triangles — their
93    /// rotation is baked into the stored vertices — nor circles.
94    #[serde(skip_serializing_if = "Option::is_none", default)]
95    pub rot_deg: Option<i32>,
96    /// Coordinates relative to the target window's top-left; present only
97    /// in `--target` sessions, for selections on the target's monitor.
98    /// Negative values mean the selection lies outside the window.
99    #[serde(skip_serializing_if = "Option::is_none", default)]
100    pub window_px: Option<Shape>,
101    /// File name of this selection's PNG crop, relative to the session dir.
102    pub crop: String,
103}
104
105impl SessionFile {
106    /// Assemble a session. `created_utc` is supplied by the caller (this
107    /// crate has no clock); `crops` pairs 1:1 with `selections`.
108    pub fn build(
109        app_version: &str,
110        created_utc: String,
111        monitors: Vec<MonitorRecord>,
112        selections: &[Selection],
113        crops: &[String],
114        target: Option<TargetRecord>,
115    ) -> Self {
116        assert_eq!(selections.len(), crops.len(), "one crop name per selection");
117        let records = selections
118            .iter()
119            .zip(crops)
120            .map(|(s, crop)| {
121                let origin = monitors
122                    .iter()
123                    .find(|m| m.index == s.monitor)
124                    .map_or(Point::new(0, 0), |m| m.origin_px);
125                // Triangles bake rotation into their vertices (exact);
126                // rects keep an axis-aligned box plus rot_deg metadata.
127                let shape = s.shape.with_rotation_baked(s.rot_deg);
128                let rot_deg = match shape {
129                    Shape::Rect(_) | Shape::Ellipse { .. } => {
130                        Some(crate::geometry::normalize_deg(s.rot_deg)).filter(|d| *d != 0)
131                    }
132                    _ => None,
133                };
134                let window_px = target
135                    .as_ref()
136                    .filter(|t| t.monitor == s.monitor)
137                    .map(|t| shape.translated(-t.origin_px.x, -t.origin_px.y));
138                SelectionRecord {
139                    shape: shape.kind(),
140                    label: s.label.clone(),
141                    monitor: s.monitor,
142                    global_px: shape.translated(origin.x, origin.y),
143                    px: shape,
144                    rot_deg,
145                    window_px,
146                    crop: crop.clone(),
147                }
148            })
149            .collect();
150        Self {
151            schema: SCHEMA_VERSION,
152            app: AppInfo {
153                name: APP_NAME.to_string(),
154                version: app_version.to_string(),
155            },
156            created_utc,
157            platform: None,
158            capture: None,
159            name: None,
160            monitors,
161            target,
162            selections: records,
163        }
164    }
165
166    /// Stamp provenance onto a built session. A resumed session passes
167    /// through what it loaded, so a file edited on another machine keeps
168    /// saying where it was captured.
169    #[must_use]
170    pub fn with_meta(
171        mut self,
172        platform: Option<String>,
173        capture: Option<CaptureKind>,
174        name: Option<String>,
175    ) -> Self {
176        self.platform = platform;
177        self.capture = capture;
178        self.name = name;
179        self
180    }
181}
182
183/// Rebuild editable selections from a saved session — the inverse of
184/// [`SessionFile::build`]. Shapes come back in monitor-local px; rects
185/// reclaim their `rot_deg` metadata, triangles keep rotation baked in
186/// their vertices (their records never carry `rot_deg`), and circles are
187/// rotation-free. Feed the result to `SelectionSet::seed`.
188///
189/// In a target session, selections whose shape falls outside the window's
190/// rect are **dropped**. Older builds recorded whatever the user marked
191/// on the whole monitor, including junk outside the window, and their
192/// stored `window_px` came out with negative coordinates. This build
193/// refuses to let a user act on those, so a resumed session should not
194/// bring them back. The dropped labels are returned so the caller can
195/// tell the user what happened.
196pub fn restore_selections(file: &SessionFile) -> (Vec<Selection>, Vec<String>) {
197    let target_rect = file.target.as_ref().map(|t| {
198        (
199            t.monitor,
200            crate::geometry::Rect::new(0, 0, t.size_px.w, t.size_px.h),
201        )
202    });
203    let mut kept = Vec::with_capacity(file.selections.len());
204    let mut dropped = Vec::new();
205    for record in &file.selections {
206        // Compare against `window_px` (already translated to window-local)
207        // rather than reconstructing coordinates from `px`; the two must
208        // agree for a valid record, and window_px is the primary frame in
209        // a target session.
210        if let Some((monitor, rect)) = target_rect
211            && record.monitor == monitor
212        {
213            let Some(shape) = &record.window_px else {
214                dropped.push(record.label.clone());
215                continue;
216            };
217            let bbox = shape.bbox();
218            let inside = bbox.x >= rect.x
219                && bbox.y >= rect.y
220                && bbox.x + bbox.w <= rect.x + rect.w
221                && bbox.y + bbox.h <= rect.y + rect.h;
222            if !inside {
223                dropped.push(record.label.clone());
224                continue;
225            }
226        }
227        kept.push(Selection {
228            shape: record.px.clone(),
229            label: record.label.clone(),
230            monitor: record.monitor,
231            rot_deg: record.rot_deg.unwrap_or(0),
232        });
233    }
234    (kept, dropped)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::geometry::Rect;
241
242    fn monitor(index: usize, ox: i32, oy: i32) -> MonitorRecord {
243        MonitorRecord {
244            index,
245            name: format!("Display {index}"),
246            primary: index == 0,
247            origin_px: Point::new(ox, oy),
248            size_px: Size::new(1920, 1080),
249            scale: 2.0,
250        }
251    }
252
253    #[test]
254    fn global_is_origin_plus_local() {
255        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 1);
256        sel.label = "target".into();
257        let file = SessionFile::build(
258            "0.1.0",
259            "2026-07-27T00:00:00Z".into(),
260            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
261            &[sel],
262            &["crop-0-target.png".into()],
263            None,
264        );
265        assert_eq!(
266            file.selections[0].px,
267            Shape::Rect(Rect::new(10, 20, 30, 40))
268        );
269        assert_eq!(
270            file.selections[0].global_px,
271            Shape::Rect(Rect::new(1930, 20, 30, 40))
272        );
273    }
274
275    #[test]
276    fn json_shape_is_stable() {
277        let sel = Selection::new(Shape::Circle { cx: 5, cy: 6, r: 7 }, 0);
278        let file = SessionFile::build(
279            "0.1.0",
280            "2026-07-27T00:00:00Z".into(),
281            vec![monitor(0, 0, 0)],
282            &[sel],
283            &["crop-0.png".into()],
284            None,
285        );
286        let json = serde_json::to_value(&file).unwrap();
287        assert_eq!(json["schema"], 1);
288        assert_eq!(json["app"]["name"], "pixelcoords");
289        assert_eq!(json["selections"][0]["shape"], "circle");
290        assert_eq!(json["selections"][0]["px"]["cx"], 5);
291        assert_eq!(json["selections"][0]["px"]["r"], 7);
292        assert_eq!(json["monitors"][0]["scale"], 2.0);
293    }
294
295    #[test]
296    fn round_trips_through_json() {
297        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
298        let file = SessionFile::build(
299            "0.1.0",
300            "2026-07-27T00:00:00Z".into(),
301            vec![monitor(0, 0, 0)],
302            &[sel],
303            &["c.png".into()],
304            None,
305        );
306        let json = serde_json::to_string(&file).unwrap();
307        let back: SessionFile = serde_json::from_str(&json).unwrap();
308        assert_eq!(back, file);
309    }
310
311    #[test]
312    fn target_yields_window_relative_coords() {
313        let on_target = Selection::new(Shape::Rect(Rect::new(500, 300, 40, 20)), 0);
314        let elsewhere = Selection::new(Shape::Rect(Rect::new(1, 1, 5, 5)), 1);
315        let target = TargetRecord {
316            app: "Notepad".into(),
317            title: "notes.txt".into(),
318            monitor: 0,
319            origin_px: Point::new(400, 250),
320            size_px: Size::new(800, 600),
321        };
322        let file = SessionFile::build(
323            "0.1.0",
324            "2026-07-27T00:00:00Z".into(),
325            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
326            &[on_target, elsewhere],
327            &["a.png".into(), "b.png".into()],
328            Some(target),
329        );
330        assert_eq!(
331            file.selections[0].window_px,
332            Some(Shape::Rect(Rect::new(100, 50, 40, 20)))
333        );
334        assert_eq!(file.selections[1].window_px, None);
335        assert_eq!(file.target.as_ref().unwrap().title, "notes.txt");
336    }
337
338    #[test]
339    fn rotation_is_metadata_for_rects_and_baked_for_triangles() {
340        let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
341        rect_sel.rot_deg = 45;
342        let mut tri_sel = Selection::new(
343            Shape::Triangle {
344                ax: 200,
345                ay: 100,
346                bx: 100,
347                by: 200,
348                cx: 300,
349                cy: 200,
350            },
351            0,
352        );
353        tri_sel.rot_deg = 180;
354        let plain = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
355
356        let file = SessionFile::build(
357            "0.1.0",
358            "2026-07-27T00:00:00Z".into(),
359            vec![monitor(0, 0, 0)],
360            &[rect_sel, tri_sel, plain],
361            &["a.png".into(), "b.png".into(), "c.png".into()],
362            None,
363        );
364        // Rect: axis-aligned px + rot_deg metadata.
365        assert_eq!(
366            file.selections[0].px,
367            Shape::Rect(Rect::new(10, 20, 30, 40))
368        );
369        assert_eq!(file.selections[0].rot_deg, Some(45));
370        // Triangle: rotation baked into vertices, no rot_deg.
371        assert_eq!(file.selections[1].rot_deg, None);
372        assert_eq!(
373            file.selections[1].px,
374            Shape::Triangle {
375                ax: 200,
376                ay: 200,
377                bx: 300,
378                by: 100,
379                cx: 100,
380                cy: 100,
381            }
382        );
383        // Unrotated: no rot_deg key at all in the JSON.
384        let json = serde_json::to_value(&file).unwrap();
385        assert!(json["selections"][2].get("rot_deg").is_none());
386        assert_eq!(json["selections"][0]["rot_deg"], 45);
387    }
388
389    #[test]
390    fn untargeted_session_omits_target_fields_in_json() {
391        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
392        let file = SessionFile::build(
393            "0.1.0",
394            "2026-07-27T00:00:00Z".into(),
395            vec![monitor(0, 0, 0)],
396            &[sel],
397            &["c.png".into()],
398            None,
399        );
400        let json = serde_json::to_value(&file).unwrap();
401        assert!(json.get("target").is_none());
402        assert!(json["selections"][0].get("window_px").is_none());
403    }
404
405    #[test]
406    fn restore_then_rebuild_reproduces_every_selection_record() {
407        // A rotated rect (metadata), a rotated triangle (baked), a circle,
408        // and a label: build -> restore -> build must reproduce the
409        // records exactly, which is what makes resume lossless.
410        let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
411        rect_sel.rot_deg = 45;
412        rect_sel.label = "spun".into();
413        let mut tri_sel = Selection::new(
414            Shape::Triangle {
415                ax: 200,
416                ay: 100,
417                bx: 100,
418                by: 200,
419                cx: 300,
420                cy: 200,
421            },
422            1,
423        );
424        tri_sel.rot_deg = 90;
425        let circle_sel = Selection::new(Shape::Circle { cx: 9, cy: 9, r: 5 }, 0);
426
427        let monitors = vec![monitor(0, 0, 0), monitor(1, 1920, 0)];
428        let crops: Vec<String> = vec!["a.png".into(), "b.png".into(), "c.png".into()];
429        let first = SessionFile::build(
430            "test",
431            "t".into(),
432            monitors.clone(),
433            &[rect_sel, tri_sel, circle_sel],
434            &crops,
435            None,
436        );
437        let (restored, _) = restore_selections(&first);
438        let second = SessionFile::build("test", "t".into(), monitors, &restored, &crops, None);
439        assert_eq!(first.selections, second.selections);
440    }
441
442    #[test]
443    fn provenance_is_optional_and_survives_round_trips() {
444        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
445        let file = SessionFile::build(
446            "test",
447            "t".into(),
448            vec![monitor(0, 0, 0)],
449            &[sel],
450            &["c.png".into()],
451            None,
452        )
453        .with_meta(
454            Some("macos".into()),
455            Some(CaptureKind::Desktop),
456            Some("microsoft teams".into()),
457        );
458        let json = serde_json::to_value(&file).unwrap();
459        assert_eq!(json["platform"], "macos");
460        assert_eq!(json["capture"], "desktop");
461        assert_eq!(json["name"], "microsoft teams");
462        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
463        assert_eq!(back, file);
464
465        // A session written before these fields existed still parses,
466        // and one built without them omits the keys entirely.
467        let old = r#"{"schema":1,"app":{"name":"pixelcoords","version":"0"},
468            "created_utc":"t","monitors":[],"selections":[]}"#;
469        let parsed: SessionFile = serde_json::from_str(old).unwrap();
470        assert_eq!(parsed.platform, None);
471        assert_eq!(parsed.capture, None);
472        assert_eq!(parsed.name, None);
473        let bare = SessionFile::build("test", "t".into(), vec![], &[], &[], None);
474        let json = serde_json::to_value(&bare).unwrap();
475        assert!(json.get("platform").is_none());
476        assert!(json.get("capture").is_none());
477        assert!(json.get("name").is_none());
478    }
479
480    #[test]
481    fn untagged_shape_deserializes_by_fields() {
482        let rect: Shape = serde_json::from_str(r#"{"x":1,"y":2,"w":3,"h":4}"#).unwrap();
483        assert_eq!(rect, Shape::Rect(Rect::new(1, 2, 3, 4)));
484        let circle: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"r":3}"#).unwrap();
485        assert_eq!(circle, Shape::Circle { cx: 1, cy: 2, r: 3 });
486        let ellipse: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"rx":3,"ry":4}"#).unwrap();
487        assert_eq!(
488            ellipse,
489            Shape::Ellipse {
490                cx: 1,
491                cy: 2,
492                rx: 3,
493                ry: 4,
494            }
495        );
496    }
497
498    #[test]
499    fn poly_records_serialize_their_vertices_and_round_trip() {
500        let sel = Selection::new(
501            Shape::Poly {
502                points: vec![Point::new(1, 2), Point::new(9, 2), Point::new(5, 9)],
503            },
504            0,
505        );
506        let file = SessionFile::build(
507            "test",
508            "t".into(),
509            vec![monitor(0, 0, 0)],
510            &[sel],
511            &["c.png".into()],
512            None,
513        );
514        let json = serde_json::to_value(&file).unwrap();
515        assert_eq!(json["selections"][0]["shape"], "poly");
516        assert_eq!(json["selections"][0]["px"]["points"][2]["x"], 5);
517        assert_eq!(
518            json["selections"][0].get("rot_deg"),
519            None,
520            "poly rotation is baked, never metadata"
521        );
522        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
523        assert_eq!(back, file);
524    }
525
526    #[test]
527    fn ellipse_records_carry_rotation_metadata_like_rects() {
528        let mut sel = Selection::new(
529            Shape::Ellipse {
530                cx: 50,
531                cy: 40,
532                rx: 20,
533                ry: 10,
534            },
535            0,
536        );
537        sel.rot_deg = 30;
538        let file = SessionFile::build(
539            "test",
540            "t".into(),
541            vec![monitor(0, 0, 0)],
542            &[sel],
543            &["c.png".into()],
544            None,
545        );
546        assert_eq!(file.selections[0].rot_deg, Some(30));
547        let json = serde_json::to_value(&file).unwrap();
548        assert_eq!(json["selections"][0]["shape"], "ellipse");
549        assert_eq!(json["selections"][0]["px"]["rx"], 20);
550        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
551        assert_eq!(back, file);
552    }
553}