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, Copy, PartialEq, Eq)]
88pub enum MonitorMatch {
89 Found(usize),
91 Changed(usize),
96 Missing,
98}
99
100pub fn match_monitor(record: &MonitorRecord, candidates: &[MonitorRecord]) -> MonitorMatch {
114 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 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 #[serde(skip_serializing_if = "Option::is_none", default)]
160 pub rot_deg: Option<i32>,
161 #[serde(skip_serializing_if = "Option::is_none", default)]
165 pub window_px: Option<Shape>,
166 pub crop: String,
168}
169
170impl SessionFile {
171 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 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 #[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
248pub 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 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
302pub 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
323pub 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 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 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 let saved_one = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
405 assert_eq!(match_monitor(&saved_one, &live), MonitorMatch::Found(1));
406
407 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 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 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 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 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 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 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 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 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 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 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 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 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}