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("[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(default, deny_unknown_fields)]
43pub struct SnapConfig {
44 pub enabled: bool,
50 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 pub monitors: Option<MonitorsSetting>,
72}
73
74#[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 pub preview_color: String,
88 pub complete_color: String,
90 pub label_color: String,
92 pub target_color: String,
94 pub thickness: u32,
96 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct SnapSettings {
125 pub enabled: bool,
126 pub radius: i32,
128}
129
130impl Default for SnapSettings {
131 fn default() -> Self {
134 Self {
135 enabled: true,
136 radius: 8,
137 }
138 }
139}
140
141#[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 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 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 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 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
252pub 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 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 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 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 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 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}