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 serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::draw::Color;
12use crate::hotkeys::{Binding, HotkeyError, default_bindings};
13
14#[derive(Debug, Error, PartialEq, Eq)]
15pub enum ConfigError {
16    #[error("invalid color '{0}': expected hex RGB, 3 or 6 digits, optional '#'")]
17    Color(String),
18    #[error("thickness {0} is out of range (0-512)")]
19    Thickness(u32),
20    #[error("[snap] radius {0} is out of range (1-64 logical pixels)")]
21    SnapRadius(u32),
22    #[error(transparent)]
23    Hotkey(#[from] HotkeyError),
24    #[error(
25        "[capture] monitors: {0} — expected \"all\", or a monitor query \
26         (an index, \"primary\", or part of a display name), or a list of them"
27    )]
28    Monitors(String),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(default, deny_unknown_fields)]
33pub struct Config {
34    pub style: StyleConfig,
35    pub hotkeys: Vec<HotkeyEntry>,
36    pub capture: CaptureConfig,
37    pub snap: SnapConfig,
38}
39
40/// Edge snapping: whether it starts on, and how far it reaches.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(default, deny_unknown_fields)]
43pub struct SnapConfig {
44    /// The launch default. Snapping starts **on**: the radius is small
45    /// enough that placement away from an edge is untouched, and the
46    /// feature is worthless if it has to be discovered before it helps.
47    /// The toggle key flips it live and does not persist — this file owns
48    /// the default.
49    pub enabled: bool,
50    /// Search radius in **logical** pixels, scaled per monitor's DPI, so
51    /// one config behaves the same on a Retina panel and a 1x one.
52    pub radius: u32,
53}
54
55impl Default for SnapConfig {
56    fn default() -> Self {
57        Self {
58            enabled: true,
59            radius: 8,
60        }
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[serde(default, deny_unknown_fields)]
66pub struct CaptureConfig {
67    /// Which monitors a no-flag launch freezes. `None` (the table absent,
68    /// or the key absent) means all of them — the launch default, and the
69    /// product's thesis. This is the answer for a double-clicked binary,
70    /// which has no terminal to pass `--monitor` on.
71    pub monitors: Option<MonitorsSetting>,
72}
73
74/// `monitors = "primary"` and `monitors = ["DELL", "Built-in"]` are both
75/// natural to write, so both parse.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(untagged)]
78pub enum MonitorsSetting {
79    One(String),
80    Many(Vec<String>),
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(default, deny_unknown_fields)]
85pub struct StyleConfig {
86    /// Outline color while dragging out a shape.
87    pub preview_color: String,
88    /// Outline color of committed shapes.
89    pub complete_color: String,
90    /// Label and HUD text color.
91    pub label_color: String,
92    /// Border color drawn around the `--target` window.
93    pub target_color: String,
94    /// Outline thickness in pixels; 0 hides outlines.
95    pub thickness: u32,
96    /// Fill shapes instead of outlining them.
97    pub fill: bool,
98}
99
100impl Default for StyleConfig {
101    fn default() -> Self {
102        Self {
103            preview_color: "#00A0FF".into(),
104            complete_color: "#00FF66".into(),
105            label_color: "#FFFFFF".into(),
106            target_color: "#FFB000".into(),
107            thickness: 2,
108            fill: false,
109        }
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct HotkeyEntry {
116    pub key: String,
117    pub action: String,
118    pub edge: Option<String>,
119    pub when: Option<String>,
120}
121
122/// Validated snapping settings.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct SnapSettings {
125    pub enabled: bool,
126    /// Logical pixels; the overlay multiplies by each monitor's UI scale.
127    pub radius: i32,
128}
129
130impl Default for SnapSettings {
131    /// What `SnapConfig::default()` resolves to, without going through
132    /// the fallible path — the defaults are in range by construction.
133    fn default() -> Self {
134        Self {
135            enabled: true,
136            radius: 8,
137        }
138    }
139}
140
141/// Validated, ready-to-use style values.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct Style {
144    pub preview: Color,
145    pub complete: Color,
146    pub label: Color,
147    pub target: Color,
148    pub thickness: i32,
149    pub fill: bool,
150}
151
152impl Config {
153    pub fn resolve_style(&self) -> Result<Style, ConfigError> {
154        let s = &self.style;
155        if s.thickness > 512 {
156            return Err(ConfigError::Thickness(s.thickness));
157        }
158        Ok(Style {
159            preview: parse_hex_color(&s.preview_color)?,
160            complete: parse_hex_color(&s.complete_color)?,
161            label: parse_hex_color(&s.label_color)?,
162            target: parse_hex_color(&s.target_color)?,
163            thickness: s.thickness as i32,
164            fill: s.fill,
165        })
166    }
167
168    /// Snapping settings, with the radius range-checked.
169    ///
170    /// A radius of 0 would be a silently disabled feature and a huge one
171    /// would drag the cursor across half the screen; both are more likely
172    /// a typo than an intent, and this module's whole premise is that a
173    /// nonsense number is an error rather than a quiet default.
174    pub fn resolve_snap(&self) -> Result<SnapSettings, ConfigError> {
175        if self.snap.radius == 0 || self.snap.radius > 64 {
176            return Err(ConfigError::SnapRadius(self.snap.radius));
177        }
178        Ok(SnapSettings {
179            enabled: self.snap.enabled,
180            radius: i32::try_from(self.snap.radius).unwrap_or(64),
181        })
182    }
183
184    /// The monitor queries a no-flag launch should use, or an empty vec
185    /// meaning all of them.
186    ///
187    /// Only the *shape* is checked here — this crate has no idea what is
188    /// plugged in. A query that names no attached display fails at launch
189    /// with the same message `--monitor` gives, which is the honest place
190    /// for it: the answer depends on the hardware, not the file. What is
191    /// rejected here is a value that could never mean anything on any
192    /// machine, because a silent default is the bug class this module
193    /// exists to avoid.
194    pub fn resolve_monitors(&self) -> Result<Vec<String>, ConfigError> {
195        let raw = match &self.capture.monitors {
196            None => return Ok(Vec::new()),
197            Some(MonitorsSetting::One(one)) => vec![one.clone()],
198            Some(MonitorsSetting::Many(many)) => {
199                if many.is_empty() {
200                    return Err(ConfigError::Monitors("the list is empty".into()));
201                }
202                many.clone()
203            }
204        };
205        // "all" is the launch default said out loud, and only means that on
206        // its own — in a list it would be a display name, which is a
207        // contradiction worth naming rather than resolving.
208        if raw.len() == 1 && raw[0].trim().eq_ignore_ascii_case("all") {
209            return Ok(Vec::new());
210        }
211        for query in &raw {
212            if query.trim().is_empty() {
213                return Err(ConfigError::Monitors("an entry is empty".into()));
214            }
215            if query.trim().eq_ignore_ascii_case("all") {
216                return Err(ConfigError::Monitors(
217                    "\"all\" cannot be combined with other monitors".into(),
218                ));
219            }
220        }
221        Ok(raw)
222    }
223
224    /// Defaults, then config-file entries, then `extra` (CLI `--bind`).
225    /// Binding any key removes ALL default bindings for that key (every
226    /// edge), so rebinding `[` doesn't leave the default's repeat-edge
227    /// rotation alive; among user bindings, later entries shadow earlier
228    /// ones per key + edge.
229    pub fn resolve_bindings(&self, extra: &[String]) -> Result<Vec<Binding>, ConfigError> {
230        let mut user: Vec<Binding> = Vec::new();
231        for entry in &self.hotkeys {
232            let mut spec = format!("{}={}", entry.key, entry.action);
233            for part in [&entry.edge, &entry.when].into_iter().flatten() {
234                spec.push(',');
235                spec.push_str(part);
236            }
237            user.push(Binding::parse(&spec)?);
238        }
239        for spec in extra {
240            user.push(Binding::parse(spec)?);
241        }
242        let user_keys: std::collections::HashSet<_> = user.iter().map(|b| b.key).collect();
243        let mut bindings: Vec<Binding> = default_bindings()
244            .into_iter()
245            .filter(|b| !user_keys.contains(&b.key))
246            .collect();
247        bindings.extend(user);
248        Ok(bindings)
249    }
250}
251
252/// Parse `RGB`/`RRGGBB` with optional `#`, matching the predecessor's rules.
253pub fn parse_hex_color(input: &str) -> Result<Color, ConfigError> {
254    let s = input
255        .trim()
256        .strip_prefix('#')
257        .unwrap_or_else(|| input.trim());
258    let expanded: String = match s.len() {
259        3 => s.chars().flat_map(|c| [c, c]).collect(),
260        6 => s.to_string(),
261        _ => return Err(ConfigError::Color(input.to_string())),
262    };
263    if !expanded.chars().all(|c| c.is_ascii_hexdigit()) {
264        return Err(ConfigError::Color(input.to_string()));
265    }
266    let channel = |range| u8::from_str_radix(&expanded[range], 16).unwrap_or_default();
267    Ok(Color {
268        r: channel(0..2),
269        g: channel(2..4),
270        b: channel(4..6),
271    })
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::hotkeys::{Action, Edge, KeyName, OverlayState, match_event};
278
279    #[test]
280    fn hex_six_digit_with_hash() {
281        assert_eq!(
282            parse_hex_color("#FF8000").unwrap(),
283            Color {
284                r: 255,
285                g: 128,
286                b: 0
287            }
288        );
289    }
290
291    #[test]
292    fn hex_without_hash_and_lowercase() {
293        assert_eq!(
294            parse_hex_color("00a0ff").unwrap(),
295            Color {
296                r: 0,
297                g: 160,
298                b: 255
299            }
300        );
301    }
302
303    #[test]
304    fn hex_three_digit_expands() {
305        assert_eq!(
306            parse_hex_color("#F80").unwrap(),
307            Color {
308                r: 255,
309                g: 136,
310                b: 0
311            }
312        );
313    }
314
315    #[test]
316    fn hex_rejects_bad_input() {
317        for bad in ["", "#", "12345", "1234567", "GGGGGG", "#12 456"] {
318            assert!(parse_hex_color(bad).is_err(), "{bad:?} should be rejected");
319        }
320    }
321
322    fn capture(toml: &str) -> Result<Vec<String>, ConfigError> {
323        let cfg: Config = ::toml::from_str(toml).expect("parses");
324        cfg.resolve_monitors()
325    }
326
327    #[test]
328    fn no_capture_table_means_every_monitor() {
329        assert!(capture("").unwrap().is_empty());
330        assert!(capture("[capture]\n").unwrap().is_empty());
331    }
332
333    #[test]
334    fn all_is_the_launch_default_said_out_loud() {
335        assert!(
336            capture("[capture]\nmonitors = \"all\"\n")
337                .unwrap()
338                .is_empty()
339        );
340        assert!(
341            capture("[capture]\nmonitors = \"ALL\"\n")
342                .unwrap()
343                .is_empty()
344        );
345    }
346
347    #[test]
348    fn a_single_query_and_a_list_both_parse() {
349        assert_eq!(
350            capture("[capture]\nmonitors = \"primary\"\n").unwrap(),
351            vec!["primary".to_string()]
352        );
353        assert_eq!(
354            capture("[capture]\nmonitors = [\"DELL\", \"Built-in\"]\n").unwrap(),
355            vec!["DELL".to_string(), "Built-in".to_string()]
356        );
357    }
358
359    #[test]
360    fn empty_and_contradictory_values_are_errors_not_silent_defaults() {
361        // The bug class this module exists to avoid: a value that means
362        // nothing quietly becoming "freeze everything".
363        assert!(capture("[capture]\nmonitors = \"\"\n").is_err());
364        assert!(capture("[capture]\nmonitors = \"   \"\n").is_err());
365        assert!(capture("[capture]\nmonitors = []\n").is_err());
366        assert!(capture("[capture]\nmonitors = [\"DELL\", \"\"]\n").is_err());
367        // "all" is only the default on its own; alongside a name it is a
368        // contradiction rather than a display.
369        assert!(capture("[capture]\nmonitors = [\"all\", \"DELL\"]\n").is_err());
370    }
371
372    #[test]
373    fn an_unknown_capture_key_is_refused_like_every_other_table() {
374        assert!(::toml::from_str::<Config>("[capture]\nmonitor = \"primary\"\n").is_err());
375    }
376
377    #[test]
378    fn default_config_resolves() {
379        let cfg = Config::default();
380        let style = cfg.resolve_style().unwrap();
381        assert_eq!(style.thickness, 2);
382        assert!(!style.fill);
383        assert_eq!(
384            style.label,
385            Color {
386                r: 255,
387                g: 255,
388                b: 255
389            }
390        );
391    }
392
393    #[test]
394    fn thickness_out_of_range_errors() {
395        let mut cfg = Config::default();
396        cfg.style.thickness = 513;
397        assert_eq!(
398            cfg.resolve_style().unwrap_err(),
399            ConfigError::Thickness(513)
400        );
401    }
402
403    #[test]
404    fn toml_round_trip_and_hotkey_merge() {
405        let toml_src = r##"
406            [style]
407            preview_color = "#F00"
408            thickness = 4
409
410            [[hotkeys]]
411            key = "x"
412            action = "save"
413            when = "has_selection"
414        "##;
415        let cfg: Config = toml::from_str(toml_src).unwrap();
416        let style = cfg.resolve_style().unwrap();
417        assert_eq!(style.preview, Color { r: 255, g: 0, b: 0 });
418        assert_eq!(style.thickness, 4);
419        // Unspecified fields keep defaults.
420        assert!(!style.fill);
421
422        let bindings = cfg.resolve_bindings(&[]).unwrap();
423        let state = OverlayState {
424            has_selection: true,
425            cursor_in_shape: false,
426        };
427        assert_eq!(
428            match_event(&bindings, KeyName::Character('X'), Edge::Press, state),
429            Some(Action::Save)
430        );
431    }
432
433    #[test]
434    fn unknown_toml_field_is_rejected() {
435        let err = toml::from_str::<Config>("[style]\npreview_colour = \"#F00\"\n");
436        assert!(err.is_err());
437    }
438
439    #[test]
440    fn rebinding_a_key_removes_all_its_default_edges() {
441        // 'Q' has press AND repeat defaults for rotate_ccw; rebinding it
442        // must silence both, not leave the repeat default alive.
443        let cfg = Config::default();
444        let bindings = cfg.resolve_bindings(&["q=next_tool".to_string()]).unwrap();
445        let state = OverlayState {
446            cursor_in_shape: true,
447            ..OverlayState::default()
448        };
449        assert_eq!(
450            match_event(&bindings, KeyName::Character('Q'), Edge::Press, state),
451            Some(Action::NextTool)
452        );
453        assert_eq!(
454            match_event(&bindings, KeyName::Character('Q'), Edge::Repeat, state),
455            None,
456            "repeat-edge default must be gone"
457        );
458        // Untouched keys keep their defaults.
459        assert_eq!(
460            match_event(&bindings, KeyName::Character('E'), Edge::Repeat, state),
461            Some(Action::RotateCw)
462        );
463    }
464
465    #[test]
466    fn cli_bind_shadows_defaults() {
467        let cfg = Config::default();
468        let bindings = cfg.resolve_bindings(&["q=undo".to_string()]).unwrap();
469        assert_eq!(
470            match_event(
471                &bindings,
472                KeyName::Character('Q'),
473                Edge::Press,
474                OverlayState::default()
475            ),
476            Some(Action::Undo)
477        );
478    }
479
480    #[test]
481    fn bad_hotkey_entry_is_an_error() {
482        let mut cfg = Config::default();
483        cfg.hotkeys.push(HotkeyEntry {
484            key: "z".into(),
485            action: "teleport".into(),
486            edge: None,
487            when: None,
488        });
489        assert!(matches!(
490            cfg.resolve_bindings(&[]),
491            Err(ConfigError::Hotkey(_))
492        ));
493    }
494
495    #[test]
496    fn snap_defaults_are_on_with_a_small_radius() {
497        let settings = Config::default().resolve_snap().unwrap();
498        assert!(settings.enabled, "snapping is on unless turned off");
499        assert_eq!(settings.radius, 8);
500    }
501
502    #[test]
503    fn a_snap_radius_outside_the_range_is_an_error_not_a_default() {
504        for radius in [0, 65, 10_000] {
505            let mut config = Config::default();
506            config.snap.radius = radius;
507            assert_eq!(
508                config.resolve_snap(),
509                Err(ConfigError::SnapRadius(radius)),
510                "radius {radius}"
511            );
512        }
513    }
514
515    #[test]
516    fn the_snap_table_parses_and_rejects_unknown_keys() {
517        let config: Config = toml::from_str("[snap]\nenabled = false\nradius = 16\n").unwrap();
518        let settings = config.resolve_snap().unwrap();
519        assert!(!settings.enabled);
520        assert_eq!(settings.radius, 16);
521        assert!(toml::from_str::<Config>("[snap]\nradius_px = 4\n").is_err());
522    }
523
524    #[test]
525    fn an_absent_snap_table_is_the_default_not_an_error() {
526        let config: Config = toml::from_str("[style]\nthickness = 3\n").unwrap();
527        assert_eq!(config.snap, SnapConfig::default());
528    }
529}