1use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::draw::Color;
14use crate::hotkeys::{Binding, HotkeyError, default_bindings};
15
16#[derive(Debug, Error, PartialEq, Eq)]
17pub enum ConfigError {
18 #[error("invalid color '{0}': expected hex RGB, 3 or 6 digits, optional '#'")]
19 Color(String),
20 #[error("thickness {0} is out of range (0-512)")]
21 Thickness(u32),
22 #[error("[snap] radius {0} is out of range (1-64 logical pixels)")]
23 SnapRadius(u32),
24 #[error(
25 "[limits] label_length {0} is out of range (1-{max}); a label becomes part of a \
26 crop's filename, and past {max} the name can outgrow what a filesystem accepts",
27 max = crate::session::MAX_LABEL_LEN
28 )]
29 LabelLength(usize),
30 #[error("[overlay] {field} {value} is out of range ({low}-{high})")]
31 Overlay {
32 field: &'static str,
33 value: u64,
34 low: u64,
35 high: u64,
36 },
37 #[error(transparent)]
38 Hotkey(#[from] HotkeyError),
39 #[error(
40 "[capture] monitors: {0} — expected \"all\", or a monitor query \
41 (an index, \"primary\", or part of a display name), or a list of them"
42 )]
43 Monitors(String),
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
47#[serde(default, deny_unknown_fields)]
48pub struct Config {
49 pub style: StyleConfig,
50 pub hotkeys: Vec<HotkeyEntry>,
51 pub capture: CaptureConfig,
52 pub snap: SnapConfig,
53 pub limits: LimitsConfig,
54 pub overlay: OverlayConfig,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(default, deny_unknown_fields)]
64pub struct LimitsConfig {
65 pub label_length: usize,
72}
73
74impl Default for LimitsConfig {
75 fn default() -> Self {
76 Self { label_length: 64 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(default, deny_unknown_fields)]
87pub struct OverlayConfig {
88 pub polygon_sides: u32,
94 pub grab_tolerance: u32,
98 pub loupe_radius: u32,
101 pub flash_ms: u64,
104 pub flash_brief_ms: u64,
108 pub caret_blink_ms: u64,
112}
113
114impl Default for OverlayConfig {
115 fn default() -> Self {
116 Self {
117 polygon_sides: 6,
118 grab_tolerance: 6,
119 loupe_radius: 15,
120 flash_ms: 2500,
121 flash_brief_ms: 1200,
122 caret_blink_ms: 500,
123 }
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(default, deny_unknown_fields)]
130pub struct SnapConfig {
131 pub enabled: bool,
137 pub radius: u32,
140}
141
142impl Default for SnapConfig {
143 fn default() -> Self {
144 Self {
145 enabled: true,
146 radius: 8,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
152#[serde(default, deny_unknown_fields)]
153pub struct CaptureConfig {
154 pub monitors: Option<MonitorsSetting>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum MonitorsSetting {
166 One(String),
167 Many(Vec<String>),
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(default, deny_unknown_fields)]
172pub struct StyleConfig {
173 pub preview_color: String,
175 pub complete_color: String,
177 pub label_color: String,
179 pub target_color: String,
181 pub thickness: u32,
183 pub fill: bool,
185}
186
187impl Default for StyleConfig {
188 fn default() -> Self {
189 Self {
190 preview_color: "#00A0FF".into(),
191 complete_color: "#00FF66".into(),
192 label_color: "#FFFFFF".into(),
193 target_color: "#FFB000".into(),
194 thickness: 2,
195 fill: false,
196 }
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct HotkeyEntry {
203 pub key: String,
204 pub action: String,
205 pub edge: Option<String>,
206 pub when: Option<String>,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub struct SnapSettings {
212 pub enabled: bool,
213 pub radius: i32,
215}
216
217impl Default for SnapSettings {
218 fn default() -> Self {
221 Self {
222 enabled: true,
223 radius: 8,
224 }
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub struct OverlaySettings {
235 pub polygon_sides: u32,
236 pub grab_tolerance: i32,
237 pub loupe_radius: i32,
238 pub flash: Duration,
239 pub flash_brief: Duration,
240 pub caret_blink: Option<Duration>,
241}
242
243impl Default for OverlaySettings {
244 fn default() -> Self {
245 OverlayConfig::default()
246 .into_settings()
247 .expect("the defaults are in range by construction")
248 }
249}
250
251impl OverlayConfig {
252 fn into_settings(self) -> Result<OverlaySettings, ConfigError> {
253 Config {
254 overlay: self,
255 ..Config::default()
256 }
257 .resolve_overlay()
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub struct Style {
264 pub preview: Color,
265 pub complete: Color,
266 pub label: Color,
267 pub target: Color,
268 pub thickness: i32,
269 pub fill: bool,
270}
271
272impl Config {
273 pub fn resolve_style(&self) -> Result<Style, ConfigError> {
274 let s = &self.style;
275 if s.thickness > 512 {
276 return Err(ConfigError::Thickness(s.thickness));
277 }
278 Ok(Style {
279 preview: parse_hex_color(&s.preview_color)?,
280 complete: parse_hex_color(&s.complete_color)?,
281 label: parse_hex_color(&s.label_color)?,
282 target: parse_hex_color(&s.target_color)?,
283 thickness: s.thickness as i32,
284 fill: s.fill,
285 })
286 }
287
288 pub fn resolve_snap(&self) -> Result<SnapSettings, ConfigError> {
295 if self.snap.radius == 0 || self.snap.radius > 64 {
296 return Err(ConfigError::SnapRadius(self.snap.radius));
297 }
298 Ok(SnapSettings {
299 enabled: self.snap.enabled,
300 radius: i32::try_from(self.snap.radius).unwrap_or(64),
301 })
302 }
303
304 pub fn resolve_limits(&self) -> Result<LimitsConfig, ConfigError> {
306 let len = self.limits.label_length;
307 if len == 0 || len > crate::session::MAX_LABEL_LEN {
308 return Err(ConfigError::LabelLength(len));
309 }
310 Ok(self.limits)
311 }
312
313 pub fn resolve_overlay(&self) -> Result<OverlaySettings, ConfigError> {
321 let o = self.overlay;
322 let check = |field, value: u64, low: u64, high: u64| {
323 if value < low || value > high {
324 return Err(ConfigError::Overlay {
325 field,
326 value,
327 low,
328 high,
329 });
330 }
331 Ok(())
332 };
333 check(
334 "polygon_sides",
335 u64::from(o.polygon_sides),
336 u64::from(crate::geometry::MIN_POLYGON_SIDES),
337 u64::from(crate::geometry::MAX_POLYGON_SIDES),
338 )?;
339 check("grab_tolerance", u64::from(o.grab_tolerance), 1, 256)?;
340 check("loupe_radius", u64::from(o.loupe_radius), 1, 256)?;
341 check("flash_ms", o.flash_ms, 1, 60_000)?;
342 check("flash_brief_ms", o.flash_brief_ms, 1, 60_000)?;
343 check("caret_blink_ms", o.caret_blink_ms, 0, 60_000)?;
344 Ok(OverlaySettings {
345 polygon_sides: o.polygon_sides,
346 grab_tolerance: i32::try_from(o.grab_tolerance).unwrap_or(6),
347 loupe_radius: i32::try_from(o.loupe_radius).unwrap_or(15),
348 flash: Duration::from_millis(o.flash_ms),
349 flash_brief: Duration::from_millis(o.flash_brief_ms),
350 caret_blink: (o.caret_blink_ms > 0).then(|| Duration::from_millis(o.caret_blink_ms)),
351 })
352 }
353
354 pub fn resolve_monitors(&self) -> Result<Vec<String>, ConfigError> {
365 let raw = match &self.capture.monitors {
366 None => return Ok(Vec::new()),
367 Some(MonitorsSetting::One(one)) => vec![one.clone()],
368 Some(MonitorsSetting::Many(many)) => {
369 if many.is_empty() {
370 return Err(ConfigError::Monitors("the list is empty".into()));
371 }
372 many.clone()
373 }
374 };
375 if raw.len() == 1 && raw[0].trim().eq_ignore_ascii_case("all") {
379 return Ok(Vec::new());
380 }
381 for query in &raw {
382 if query.trim().is_empty() {
383 return Err(ConfigError::Monitors("an entry is empty".into()));
384 }
385 if query.trim().eq_ignore_ascii_case("all") {
386 return Err(ConfigError::Monitors(
387 "\"all\" cannot be combined with other monitors".into(),
388 ));
389 }
390 }
391 Ok(raw)
392 }
393
394 pub fn resolve_bindings(&self, extra: &[String]) -> Result<Vec<Binding>, ConfigError> {
400 let mut user: Vec<Binding> = Vec::new();
401 for entry in &self.hotkeys {
402 let mut spec = format!("{}={}", entry.key, entry.action);
403 for part in [&entry.edge, &entry.when].into_iter().flatten() {
404 spec.push(',');
405 spec.push_str(part);
406 }
407 user.push(Binding::parse(&spec)?);
408 }
409 for spec in extra {
410 user.push(Binding::parse(spec)?);
411 }
412 let user_keys: std::collections::HashSet<_> = user.iter().map(|b| b.key).collect();
413 let mut bindings: Vec<Binding> = default_bindings()
414 .into_iter()
415 .filter(|b| !user_keys.contains(&b.key))
416 .collect();
417 bindings.extend(user);
418 Ok(bindings)
419 }
420}
421
422pub fn parse_hex_color(input: &str) -> Result<Color, ConfigError> {
424 let s = input
425 .trim()
426 .strip_prefix('#')
427 .unwrap_or_else(|| input.trim());
428 let expanded: String = match s.len() {
429 3 => s.chars().flat_map(|c| [c, c]).collect(),
430 6 => s.to_string(),
431 _ => return Err(ConfigError::Color(input.to_string())),
432 };
433 if !expanded.chars().all(|c| c.is_ascii_hexdigit()) {
434 return Err(ConfigError::Color(input.to_string()));
435 }
436 let channel = |range| u8::from_str_radix(&expanded[range], 16).unwrap_or_default();
437 Ok(Color {
438 r: channel(0..2),
439 g: channel(2..4),
440 b: channel(4..6),
441 })
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::hotkeys::{Action, Edge, KeyName, OverlayState, match_event};
448
449 #[test]
450 fn hex_six_digit_with_hash() {
451 assert_eq!(
452 parse_hex_color("#FF8000").unwrap(),
453 Color {
454 r: 255,
455 g: 128,
456 b: 0
457 }
458 );
459 }
460
461 #[test]
462 fn hex_without_hash_and_lowercase() {
463 assert_eq!(
464 parse_hex_color("00a0ff").unwrap(),
465 Color {
466 r: 0,
467 g: 160,
468 b: 255
469 }
470 );
471 }
472
473 #[test]
474 fn hex_three_digit_expands() {
475 assert_eq!(
476 parse_hex_color("#F80").unwrap(),
477 Color {
478 r: 255,
479 g: 136,
480 b: 0
481 }
482 );
483 }
484
485 #[test]
486 fn hex_rejects_bad_input() {
487 for bad in ["", "#", "12345", "1234567", "GGGGGG", "#12 456"] {
488 assert!(parse_hex_color(bad).is_err(), "{bad:?} should be rejected");
489 }
490 }
491
492 fn capture(toml: &str) -> Result<Vec<String>, ConfigError> {
493 let cfg: Config = ::toml::from_str(toml).expect("parses");
494 cfg.resolve_monitors()
495 }
496
497 #[test]
498 fn no_capture_table_means_every_monitor() {
499 assert!(capture("").unwrap().is_empty());
500 assert!(capture("[capture]\n").unwrap().is_empty());
501 }
502
503 #[test]
504 fn all_is_the_launch_default_said_out_loud() {
505 assert!(
506 capture("[capture]\nmonitors = \"all\"\n")
507 .unwrap()
508 .is_empty()
509 );
510 assert!(
511 capture("[capture]\nmonitors = \"ALL\"\n")
512 .unwrap()
513 .is_empty()
514 );
515 }
516
517 #[test]
518 fn a_single_query_and_a_list_both_parse() {
519 assert_eq!(
520 capture("[capture]\nmonitors = \"primary\"\n").unwrap(),
521 vec!["primary".to_string()]
522 );
523 assert_eq!(
524 capture("[capture]\nmonitors = [\"DELL\", \"Built-in\"]\n").unwrap(),
525 vec!["DELL".to_string(), "Built-in".to_string()]
526 );
527 }
528
529 #[test]
530 fn empty_and_contradictory_values_are_errors_not_silent_defaults() {
531 assert!(capture("[capture]\nmonitors = \"\"\n").is_err());
534 assert!(capture("[capture]\nmonitors = \" \"\n").is_err());
535 assert!(capture("[capture]\nmonitors = []\n").is_err());
536 assert!(capture("[capture]\nmonitors = [\"DELL\", \"\"]\n").is_err());
537 assert!(capture("[capture]\nmonitors = [\"all\", \"DELL\"]\n").is_err());
540 }
541
542 #[test]
543 fn an_unknown_capture_key_is_refused_like_every_other_table() {
544 assert!(::toml::from_str::<Config>("[capture]\nmonitor = \"primary\"\n").is_err());
545 }
546
547 #[test]
548 fn default_config_resolves() {
549 let cfg = Config::default();
550 let style = cfg.resolve_style().unwrap();
551 assert_eq!(style.thickness, 2);
552 assert!(!style.fill);
553 assert_eq!(
554 style.label,
555 Color {
556 r: 255,
557 g: 255,
558 b: 255
559 }
560 );
561 }
562
563 #[test]
564 fn thickness_out_of_range_errors() {
565 let mut cfg = Config::default();
566 cfg.style.thickness = 513;
567 assert_eq!(
568 cfg.resolve_style().unwrap_err(),
569 ConfigError::Thickness(513)
570 );
571 }
572
573 #[test]
574 fn toml_round_trip_and_hotkey_merge() {
575 let toml_src = r##"
576 [style]
577 preview_color = "#F00"
578 thickness = 4
579
580 [[hotkeys]]
581 key = "x"
582 action = "save"
583 when = "has_selection"
584 "##;
585 let cfg: Config = toml::from_str(toml_src).unwrap();
586 let style = cfg.resolve_style().unwrap();
587 assert_eq!(style.preview, Color { r: 255, g: 0, b: 0 });
588 assert_eq!(style.thickness, 4);
589 assert!(!style.fill);
591
592 let bindings = cfg.resolve_bindings(&[]).unwrap();
593 let state = OverlayState {
594 has_selection: true,
595 cursor_in_shape: false,
596 };
597 assert_eq!(
598 match_event(&bindings, KeyName::Character('X'), Edge::Press, state),
599 Some(Action::Save)
600 );
601 }
602
603 #[test]
604 fn unknown_toml_field_is_rejected() {
605 let err = toml::from_str::<Config>("[style]\npreview_colour = \"#F00\"\n");
606 assert!(err.is_err());
607 }
608
609 #[test]
610 fn rebinding_a_key_removes_all_its_default_edges() {
611 let cfg = Config::default();
614 let bindings = cfg.resolve_bindings(&["q=next_tool".to_string()]).unwrap();
615 let state = OverlayState {
616 cursor_in_shape: true,
617 ..OverlayState::default()
618 };
619 assert_eq!(
620 match_event(&bindings, KeyName::Character('Q'), Edge::Press, state),
621 Some(Action::NextTool)
622 );
623 assert_eq!(
624 match_event(&bindings, KeyName::Character('Q'), Edge::Repeat, state),
625 None,
626 "repeat-edge default must be gone"
627 );
628 assert_eq!(
630 match_event(&bindings, KeyName::Character('E'), Edge::Repeat, state),
631 Some(Action::RotateCw)
632 );
633 }
634
635 #[test]
636 fn cli_bind_shadows_defaults() {
637 let cfg = Config::default();
638 let bindings = cfg.resolve_bindings(&["q=undo".to_string()]).unwrap();
639 assert_eq!(
640 match_event(
641 &bindings,
642 KeyName::Character('Q'),
643 Edge::Press,
644 OverlayState::default()
645 ),
646 Some(Action::Undo)
647 );
648 }
649
650 #[test]
651 fn bad_hotkey_entry_is_an_error() {
652 let mut cfg = Config::default();
653 cfg.hotkeys.push(HotkeyEntry {
654 key: "z".into(),
655 action: "teleport".into(),
656 edge: None,
657 when: None,
658 });
659 assert!(matches!(
660 cfg.resolve_bindings(&[]),
661 Err(ConfigError::Hotkey(_))
662 ));
663 }
664
665 #[test]
666 fn the_new_tables_default_to_todays_behavior() {
667 let c = Config::default();
670 assert_eq!(c.resolve_limits().unwrap().label_length, 64);
671 let o = c.resolve_overlay().unwrap();
672 assert_eq!(o.polygon_sides, 6);
673 assert_eq!(o.grab_tolerance, 6);
674 assert_eq!(o.loupe_radius, 15);
675 assert_eq!(o.flash, Duration::from_millis(2500));
676 assert_eq!(o.flash_brief, Duration::from_millis(1200));
677 assert_eq!(o.caret_blink, Some(Duration::from_millis(500)));
678 }
679
680 #[test]
681 fn an_absent_table_is_the_default_not_an_error() {
682 let c: Config = toml::from_str("[style]\nthickness = 3\n").unwrap();
683 assert_eq!(c.limits, LimitsConfig::default());
684 assert_eq!(c.overlay, OverlayConfig::default());
685 }
686
687 #[test]
688 fn a_zero_caret_blink_means_do_not_blink() {
689 let c: Config = toml::from_str("[overlay]\ncaret_blink_ms = 0\n").unwrap();
692 assert_eq!(c.resolve_overlay().unwrap().caret_blink, None);
693 }
694
695 #[test]
696 fn a_polygon_default_past_the_digit_keys_is_allowed() {
697 let c: Config = toml::from_str("[overlay]\npolygon_sides = 24\n").unwrap();
700 assert_eq!(c.resolve_overlay().unwrap().polygon_sides, 24);
701 }
702
703 #[test]
704 fn out_of_range_overlay_values_are_errors_naming_the_field() {
705 let cases = [
706 ("polygon_sides = 2", "polygon_sides"),
707 ("polygon_sides = 100000", "polygon_sides"),
708 ("grab_tolerance = 0", "grab_tolerance"),
709 ("loupe_radius = 0", "loupe_radius"),
710 ("flash_ms = 0", "flash_ms"),
711 ("flash_brief_ms = 999999", "flash_brief_ms"),
712 ("caret_blink_ms = 999999", "caret_blink_ms"),
713 ];
714 for (line, field) in cases {
715 let c: Config = toml::from_str(&format!("[overlay]\n{line}\n")).unwrap();
716 let Err(err) = c.resolve_overlay() else {
717 panic!("{line} was accepted");
718 };
719 let rendered = err.to_string();
720 assert!(rendered.contains(field), "{rendered:?} omits {field}");
721 }
722 }
723
724 #[test]
725 fn a_label_length_past_what_a_filename_holds_is_refused() {
726 for len in [0, crate::session::MAX_LABEL_LEN + 1] {
727 let c: Config = toml::from_str(&format!("[limits]\nlabel_length = {len}\n")).unwrap();
728 assert_eq!(c.resolve_limits(), Err(ConfigError::LabelLength(len)));
729 }
730 let c: Config = toml::from_str(&format!(
733 "[limits]\nlabel_length = {}\n",
734 crate::session::MAX_LABEL_LEN
735 ))
736 .unwrap();
737 assert!(c.resolve_limits().is_ok());
738 }
739
740 #[test]
741 fn the_new_tables_refuse_unknown_keys_like_every_other() {
742 assert!(toml::from_str::<Config>("[limits]\nlabel_len = 4\n").is_err());
743 assert!(toml::from_str::<Config>("[overlay]\nloupe = 4\n").is_err());
744 }
745
746 #[test]
747 fn snap_defaults_are_on_with_a_small_radius() {
748 let settings = Config::default().resolve_snap().unwrap();
749 assert!(settings.enabled, "snapping is on unless turned off");
750 assert_eq!(settings.radius, 8);
751 }
752
753 #[test]
754 fn a_snap_radius_outside_the_range_is_an_error_not_a_default() {
755 for radius in [0, 65, 10_000] {
756 let mut config = Config::default();
757 config.snap.radius = radius;
758 assert_eq!(
759 config.resolve_snap(),
760 Err(ConfigError::SnapRadius(radius)),
761 "radius {radius}"
762 );
763 }
764 }
765
766 #[test]
767 fn the_snap_table_parses_and_rejects_unknown_keys() {
768 let config: Config = toml::from_str("[snap]\nenabled = false\nradius = 16\n").unwrap();
769 let settings = config.resolve_snap().unwrap();
770 assert!(!settings.enabled);
771 assert_eq!(settings.radius, 16);
772 assert!(toml::from_str::<Config>("[snap]\nradius_px = 4\n").is_err());
773 }
774
775 #[test]
776 fn an_absent_snap_table_is_the_default_not_an_error() {
777 let config: Config = toml::from_str("[style]\nthickness = 3\n").unwrap();
778 assert_eq!(config.snap, SnapConfig::default());
779 }
780}