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 #[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 pub monitors: Option<MonitorsSetting>,
45}
46
47#[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 pub preview_color: String,
61 pub complete_color: String,
63 pub label_color: String,
65 pub target_color: String,
67 pub thickness: u32,
69 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#[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 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 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 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
190pub 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 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 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 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 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 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}