1use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use crate::geometry::{Point, Shape, Size, ToolKind};
13use crate::selection::Selection;
14
15pub const SCHEMA_VERSION: u32 = 1;
16pub const APP_NAME: &str = "pixelcoords";
17
18pub use crate::geometry::MAX_COORD;
19
20pub const MAX_LABEL_LEN: usize = 80;
41
42#[derive(Debug, Error, PartialEq)]
48pub enum SessionError {
49 #[error("monitor {index} ({name:?}) has scale {scale}, which is not a positive finite number")]
50 Scale {
51 index: usize,
52 name: String,
53 scale: f64,
54 },
55 #[error("monitor {index} ({name:?}) has size {w}x{h}; a display cannot be empty")]
56 MonitorSize {
57 index: usize,
58 name: String,
59 w: i32,
60 h: i32,
61 },
62 #[error("the target window has size {w}x{h}; a window cannot be empty")]
63 TargetSize { w: i32, h: i32 },
64 #[error(
65 "{what} carries the coordinate {value}, beyond the +/-{MAX_COORD} a session may describe"
66 )]
67 Coordinate { what: String, value: i32 },
68 #[error(
69 "{what} has a {len}-character label; the limit is {MAX_LABEL_LEN}, because the label \
70 becomes part of a crop's filename"
71 )]
72 Label { what: String, len: usize },
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum CaptureKind {
80 Desktop,
82 Window,
85 Pick,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct SessionFile {
92 pub schema: u32,
93 pub app: AppInfo,
94 pub created_utc: String,
95 #[serde(skip_serializing_if = "Option::is_none", default)]
99 pub platform: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none", default)]
102 pub capture: Option<CaptureKind>,
103 #[serde(skip_serializing_if = "Option::is_none", default)]
106 pub name: Option<String>,
107 pub monitors: Vec<MonitorRecord>,
108 #[serde(skip_serializing_if = "Option::is_none", default)]
111 pub target: Option<TargetRecord>,
112 pub selections: Vec<SelectionRecord>,
113 #[serde(skip_serializing_if = "Vec::is_empty", default)]
119 pub measures: Vec<MeasureRecord>,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct MeasureRecord {
126 pub label: String,
127 pub monitor: usize,
128 pub px: LineRecord,
130 pub global_px: LineRecord,
132 pub length_px: f64,
133 pub dx: i32,
134 pub dy: i32,
135 pub angle_deg: f64,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144pub struct LineRecord {
145 pub ax: i32,
146 pub ay: i32,
147 pub bx: i32,
148 pub by: i32,
149}
150
151impl From<crate::geometry::Line> for LineRecord {
152 fn from(line: crate::geometry::Line) -> Self {
153 Self {
154 ax: line.a.x,
155 ay: line.a.y,
156 bx: line.b.x,
157 by: line.b.y,
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct TargetRecord {
165 pub app: String,
166 pub title: String,
167 pub monitor: usize,
168 pub origin_px: Point,
170 pub size_px: Size,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct AppInfo {
175 pub name: String,
176 pub version: String,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct MonitorRecord {
181 pub index: usize,
182 pub name: String,
183 pub primary: bool,
184 pub origin_px: Point,
185 pub size_px: Size,
186 pub scale: f64,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum MonitorMatch {
194 Found(usize),
196 Changed(usize),
201 Missing,
203}
204
205pub fn match_monitor(record: &MonitorRecord, candidates: &[MonitorRecord]) -> MonitorMatch {
219 let best = |pool: &[usize]| -> Option<usize> {
223 pool.iter()
224 .copied()
225 .find(|&i| candidates[i].index == record.index)
226 .or_else(|| pool.iter().copied().min_by_key(|&i| candidates[i].index))
227 };
228
229 let named: Vec<usize> = candidates
230 .iter()
231 .enumerate()
232 .filter(|(_, c)| c.name == record.name)
233 .map(|(i, _)| i)
234 .collect();
235 if named.is_empty() {
236 return MonitorMatch::Missing;
237 }
238 let exact: Vec<usize> = named
239 .iter()
240 .copied()
241 .filter(|&i| {
242 let c = &candidates[i];
243 c.size_px == record.size_px && (c.scale - record.scale).abs() < f64::EPSILON
246 })
247 .collect();
248 if let Some(i) = best(&exact) {
249 return MonitorMatch::Found(i);
250 }
251 best(&named).map_or(MonitorMatch::Missing, MonitorMatch::Changed)
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct SelectionRecord {
256 pub shape: ToolKind,
257 pub label: String,
258 pub monitor: usize,
259 pub px: Shape,
260 pub global_px: Shape,
261 #[serde(skip_serializing_if = "Option::is_none", default)]
265 pub rot_deg: Option<i32>,
266 #[serde(skip_serializing_if = "Option::is_none", default)]
270 pub window_px: Option<Shape>,
271 pub crop: String,
273 #[serde(skip_serializing_if = "Option::is_none", default)]
282 pub color: Option<String>,
283}
284
285impl SessionFile {
286 pub fn build(
289 app_version: &str,
290 created_utc: String,
291 monitors: Vec<MonitorRecord>,
292 selections: &[Selection],
293 crops: &[String],
294 target: Option<TargetRecord>,
295 ) -> Self {
296 assert_eq!(selections.len(), crops.len(), "one crop name per selection");
297 let records = selections
298 .iter()
299 .zip(crops)
300 .map(|(s, crop)| {
301 let origin = monitors
302 .iter()
303 .find(|m| m.index == s.monitor)
304 .map_or(Point::new(0, 0), |m| m.origin_px);
305 let shape = s.shape.with_rotation_baked(s.rot_deg);
308 let rot_deg = match shape {
309 Shape::Rect(_) | Shape::Ellipse { .. } => {
310 Some(crate::geometry::normalize_deg(s.rot_deg)).filter(|d| *d != 0)
311 }
312 _ => None,
313 };
314 let window_px = target
315 .as_ref()
316 .filter(|t| t.monitor == s.monitor)
317 .map(|t| shape.translated(-t.origin_px.x, -t.origin_px.y));
318 SelectionRecord {
319 shape: shape.kind(),
320 label: s.label.clone(),
321 monitor: s.monitor,
322 global_px: shape.translated(origin.x, origin.y),
323 px: shape,
324 rot_deg,
325 window_px,
326 crop: crop.clone(),
327 color: None,
330 }
331 })
332 .collect();
333 Self {
334 schema: SCHEMA_VERSION,
335 app: AppInfo {
336 name: APP_NAME.to_string(),
337 version: app_version.to_string(),
338 },
339 created_utc,
340 platform: None,
341 capture: None,
342 name: None,
343 monitors,
344 target,
345 selections: records,
346 measures: Vec::new(),
347 }
348 }
349
350 #[must_use]
354 pub fn with_meta(
355 mut self,
356 platform: Option<String>,
357 capture: Option<CaptureKind>,
358 name: Option<String>,
359 ) -> Self {
360 self.platform = platform;
361 self.capture = capture;
362 self.name = name;
363 self
364 }
365
366 #[must_use]
374 pub fn with_measures(mut self, measures: &[crate::selection::Measure]) -> Self {
375 self.measures = measures
376 .iter()
377 .map(|m| {
378 let origin = self
379 .monitors
380 .iter()
381 .find(|mon| mon.index == m.monitor)
382 .map_or(Point::new(0, 0), |mon| mon.origin_px);
383 let (dx, dy) = m.line.delta();
384 MeasureRecord {
385 label: m.label.clone(),
386 monitor: m.monitor,
387 px: m.line.into(),
388 global_px: m.line.translated(origin.x, origin.y).into(),
389 length_px: m.line.length(),
390 dx,
391 dy,
392 angle_deg: m.line.angle_deg(),
393 }
394 })
395 .collect();
396 self
397 }
398
399 #[must_use]
408 pub fn with_colors(mut self, colors: &[Option<String>]) -> Self {
409 for (record, color) in self.selections.iter_mut().zip(colors) {
410 record.color.clone_from(color);
411 }
412 self
413 }
414}
415
416pub fn restore_selections(file: &SessionFile) -> (Vec<Selection>, Vec<String>) {
430 let target_rect = file.target.as_ref().map(|t| {
431 (
432 t.monitor,
433 crate::geometry::Rect::new(0, 0, t.size_px.w, t.size_px.h),
434 )
435 });
436 let mut kept = Vec::with_capacity(file.selections.len());
437 let mut dropped = Vec::new();
438 for record in &file.selections {
439 if let Some((monitor, rect)) = target_rect
444 && record.monitor == monitor
445 {
446 let Some(shape) = &record.window_px else {
447 dropped.push(record.label.clone());
448 continue;
449 };
450 let bbox = shape.bbox();
451 let inside = bbox.x >= rect.x
452 && bbox.y >= rect.y
453 && bbox.x + bbox.w <= rect.x + rect.w
454 && bbox.y + bbox.h <= rect.y + rect.h;
455 if !inside {
456 dropped.push(record.label.clone());
457 continue;
458 }
459 }
460 kept.push(Selection {
461 shape: record.px.clone(),
462 label: record.label.clone(),
463 monitor: record.monitor,
464 rot_deg: record.rot_deg.unwrap_or(0),
465 });
466 }
467 (kept, dropped)
468}
469
470fn raw_values(shape: &Shape) -> Vec<i32> {
477 match shape {
478 Shape::Rect(r) => vec![r.x, r.y, r.w, r.h],
479 Shape::Circle { cx, cy, r } => vec![*cx, *cy, *r],
480 Shape::Ellipse { cx, cy, rx, ry } => vec![*cx, *cy, *rx, *ry],
481 Shape::Triangle {
482 ax,
483 ay,
484 bx,
485 by,
486 cx,
487 cy,
488 } => vec![*ax, *ay, *bx, *by, *cx, *cy],
489 Shape::Poly { points } => points.iter().flat_map(|p| [p.x, p.y]).collect(),
490 }
491}
492
493fn check_label(label: &str, what: &str) -> Result<(), SessionError> {
494 let len = label.chars().count();
495 if len > MAX_LABEL_LEN {
496 return Err(SessionError::Label {
497 what: what.to_string(),
498 len,
499 });
500 }
501 Ok(())
502}
503
504fn in_range(values: &[i32], what: &str) -> Result<(), SessionError> {
505 for &value in values {
506 if value.abs() > MAX_COORD {
507 return Err(SessionError::Coordinate {
508 what: what.to_string(),
509 value,
510 });
511 }
512 }
513 Ok(())
514}
515
516impl SessionFile {
517 pub fn validate(&self) -> Result<(), SessionError> {
525 for monitor in &self.monitors {
526 if !monitor.scale.is_finite() || monitor.scale <= 0.0 {
527 return Err(SessionError::Scale {
528 index: monitor.index,
529 name: monitor.name.clone(),
530 scale: monitor.scale,
531 });
532 }
533 if monitor.size_px.w <= 0 || monitor.size_px.h <= 0 {
534 return Err(SessionError::MonitorSize {
535 index: monitor.index,
536 name: monitor.name.clone(),
537 w: monitor.size_px.w,
538 h: monitor.size_px.h,
539 });
540 }
541 let label = format!("monitor {}", monitor.index);
542 in_range(
543 &[
544 monitor.origin_px.x,
545 monitor.origin_px.y,
546 monitor.size_px.w,
547 monitor.size_px.h,
548 ],
549 &label,
550 )?;
551 }
552 if let Some(target) = &self.target {
553 if target.size_px.w <= 0 || target.size_px.h <= 0 {
554 return Err(SessionError::TargetSize {
555 w: target.size_px.w,
556 h: target.size_px.h,
557 });
558 }
559 in_range(
560 &[
561 target.origin_px.x,
562 target.origin_px.y,
563 target.size_px.w,
564 target.size_px.h,
565 ],
566 "the target window",
567 )?;
568 }
569 for (index, record) in self.selections.iter().enumerate() {
570 let label = format!("selection {index}");
571 check_label(&record.label, &label)?;
572 in_range(&raw_values(&record.px), &label)?;
573 in_range(&raw_values(&record.global_px), &label)?;
574 if let Some(window) = &record.window_px {
575 in_range(&raw_values(window), &label)?;
576 }
577 }
578 for (index, record) in self.measures.iter().enumerate() {
579 let label = format!("measure {index}");
580 check_label(&record.label, &label)?;
581 for line in [&record.px, &record.global_px] {
582 in_range(&[line.ax, line.ay, line.bx, line.by], &label)?;
583 }
584 }
585 Ok(())
586 }
587}
588
589#[must_use]
598pub fn restore_measures(file: &SessionFile) -> Vec<crate::selection::Measure> {
599 file.measures
600 .iter()
601 .map(|record| crate::selection::Measure {
602 line: crate::geometry::Line::new(
603 Point::new(record.px.ax, record.px.ay),
604 Point::new(record.px.bx, record.px.by),
605 ),
606 label: record.label.clone(),
607 monitor: record.monitor,
608 })
609 .collect()
610}
611
612pub fn select_by_label<'a>(
622 session: &'a SessionFile,
623 label: Option<&str>,
624) -> Vec<(usize, &'a SelectionRecord)> {
625 session
626 .selections
627 .iter()
628 .enumerate()
629 .filter(|(_, record)| label.is_none_or(|want| record.label.eq_ignore_ascii_case(want)))
630 .collect()
631}
632
633pub fn distinct_labels<'a>(records: impl Iterator<Item = &'a SelectionRecord>) -> Vec<String> {
642 let mut labels: Vec<String> = Vec::new();
643 for record in records {
644 if record.label.is_empty() {
645 continue;
646 }
647 if labels.iter().any(|l| l.eq_ignore_ascii_case(&record.label)) {
648 continue;
649 }
650 labels.push(record.label.clone());
651 }
652 labels
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use crate::geometry::Rect;
659
660 #[test]
661 fn measures_are_absent_from_a_session_that_has_none() {
662 let file = labeled(&["submit"]);
663 let json = serde_json::to_value(&file).unwrap();
664 assert!(
665 json.get("measures").is_none(),
666 "a session without measures must look exactly as it always did"
667 );
668 assert_eq!(json["schema"], 1);
669 }
670
671 #[test]
672 fn a_measure_records_its_globals_and_derived_values() {
673 use crate::geometry::Line;
674 use crate::selection::Measure;
675 let mut m = Measure::new(Line::new(Point::new(100, 80), Point::new(262, 80)), 1);
678 m.label = "toolbar-gap".into();
679 let file = SessionFile::build(
680 "test",
681 "2026-08-01T00:00:00Z".into(),
682 vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
683 &[],
684 &[],
685 None,
686 )
687 .with_measures(&[m]);
688
689 let json = serde_json::to_value(&file).unwrap();
690 let rec = &json["measures"][0];
691 assert_eq!(rec["label"], "toolbar-gap");
692 assert_eq!(rec["monitor"], 1);
693 assert_eq!(rec["px"]["ax"], 100);
694 assert_eq!(rec["global_px"]["ax"], 2020, "origin added");
695 assert_eq!(rec["global_px"]["bx"], 2182);
696 assert_eq!(rec["dx"], 162);
697 assert_eq!(rec["dy"], 0);
698 assert_eq!(rec["length_px"], 162.0);
699 assert_eq!(rec["angle_deg"], 0.0);
700 assert_eq!(json["schema"], 1, "measures are additive");
701 }
702
703 #[test]
704 fn stored_derived_values_match_recomputing_them() {
705 use crate::geometry::Line;
706 use crate::selection::Measure;
707 let line = Line::new(Point::new(-30, 12), Point::new(45, -60));
710 let file = SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None)
711 .with_measures(&[Measure::new(line, 0)]);
712 let rec = &file.measures[0];
713 assert!((rec.length_px - line.length()).abs() < f64::EPSILON);
714 assert!((rec.angle_deg - line.angle_deg()).abs() < f64::EPSILON);
715 assert_eq!((rec.dx, rec.dy), line.delta());
716 }
717
718 #[test]
719 fn restoring_measures_recovers_every_ruler_and_recomputes_nothing_wrong() {
720 let base =
721 || SessionFile::build("test", "t".into(), vec![monitor(0, 100, 0)], &[], &[], None);
722 let file = base().with_measures(&[
723 crate::selection::Measure::new(
724 crate::geometry::Line::new(Point::new(10, 20), Point::new(40, 60)),
725 0,
726 ),
727 crate::selection::Measure {
728 line: crate::geometry::Line::new(Point::new(1, 2), Point::new(3, 4)),
729 label: "gutter".into(),
730 monitor: 0,
731 },
732 ]);
733
734 let restored = restore_measures(&file);
735
736 assert_eq!(restored.len(), 2);
737 assert_eq!(
738 restored[0].line,
739 crate::geometry::Line::new(Point::new(10, 20), Point::new(40, 60)),
740 "monitor-local endpoints, not the global ones"
741 );
742 assert_eq!(restored[1].label, "gutter");
743 assert_eq!(base().with_measures(&restored).measures, file.measures);
745 }
746
747 #[test]
748 fn an_empty_target_window_is_refused() {
749 let mut file =
750 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
751 file.target = Some(TargetRecord {
752 app: "App".into(),
753 title: "T".into(),
754 monitor: 0,
755 origin_px: Point::new(0, 0),
756 size_px: Size::new(0, 400),
757 });
758 assert!(matches!(
759 file.validate(),
760 Err(SessionError::TargetSize { w: 0, h: 400 })
761 ));
762 }
763
764 #[test]
765 fn a_target_window_past_the_bound_is_refused() {
766 let mut file =
767 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
768 file.target = Some(TargetRecord {
769 app: "App".into(),
770 title: "T".into(),
771 monitor: 0,
772 origin_px: Point::new(MAX_COORD + 1, 0),
773 size_px: Size::new(400, 400),
774 });
775 assert!(matches!(
776 file.validate(),
777 Err(SessionError::Coordinate { .. })
778 ));
779 }
780
781 #[test]
782 fn every_refusal_names_the_field_and_the_value() {
783 let cases = [
788 (
789 SessionError::Scale {
790 index: 2,
791 name: "DELL".into(),
792 scale: 0.0,
793 },
794 vec!["monitor 2", "DELL", "0", "positive finite"],
795 ),
796 (
797 SessionError::MonitorSize {
798 index: 1,
799 name: "Built-in".into(),
800 w: 0,
801 h: 1080,
802 },
803 vec!["monitor 1", "Built-in", "0x1080"],
804 ),
805 (
806 SessionError::TargetSize { w: 640, h: 0 },
807 vec!["target window", "640x0"],
808 ),
809 (
810 SessionError::Coordinate {
811 what: "selection 3".into(),
812 value: 2_000_000_000,
813 },
814 vec!["selection 3", "2000000000", "1000000"],
815 ),
816 ];
817 for (error, expected) in cases {
818 let rendered = error.to_string();
819 for needle in expected {
820 assert!(
821 rendered.contains(needle),
822 "{rendered:?} does not mention {needle:?}"
823 );
824 }
825 }
826 }
827
828 #[test]
829 fn a_label_too_long_for_a_filename_is_refused() {
830 let mut file =
834 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
835 file.selections.push(SelectionRecord {
836 shape: ToolKind::Rect,
837 label: "x".repeat(MAX_LABEL_LEN + 1),
838 monitor: 0,
839 px: Shape::Rect(Rect::new(0, 0, 10, 10)),
840 global_px: Shape::Rect(Rect::new(0, 0, 10, 10)),
841 rot_deg: None,
842 window_px: None,
843 crop: "c.png".into(),
844 color: None,
845 });
846 let Err(SessionError::Label { len, .. }) = file.validate() else {
847 panic!("an over-long label was accepted")
848 };
849 assert_eq!(len, MAX_LABEL_LEN + 1);
850 }
851
852 #[test]
853 fn a_label_exactly_at_the_cap_is_fine() {
854 let mut file =
855 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
856 file.selections.push(SelectionRecord {
857 shape: ToolKind::Rect,
858 label: "x".repeat(MAX_LABEL_LEN),
859 monitor: 0,
860 px: Shape::Rect(Rect::new(0, 0, 10, 10)),
861 global_px: Shape::Rect(Rect::new(0, 0, 10, 10)),
862 rot_deg: None,
863 window_px: None,
864 crop: "c.png".into(),
865 color: None,
866 });
867 assert_eq!(file.validate(), Ok(()));
868 }
869
870 #[test]
871 fn a_measures_label_is_held_to_the_same_cap() {
872 let mut file =
873 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None)
874 .with_measures(&[crate::selection::Measure {
875 line: crate::geometry::Line::new(Point::new(0, 0), Point::new(5, 5)),
876 label: "m".repeat(MAX_LABEL_LEN + 1),
877 monitor: 0,
878 }]);
879 file.monitors = vec![monitor(0, 0, 0)];
880 assert!(matches!(file.validate(), Err(SessionError::Label { .. })));
881 }
882
883 #[test]
884 fn a_label_is_counted_in_characters_not_bytes() {
885 let mut file =
888 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
889 file.selections.push(SelectionRecord {
890 shape: ToolKind::Rect,
891 label: "é".repeat(MAX_LABEL_LEN),
892 monitor: 0,
893 px: Shape::Rect(Rect::new(0, 0, 10, 10)),
894 global_px: Shape::Rect(Rect::new(0, 0, 10, 10)),
895 rot_deg: None,
896 window_px: None,
897 crop: "c.png".into(),
898 color: None,
899 });
900 assert_eq!(file.validate(), Ok(()), "64 characters, 128 bytes");
901 }
902
903 #[test]
904 fn a_valid_session_passes_validation() {
905 let file = SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
906 assert_eq!(file.validate(), Ok(()));
907 }
908
909 #[test]
910 fn a_scale_that_cannot_divide_is_refused() {
911 for bad in [0.0, -2.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
915 let mut file =
916 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
917 file.monitors[0].scale = bad;
918 let Err(SessionError::Scale { scale, index, .. }) = file.validate() else {
919 panic!("scale {bad} was accepted");
920 };
921 assert_eq!(index, 0);
922 assert_eq!(scale.to_bits(), bad.to_bits());
925 }
926 }
927
928 #[test]
929 fn a_positive_scale_below_one_is_fine() {
930 let mut file =
933 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
934 file.monitors[0].scale = 0.75;
935 assert_eq!(file.validate(), Ok(()));
936 }
937
938 #[test]
939 fn an_empty_display_is_refused() {
940 for (w, h) in [(0, 1080), (1920, 0), (-1920, 1080)] {
941 let mut file =
942 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
943 file.monitors[0].size_px = Size::new(w, h);
944 assert!(
945 matches!(file.validate(), Err(SessionError::MonitorSize { .. })),
946 "{w}x{h} was accepted"
947 );
948 }
949 }
950
951 #[test]
952 fn a_coordinate_past_the_bound_is_refused_wherever_it_hides() {
953 let far = MAX_COORD + 1;
954 let base =
955 || SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
956
957 let mut in_monitor = base();
958 in_monitor.monitors[0].origin_px = Point::new(far, 0);
959 assert!(matches!(
960 in_monitor.validate(),
961 Err(SessionError::Coordinate { .. })
962 ));
963
964 let mut in_selection = base();
965 in_selection.selections.push(SelectionRecord {
966 shape: ToolKind::Rect,
967 label: String::new(),
968 monitor: 0,
969 px: Shape::Rect(Rect::new(far, 0, 10, 10)),
970 global_px: Shape::Rect(Rect::new(0, 0, 10, 10)),
971 rot_deg: None,
972 window_px: None,
973 crop: "c.png".into(),
974 color: None,
975 });
976 assert!(matches!(
977 in_selection.validate(),
978 Err(SessionError::Coordinate { .. })
979 ));
980
981 let mut in_measure = base().with_measures(&[crate::selection::Measure::new(
982 crate::geometry::Line::new(Point::new(far, 0), Point::new(0, 0)),
983 0,
984 )]);
985 in_measure.monitors = vec![monitor(0, 0, 0)];
986 assert!(matches!(
987 in_measure.validate(),
988 Err(SessionError::Coordinate { .. })
989 ));
990 }
991
992 #[test]
993 fn the_bound_itself_is_allowed() {
994 let mut file =
997 SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
998 file.monitors[0].origin_px = Point::new(MAX_COORD, -MAX_COORD);
999 assert_eq!(file.validate(), Ok(()));
1000 }
1001
1002 #[test]
1003 fn every_shape_kind_is_walked_for_coordinates() {
1004 let far = MAX_COORD + 1;
1007 let shapes = [
1008 Shape::Rect(Rect::new(far, 0, 1, 1)),
1009 Shape::Circle {
1010 cx: far,
1011 cy: 0,
1012 r: 1,
1013 },
1014 Shape::Ellipse {
1015 cx: far,
1016 cy: 0,
1017 rx: 1,
1018 ry: 1,
1019 },
1020 Shape::Triangle {
1021 ax: far,
1022 ay: 0,
1023 bx: 1,
1024 by: 1,
1025 cx: 2,
1026 cy: 2,
1027 },
1028 Shape::Poly {
1029 points: vec![Point::new(far, 0), Point::new(1, 1), Point::new(2, 2)],
1030 },
1031 ];
1032 for shape in shapes {
1033 assert!(
1034 raw_values(&shape).iter().any(|v| v.abs() > MAX_COORD),
1035 "{shape:?} hid its out-of-range coordinate"
1036 );
1037 }
1038 }
1039
1040 #[test]
1041 fn restoring_a_session_without_measures_yields_none() {
1042 let file = SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
1043 assert!(restore_measures(&file).is_empty());
1044 }
1045
1046 #[test]
1047 fn a_session_with_measures_round_trips() {
1048 use crate::geometry::Line;
1049 use crate::selection::Measure;
1050 let file = SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None)
1051 .with_measures(&[Measure::new(
1052 Line::new(Point::new(1, 2), Point::new(3, 4)),
1053 0,
1054 )]);
1055 let text = serde_json::to_string(&file).unwrap();
1056 let back: SessionFile = serde_json::from_str(&text).unwrap();
1057 assert_eq!(back, file);
1058 }
1059
1060 fn monitor(index: usize, ox: i32, oy: i32) -> MonitorRecord {
1061 MonitorRecord {
1062 index,
1063 name: format!("Display {index}"),
1064 primary: index == 0,
1065 origin_px: Point::new(ox, oy),
1066 size_px: Size::new(1920, 1080),
1067 scale: 2.0,
1068 }
1069 }
1070
1071 fn panel(index: usize, name: &str, w: i32, h: i32, scale: f64) -> MonitorRecord {
1074 MonitorRecord {
1075 index,
1076 name: name.into(),
1077 primary: index == 0,
1078 origin_px: Point::new(0, 0),
1079 size_px: Size::new(w, h),
1080 scale,
1081 }
1082 }
1083
1084 #[test]
1085 fn a_replug_that_reorders_enumeration_still_finds_the_panel() {
1086 let saved = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
1090 let live = [
1091 panel(0, "DELL U2723QE", 3840, 2160, 1.0),
1092 panel(1, "Built-in Retina Display", 3600, 2338, 2.0),
1093 ];
1094 assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(0));
1095 }
1096
1097 #[test]
1098 fn nothing_moved_resolves_to_the_recorded_index() {
1099 let saved = panel(1, "Built-in Retina Display", 3600, 2338, 2.0);
1100 let live = [
1101 panel(0, "DELL U2723QE", 3840, 2160, 1.0),
1102 panel(1, "Built-in Retina Display", 3600, 2338, 2.0),
1103 ];
1104 assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
1105 }
1106
1107 #[test]
1108 fn identical_twins_break_toward_the_recorded_index_then_the_lowest() {
1109 let live = [
1110 panel(0, "DELL U2723QE", 3840, 2160, 1.0),
1111 panel(1, "DELL U2723QE", 3840, 2160, 1.0),
1112 ];
1113 let saved_one = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
1115 assert_eq!(match_monitor(&saved_one, &live), MonitorMatch::Found(1));
1116
1117 let saved_seven = panel(7, "DELL U2723QE", 3840, 2160, 1.0);
1120 assert_eq!(match_monitor(&saved_seven, &live), MonitorMatch::Found(0));
1121 }
1122
1123 #[test]
1124 fn the_lowest_index_wins_regardless_of_enumeration_order() {
1125 let saved = panel(9, "DELL U2723QE", 3840, 2160, 1.0);
1129 let live = [
1130 panel(3, "DELL U2723QE", 3840, 2160, 1.0),
1131 panel(1, "DELL U2723QE", 3840, 2160, 1.0),
1132 ];
1133 assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
1134 }
1135
1136 #[test]
1137 fn a_resized_display_is_changed_not_missing() {
1138 let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
1141 let live = [panel(0, "DELL U2723QE", 2560, 1440, 1.0)];
1142 assert_eq!(match_monitor(&saved, &live), MonitorMatch::Changed(0));
1143 }
1144
1145 #[test]
1146 fn a_rescaled_display_is_changed_not_missing() {
1147 let saved = panel(0, "Built-in Retina Display", 3600, 2338, 2.0);
1148 let live = [panel(0, "Built-in Retina Display", 3600, 2338, 1.0)];
1149 assert_eq!(match_monitor(&saved, &live), MonitorMatch::Changed(0));
1150 }
1151
1152 #[test]
1153 fn an_exact_match_beats_a_changed_one_of_the_same_name() {
1154 let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
1157 let live = [
1158 panel(0, "DELL U2723QE", 2560, 1440, 1.0),
1159 panel(1, "DELL U2723QE", 3840, 2160, 1.0),
1160 ];
1161 assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
1162 }
1163
1164 #[test]
1165 fn an_absent_display_is_missing_even_when_something_else_fits() {
1166 let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
1168 let live = [panel(0, "LG UltraFine", 3840, 2160, 1.0)];
1169 assert_eq!(match_monitor(&saved, &live), MonitorMatch::Missing);
1170 }
1171
1172 #[test]
1173 fn no_displays_at_all_is_missing() {
1174 let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
1175 assert_eq!(match_monitor(&saved, &[]), MonitorMatch::Missing);
1176 }
1177
1178 fn labeled(labels: &[&str]) -> SessionFile {
1180 let selections: Vec<Selection> = labels
1181 .iter()
1182 .map(|label| {
1183 let mut sel = Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0);
1184 sel.label = (*label).to_string();
1185 sel
1186 })
1187 .collect();
1188 let crops: Vec<String> = (0..labels.len()).map(|i| format!("crop-{i}.png")).collect();
1189 SessionFile::build(
1190 "test",
1191 "2026-07-27T00:00:00Z".into(),
1192 vec![monitor(0, 0, 0)],
1193 &selections,
1194 &crops,
1195 None,
1196 )
1197 }
1198
1199 #[test]
1200 fn select_by_label_keeps_session_indices() {
1201 let file = labeled(&["submit", "cancel", "submit"]);
1202
1203 let all = select_by_label(&file, None);
1204 assert_eq!(all.len(), 3, "no label selects everything");
1205 assert_eq!(all.iter().map(|(i, _)| *i).collect::<Vec<_>>(), [0, 1, 2]);
1206
1207 let some = select_by_label(&file, Some("submit"));
1210 assert_eq!(some.iter().map(|(i, _)| *i).collect::<Vec<_>>(), [0, 2]);
1211 }
1212
1213 #[test]
1214 fn select_by_label_matches_case_insensitively_and_can_come_up_empty() {
1215 let file = labeled(&["Submit"]);
1216 assert_eq!(select_by_label(&file, Some("SUBMIT")).len(), 1);
1217 assert!(
1218 select_by_label(&file, Some("nope")).is_empty(),
1219 "an unmatched label is an empty result, not an error — the \
1220 caller decides how to refuse"
1221 );
1222 }
1223
1224 #[test]
1225 fn distinct_labels_dedupes_case_insensitively_and_drops_blanks() {
1226 let file = labeled(&["submit", "", "SUBMIT", "cancel"]);
1227 assert_eq!(
1228 distinct_labels(file.selections.iter()),
1229 ["submit", "cancel"],
1230 "first spelling wins, session order is kept, unlabeled \
1231 selections contribute nothing"
1232 );
1233 }
1234
1235 #[test]
1236 fn distinct_labels_reports_only_what_it_is_given() {
1237 let file = labeled(&["submit", "cancel"]);
1240 let first_only = distinct_labels(file.selections.iter().take(1));
1241 assert_eq!(first_only, ["submit"]);
1242 }
1243
1244 #[test]
1245 fn global_is_origin_plus_local() {
1246 let mut sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 1);
1247 sel.label = "target".into();
1248 let file = SessionFile::build(
1249 "0.1.0",
1250 "2026-07-27T00:00:00Z".into(),
1251 vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
1252 &[sel],
1253 &["crop-0-target.png".into()],
1254 None,
1255 );
1256 assert_eq!(
1257 file.selections[0].px,
1258 Shape::Rect(Rect::new(10, 20, 30, 40))
1259 );
1260 assert_eq!(
1261 file.selections[0].global_px,
1262 Shape::Rect(Rect::new(1930, 20, 30, 40))
1263 );
1264 }
1265
1266 #[test]
1267 fn json_shape_is_stable() {
1268 let sel = Selection::new(Shape::Circle { cx: 5, cy: 6, r: 7 }, 0);
1269 let file = SessionFile::build(
1270 "0.1.0",
1271 "2026-07-27T00:00:00Z".into(),
1272 vec![monitor(0, 0, 0)],
1273 &[sel],
1274 &["crop-0.png".into()],
1275 None,
1276 );
1277 let json = serde_json::to_value(&file).unwrap();
1278 assert_eq!(json["schema"], 1);
1279 assert_eq!(json["app"]["name"], "pixelcoords");
1280 assert_eq!(json["selections"][0]["shape"], "circle");
1281 assert_eq!(json["selections"][0]["px"]["cx"], 5);
1282 assert_eq!(json["selections"][0]["px"]["r"], 7);
1283 assert_eq!(json["monitors"][0]["scale"], 2.0);
1284 }
1285
1286 #[test]
1287 fn round_trips_through_json() {
1288 let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
1289 let file = SessionFile::build(
1290 "0.1.0",
1291 "2026-07-27T00:00:00Z".into(),
1292 vec![monitor(0, 0, 0)],
1293 &[sel],
1294 &["c.png".into()],
1295 None,
1296 );
1297 let json = serde_json::to_string(&file).unwrap();
1298 let back: SessionFile = serde_json::from_str(&json).unwrap();
1299 assert_eq!(back, file);
1300 }
1301
1302 #[test]
1303 fn target_yields_window_relative_coords() {
1304 let on_target = Selection::new(Shape::Rect(Rect::new(500, 300, 40, 20)), 0);
1305 let elsewhere = Selection::new(Shape::Rect(Rect::new(1, 1, 5, 5)), 1);
1306 let target = TargetRecord {
1307 app: "Notepad".into(),
1308 title: "notes.txt".into(),
1309 monitor: 0,
1310 origin_px: Point::new(400, 250),
1311 size_px: Size::new(800, 600),
1312 };
1313 let file = SessionFile::build(
1314 "0.1.0",
1315 "2026-07-27T00:00:00Z".into(),
1316 vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
1317 &[on_target, elsewhere],
1318 &["a.png".into(), "b.png".into()],
1319 Some(target),
1320 );
1321 assert_eq!(
1322 file.selections[0].window_px,
1323 Some(Shape::Rect(Rect::new(100, 50, 40, 20)))
1324 );
1325 assert_eq!(file.selections[1].window_px, None);
1326 assert_eq!(file.target.as_ref().unwrap().title, "notes.txt");
1327 }
1328
1329 #[test]
1330 fn rotation_is_metadata_for_rects_and_baked_for_triangles() {
1331 let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
1332 rect_sel.rot_deg = 45;
1333 let mut tri_sel = Selection::new(
1334 Shape::Triangle {
1335 ax: 200,
1336 ay: 100,
1337 bx: 100,
1338 by: 200,
1339 cx: 300,
1340 cy: 200,
1341 },
1342 0,
1343 );
1344 tri_sel.rot_deg = 180;
1345 let plain = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
1346
1347 let file = SessionFile::build(
1348 "0.1.0",
1349 "2026-07-27T00:00:00Z".into(),
1350 vec![monitor(0, 0, 0)],
1351 &[rect_sel, tri_sel, plain],
1352 &["a.png".into(), "b.png".into(), "c.png".into()],
1353 None,
1354 );
1355 assert_eq!(
1357 file.selections[0].px,
1358 Shape::Rect(Rect::new(10, 20, 30, 40))
1359 );
1360 assert_eq!(file.selections[0].rot_deg, Some(45));
1361 assert_eq!(file.selections[1].rot_deg, None);
1363 assert_eq!(
1364 file.selections[1].px,
1365 Shape::Triangle {
1366 ax: 200,
1367 ay: 200,
1368 bx: 300,
1369 by: 100,
1370 cx: 100,
1371 cy: 100,
1372 }
1373 );
1374 let json = serde_json::to_value(&file).unwrap();
1376 assert!(json["selections"][2].get("rot_deg").is_none());
1377 assert_eq!(json["selections"][0]["rot_deg"], 45);
1378 }
1379
1380 #[test]
1381 fn untargeted_session_omits_target_fields_in_json() {
1382 let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
1383 let file = SessionFile::build(
1384 "0.1.0",
1385 "2026-07-27T00:00:00Z".into(),
1386 vec![monitor(0, 0, 0)],
1387 &[sel],
1388 &["c.png".into()],
1389 None,
1390 );
1391 let json = serde_json::to_value(&file).unwrap();
1392 assert!(json.get("target").is_none());
1393 assert!(json["selections"][0].get("window_px").is_none());
1394 }
1395
1396 #[test]
1397 fn restore_then_rebuild_reproduces_every_selection_record() {
1398 let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
1402 rect_sel.rot_deg = 45;
1403 rect_sel.label = "spun".into();
1404 let mut tri_sel = Selection::new(
1405 Shape::Triangle {
1406 ax: 200,
1407 ay: 100,
1408 bx: 100,
1409 by: 200,
1410 cx: 300,
1411 cy: 200,
1412 },
1413 1,
1414 );
1415 tri_sel.rot_deg = 90;
1416 let circle_sel = Selection::new(Shape::Circle { cx: 9, cy: 9, r: 5 }, 0);
1417
1418 let monitors = vec![monitor(0, 0, 0), monitor(1, 1920, 0)];
1419 let crops: Vec<String> = vec!["a.png".into(), "b.png".into(), "c.png".into()];
1420 let first = SessionFile::build(
1421 "test",
1422 "t".into(),
1423 monitors.clone(),
1424 &[rect_sel, tri_sel, circle_sel],
1425 &crops,
1426 None,
1427 );
1428 let (restored, _) = restore_selections(&first);
1429 let second = SessionFile::build("test", "t".into(), monitors, &restored, &crops, None);
1430 assert_eq!(first.selections, second.selections);
1431 }
1432
1433 #[test]
1434 fn provenance_is_optional_and_survives_round_trips() {
1435 let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
1436 let file = SessionFile::build(
1437 "test",
1438 "t".into(),
1439 vec![monitor(0, 0, 0)],
1440 &[sel],
1441 &["c.png".into()],
1442 None,
1443 )
1444 .with_meta(
1445 Some("macos".into()),
1446 Some(CaptureKind::Desktop),
1447 Some("microsoft teams".into()),
1448 );
1449 let json = serde_json::to_value(&file).unwrap();
1450 assert_eq!(json["platform"], "macos");
1451 assert_eq!(json["capture"], "desktop");
1452 assert_eq!(json["name"], "microsoft teams");
1453 let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
1454 assert_eq!(back, file);
1455
1456 let old = r#"{"schema":1,"app":{"name":"pixelcoords","version":"0"},
1459 "created_utc":"t","monitors":[],"selections":[]}"#;
1460 let parsed: SessionFile = serde_json::from_str(old).unwrap();
1461 assert_eq!(parsed.platform, None);
1462 assert_eq!(parsed.capture, None);
1463 assert_eq!(parsed.name, None);
1464 let bare = SessionFile::build("test", "t".into(), vec![], &[], &[], None);
1465 let json = serde_json::to_value(&bare).unwrap();
1466 assert!(json.get("platform").is_none());
1467 assert!(json.get("capture").is_none());
1468 assert!(json.get("name").is_none());
1469 }
1470
1471 #[test]
1472 fn untagged_shape_deserializes_by_fields() {
1473 let rect: Shape = serde_json::from_str(r#"{"x":1,"y":2,"w":3,"h":4}"#).unwrap();
1474 assert_eq!(rect, Shape::Rect(Rect::new(1, 2, 3, 4)));
1475 let circle: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"r":3}"#).unwrap();
1476 assert_eq!(circle, Shape::Circle { cx: 1, cy: 2, r: 3 });
1477 let ellipse: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"rx":3,"ry":4}"#).unwrap();
1478 assert_eq!(
1479 ellipse,
1480 Shape::Ellipse {
1481 cx: 1,
1482 cy: 2,
1483 rx: 3,
1484 ry: 4,
1485 }
1486 );
1487 }
1488
1489 #[test]
1490 fn poly_records_serialize_their_vertices_and_round_trip() {
1491 let sel = Selection::new(
1492 Shape::Poly {
1493 points: vec![Point::new(1, 2), Point::new(9, 2), Point::new(5, 9)],
1494 },
1495 0,
1496 );
1497 let file = SessionFile::build(
1498 "test",
1499 "t".into(),
1500 vec![monitor(0, 0, 0)],
1501 &[sel],
1502 &["c.png".into()],
1503 None,
1504 );
1505 let json = serde_json::to_value(&file).unwrap();
1506 assert_eq!(json["selections"][0]["shape"], "poly");
1507 assert_eq!(json["selections"][0]["px"]["points"][2]["x"], 5);
1508 assert_eq!(
1509 json["selections"][0].get("rot_deg"),
1510 None,
1511 "poly rotation is baked, never metadata"
1512 );
1513 let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
1514 assert_eq!(back, file);
1515 }
1516
1517 #[test]
1518 fn ellipse_records_carry_rotation_metadata_like_rects() {
1519 let mut sel = Selection::new(
1520 Shape::Ellipse {
1521 cx: 50,
1522 cy: 40,
1523 rx: 20,
1524 ry: 10,
1525 },
1526 0,
1527 );
1528 sel.rot_deg = 30;
1529 let file = SessionFile::build(
1530 "test",
1531 "t".into(),
1532 vec![monitor(0, 0, 0)],
1533 &[sel],
1534 &["c.png".into()],
1535 None,
1536 );
1537 assert_eq!(file.selections[0].rot_deg, Some(30));
1538 let json = serde_json::to_value(&file).unwrap();
1539 assert_eq!(json["selections"][0]["shape"], "ellipse");
1540 assert_eq!(json["selections"][0]["px"]["rx"], 20);
1541 let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
1542 assert_eq!(back, file);
1543 }
1544}