Skip to main content

pixelcoords_core/
config.rs

1//! Configuration types (serde) and their resolution into validated values.
2//!
3//! The structs here are plain data the binary deserializes from TOML; this
4//! crate stays parser-agnostic. Resolution is strict: bad colors, silly
5//! thicknesses, and unknown hotkey pieces are errors, never silently
6//! defaulted (the predecessor's silent numeric fallbacks were a bug class).
7
8use 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/// Constraints on what a session may hold.
58///
59/// Separate from [`OverlayConfig`] because these bound the *data*, not the
60/// feel: a label that is too long is a filename the save cannot write,
61/// whichever way it was typed.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(default, deny_unknown_fields)]
64pub struct LimitsConfig {
65    /// How many characters a label may hold.
66    ///
67    /// The default is 64 and the ceiling is
68    /// [`crate::session::MAX_LABEL_LEN`], which is derived from what a
69    /// filename can be rather than chosen. Lower it if you want shorter
70    /// crop names; there is no reason to, and no harm either.
71    pub label_length: usize,
72}
73
74impl Default for LimitsConfig {
75    fn default() -> Self {
76        Self { label_length: 64 }
77    }
78}
79
80/// How the overlay feels: reach, magnification, and how long it talks.
81///
82/// None of these change what is saved. They exist because the right
83/// number is a matter of hand, screen, and eyesight rather than something
84/// this project can pick for everyone.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(default, deny_unknown_fields)]
87pub struct OverlayConfig {
88    /// Sides the polygon tool starts on.
89    ///
90    /// The digit keys reach 3 to 9 because that is what one keypress can
91    /// say. This is how you get anything else: set it here and the tool
92    /// opens on it.
93    pub polygon_sides: u32,
94    /// How close to a border counts as grabbing it, in logical pixels,
95    /// scaled per monitor. Larger is easier on a trackpad and makes small
96    /// shapes harder to click inside.
97    pub grab_tolerance: u32,
98    /// The magnifier's source radius: it shows a `2r+1` pixel square, so
99    /// larger means more context at less magnification.
100    pub loupe_radius: u32,
101    /// How long a message stays on screen — saves, errors, the things
102    /// worth reading twice.
103    pub flash_ms: u64,
104    /// How long the brief messages stay: tool switches and the like, the
105    /// ones confirming something you just did on purpose. Raise it if
106    /// 1.2 seconds is not long enough to read comfortably.
107    pub flash_brief_ms: u64,
108    /// Caret blink interval in the label editor. **`0` stops the blink**
109    /// and leaves the caret solid, which is a supported value rather than
110    /// an accident of the arithmetic.
111    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/// Edge snapping: whether it starts on, and how far it reaches.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(default, deny_unknown_fields)]
130pub struct SnapConfig {
131    /// The launch default. Snapping starts **on**: the radius is small
132    /// enough that placement away from an edge is untouched, and the
133    /// feature is worthless if it has to be discovered before it helps.
134    /// The toggle key flips it live and does not persist — this file owns
135    /// the default.
136    pub enabled: bool,
137    /// Search radius in **logical** pixels, scaled per monitor's DPI, so
138    /// one config behaves the same on a Retina panel and a 1x one.
139    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    /// Which monitors a no-flag launch freezes. `None` (the table absent,
155    /// or the key absent) means all of them — the launch default, and the
156    /// product's thesis. This is the answer for a double-clicked binary,
157    /// which has no terminal to pass `--monitor` on.
158    pub monitors: Option<MonitorsSetting>,
159}
160
161/// `monitors = "primary"` and `monitors = ["DELL", "Built-in"]` are both
162/// natural to write, so both parse.
163#[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    /// Outline color while dragging out a shape.
174    pub preview_color: String,
175    /// Outline color of committed shapes.
176    pub complete_color: String,
177    /// Label and HUD text color.
178    pub label_color: String,
179    /// Border color drawn around the `--target` window.
180    pub target_color: String,
181    /// Outline thickness in pixels; 0 hides outlines.
182    pub thickness: u32,
183    /// Fill shapes instead of outlining them.
184    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/// Validated snapping settings.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub struct SnapSettings {
212    pub enabled: bool,
213    /// Logical pixels; the overlay multiplies by each monitor's UI scale.
214    pub radius: i32,
215}
216
217impl Default for SnapSettings {
218    /// What `SnapConfig::default()` resolves to, without going through
219    /// the fallible path — the defaults are in range by construction.
220    fn default() -> Self {
221        Self {
222            enabled: true,
223            radius: 8,
224        }
225    }
226}
227
228/// Validated overlay comfort values, in the units the overlay uses.
229///
230/// `caret_blink` is an `Option` rather than a zero duration: "do not
231/// blink" is a different instruction from "blink every zero
232/// milliseconds", and the type says which one it is.
233#[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/// Validated, ready-to-use style values.
262#[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    /// Snapping settings, with the radius range-checked.
289    ///
290    /// A radius of 0 would be a silently disabled feature and a huge one
291    /// would drag the cursor across half the screen; both are more likely
292    /// a typo than an intent, and this module's whole premise is that a
293    /// nonsense number is an error rather than a quiet default.
294    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    /// Validated limits.
305    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    /// Validated overlay comfort values.
314    ///
315    /// Every bound here is a range a person could plausibly want, widened
316    /// generously — the point of the table is to stop the tool deciding
317    /// for you, so the checks exist to catch a typo rather than to hold an
318    /// opinion. `caret_blink_ms` starts at 0 on purpose: 0 means "do not
319    /// blink", which is the one value someone may actively need.
320    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    /// The monitor queries a no-flag launch should use, or an empty vec
355    /// meaning all of them.
356    ///
357    /// Only the *shape* is checked here — this crate has no idea what is
358    /// plugged in. A query that names no attached display fails at launch
359    /// with the same message `--monitor` gives, which is the honest place
360    /// for it: the answer depends on the hardware, not the file. What is
361    /// rejected here is a value that could never mean anything on any
362    /// machine, because a silent default is the bug class this module
363    /// exists to avoid.
364    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        // "all" is the launch default said out loud, and only means that on
376        // its own — in a list it would be a display name, which is a
377        // contradiction worth naming rather than resolving.
378        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    /// Defaults, then config-file entries, then `extra` (CLI `--bind`).
395    /// Binding any key removes ALL default bindings for that key (every
396    /// edge), so rebinding `[` doesn't leave the default's repeat-edge
397    /// rotation alive; among user bindings, later entries shadow earlier
398    /// ones per key + edge.
399    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
422/// Parse `RGB`/`RRGGBB` with optional `#`, matching the predecessor's rules.
423pub 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        // The bug class this module exists to avoid: a value that means
532        // nothing quietly becoming "freeze everything".
533        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        // "all" is only the default on its own; alongside a name it is a
538        // contradiction rather than a display.
539        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        // Unspecified fields keep defaults.
590        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        // 'Q' has press AND repeat defaults for rotate_ccw; rebinding it
612        // must silence both, not leave the repeat default alive.
613        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        // Untouched keys keep their defaults.
629        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        // The whole point of a default is that an absent table changes
668        // nothing, so these are the constants they replaced.
669        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        // Not an error and not a zero-length interval: the one value
690        // someone may actively need, so it has to be expressible.
691        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        // The reason this knob exists: 3..=9 is what one keypress can
698        // say, and this is how anything else is reachable.
699        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        // The ceiling itself is allowed — a bound that rejects its own
731        // limit is an off-by-one nobody thinks to test for.
732        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}