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