1use 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 pub preview_color: String,
36 pub complete_color: String,
38 pub label_color: String,
40 pub target_color: String,
42 pub thickness: u32,
44 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#[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 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
125pub 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 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 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 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}