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}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(default, deny_unknown_fields)]
26pub struct Config {
27    pub style: StyleConfig,
28    pub hotkeys: Vec<HotkeyEntry>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(default, deny_unknown_fields)]
33pub struct StyleConfig {
34    /// Outline color while dragging out a shape.
35    pub preview_color: String,
36    /// Outline color of committed shapes.
37    pub complete_color: String,
38    /// Label and HUD text color.
39    pub label_color: String,
40    /// Border color drawn around the `--target` window.
41    pub target_color: String,
42    /// Outline thickness in pixels; 0 hides outlines.
43    pub thickness: u32,
44    /// Fill shapes instead of outlining them.
45    pub fill: bool,
46}
47
48impl Default for StyleConfig {
49    fn default() -> Self {
50        Self {
51            preview_color: "#00A0FF".into(),
52            complete_color: "#00FF66".into(),
53            label_color: "#FFFFFF".into(),
54            target_color: "#FFB000".into(),
55            thickness: 2,
56            fill: false,
57        }
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct HotkeyEntry {
64    pub key: String,
65    pub action: String,
66    pub edge: Option<String>,
67    pub when: Option<String>,
68}
69
70/// Validated, ready-to-use style values.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct Style {
73    pub preview: Color,
74    pub complete: Color,
75    pub label: Color,
76    pub target: Color,
77    pub thickness: i32,
78    pub fill: bool,
79}
80
81impl Config {
82    pub fn resolve_style(&self) -> Result<Style, ConfigError> {
83        let s = &self.style;
84        if s.thickness > 512 {
85            return Err(ConfigError::Thickness(s.thickness));
86        }
87        Ok(Style {
88            preview: parse_hex_color(&s.preview_color)?,
89            complete: parse_hex_color(&s.complete_color)?,
90            label: parse_hex_color(&s.label_color)?,
91            target: parse_hex_color(&s.target_color)?,
92            thickness: s.thickness as i32,
93            fill: s.fill,
94        })
95    }
96
97    /// Defaults, then config-file entries, then `extra` (CLI `--bind`).
98    /// Binding any key removes ALL default bindings for that key (every
99    /// edge), so rebinding `[` doesn't leave the default's repeat-edge
100    /// rotation alive; among user bindings, later entries shadow earlier
101    /// ones per key + edge.
102    pub fn resolve_bindings(&self, extra: &[String]) -> Result<Vec<Binding>, ConfigError> {
103        let mut user: Vec<Binding> = Vec::new();
104        for entry in &self.hotkeys {
105            let mut spec = format!("{}={}", entry.key, entry.action);
106            for part in [&entry.edge, &entry.when].into_iter().flatten() {
107                spec.push(',');
108                spec.push_str(part);
109            }
110            user.push(Binding::parse(&spec)?);
111        }
112        for spec in extra {
113            user.push(Binding::parse(spec)?);
114        }
115        let user_keys: std::collections::HashSet<_> = user.iter().map(|b| b.key).collect();
116        let mut bindings: Vec<Binding> = default_bindings()
117            .into_iter()
118            .filter(|b| !user_keys.contains(&b.key))
119            .collect();
120        bindings.extend(user);
121        Ok(bindings)
122    }
123}
124
125/// Parse `RGB`/`RRGGBB` with optional `#`, matching the predecessor's rules.
126pub fn parse_hex_color(input: &str) -> Result<Color, ConfigError> {
127    let s = input
128        .trim()
129        .strip_prefix('#')
130        .unwrap_or_else(|| input.trim());
131    let expanded: String = match s.len() {
132        3 => s.chars().flat_map(|c| [c, c]).collect(),
133        6 => s.to_string(),
134        _ => return Err(ConfigError::Color(input.to_string())),
135    };
136    if !expanded.chars().all(|c| c.is_ascii_hexdigit()) {
137        return Err(ConfigError::Color(input.to_string()));
138    }
139    let channel = |range| u8::from_str_radix(&expanded[range], 16).unwrap_or_default();
140    Ok(Color {
141        r: channel(0..2),
142        g: channel(2..4),
143        b: channel(4..6),
144    })
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::hotkeys::{Action, Edge, KeyName, OverlayState, match_event};
151
152    #[test]
153    fn hex_six_digit_with_hash() {
154        assert_eq!(
155            parse_hex_color("#FF8000").unwrap(),
156            Color {
157                r: 255,
158                g: 128,
159                b: 0
160            }
161        );
162    }
163
164    #[test]
165    fn hex_without_hash_and_lowercase() {
166        assert_eq!(
167            parse_hex_color("00a0ff").unwrap(),
168            Color {
169                r: 0,
170                g: 160,
171                b: 255
172            }
173        );
174    }
175
176    #[test]
177    fn hex_three_digit_expands() {
178        assert_eq!(
179            parse_hex_color("#F80").unwrap(),
180            Color {
181                r: 255,
182                g: 136,
183                b: 0
184            }
185        );
186    }
187
188    #[test]
189    fn hex_rejects_bad_input() {
190        for bad in ["", "#", "12345", "1234567", "GGGGGG", "#12 456"] {
191            assert!(parse_hex_color(bad).is_err(), "{bad:?} should be rejected");
192        }
193    }
194
195    #[test]
196    fn default_config_resolves() {
197        let cfg = Config::default();
198        let style = cfg.resolve_style().unwrap();
199        assert_eq!(style.thickness, 2);
200        assert!(!style.fill);
201        assert_eq!(
202            style.label,
203            Color {
204                r: 255,
205                g: 255,
206                b: 255
207            }
208        );
209    }
210
211    #[test]
212    fn thickness_out_of_range_errors() {
213        let mut cfg = Config::default();
214        cfg.style.thickness = 513;
215        assert_eq!(
216            cfg.resolve_style().unwrap_err(),
217            ConfigError::Thickness(513)
218        );
219    }
220
221    #[test]
222    fn toml_round_trip_and_hotkey_merge() {
223        let toml_src = r##"
224            [style]
225            preview_color = "#F00"
226            thickness = 4
227
228            [[hotkeys]]
229            key = "x"
230            action = "save"
231            when = "has_selection"
232        "##;
233        let cfg: Config = toml::from_str(toml_src).unwrap();
234        let style = cfg.resolve_style().unwrap();
235        assert_eq!(style.preview, Color { r: 255, g: 0, b: 0 });
236        assert_eq!(style.thickness, 4);
237        // Unspecified fields keep defaults.
238        assert!(!style.fill);
239
240        let bindings = cfg.resolve_bindings(&[]).unwrap();
241        let state = OverlayState {
242            has_selection: true,
243            cursor_in_shape: false,
244        };
245        assert_eq!(
246            match_event(&bindings, KeyName::Character('X'), Edge::Press, state),
247            Some(Action::Save)
248        );
249    }
250
251    #[test]
252    fn unknown_toml_field_is_rejected() {
253        let err = toml::from_str::<Config>("[style]\npreview_colour = \"#F00\"\n");
254        assert!(err.is_err());
255    }
256
257    #[test]
258    fn rebinding_a_key_removes_all_its_default_edges() {
259        // 'Q' has press AND repeat defaults for rotate_ccw; rebinding it
260        // must silence both, not leave the repeat default alive.
261        let cfg = Config::default();
262        let bindings = cfg.resolve_bindings(&["q=next_tool".to_string()]).unwrap();
263        let state = OverlayState {
264            cursor_in_shape: true,
265            ..OverlayState::default()
266        };
267        assert_eq!(
268            match_event(&bindings, KeyName::Character('Q'), Edge::Press, state),
269            Some(Action::NextTool)
270        );
271        assert_eq!(
272            match_event(&bindings, KeyName::Character('Q'), Edge::Repeat, state),
273            None,
274            "repeat-edge default must be gone"
275        );
276        // Untouched keys keep their defaults.
277        assert_eq!(
278            match_event(&bindings, KeyName::Character('E'), Edge::Repeat, state),
279            Some(Action::RotateCw)
280        );
281    }
282
283    #[test]
284    fn cli_bind_shadows_defaults() {
285        let cfg = Config::default();
286        let bindings = cfg.resolve_bindings(&["q=undo".to_string()]).unwrap();
287        assert_eq!(
288            match_event(
289                &bindings,
290                KeyName::Character('Q'),
291                Edge::Press,
292                OverlayState::default()
293            ),
294            Some(Action::Undo)
295        );
296    }
297
298    #[test]
299    fn bad_hotkey_entry_is_an_error() {
300        let mut cfg = Config::default();
301        cfg.hotkeys.push(HotkeyEntry {
302            key: "z".into(),
303            action: "teleport".into(),
304            edge: None,
305            when: None,
306        });
307        assert!(matches!(
308            cfg.resolve_bindings(&[]),
309            Err(ConfigError::Hotkey(_))
310        ));
311    }
312}