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