1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum CaptureKind {
22 Desktop,
24 Window,
27 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 #[serde(skip_serializing_if = "Option::is_none", default)]
41 pub platform: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none", default)]
44 pub capture: Option<CaptureKind>,
45 #[serde(skip_serializing_if = "Option::is_none", default)]
48 pub name: Option<String>,
49 pub monitors: Vec<MonitorRecord>,
50 #[serde(skip_serializing_if = "Option::is_none", default)]
53 pub target: Option<TargetRecord>,
54 pub selections: Vec<SelectionRecord>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct TargetRecord {
60 pub app: String,
61 pub title: String,
62 pub monitor: usize,
63 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 #[serde(skip_serializing_if = "Option::is_none", default)]
95 pub rot_deg: Option<i32>,
96 #[serde(skip_serializing_if = "Option::is_none", default)]
100 pub window_px: Option<Shape>,
101 pub crop: String,
103}
104
105impl SessionFile {
106 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 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 #[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
183pub 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 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 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 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 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 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 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}