use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::draw::Color;
use crate::hotkeys::{Binding, HotkeyError, default_bindings};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ConfigError {
#[error("invalid color '{0}': expected hex RGB, 3 or 6 digits, optional '#'")]
Color(String),
#[error("thickness {0} is out of range (0-512)")]
Thickness(u32),
#[error(transparent)]
Hotkey(#[from] HotkeyError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub style: StyleConfig,
pub hotkeys: Vec<HotkeyEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StyleConfig {
pub preview_color: String,
pub complete_color: String,
pub label_color: String,
pub target_color: String,
pub thickness: u32,
pub fill: bool,
}
impl Default for StyleConfig {
fn default() -> Self {
Self {
preview_color: "#00A0FF".into(),
complete_color: "#00FF66".into(),
label_color: "#FFFFFF".into(),
target_color: "#FFB000".into(),
thickness: 2,
fill: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HotkeyEntry {
pub key: String,
pub action: String,
pub edge: Option<String>,
pub when: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Style {
pub preview: Color,
pub complete: Color,
pub label: Color,
pub target: Color,
pub thickness: i32,
pub fill: bool,
}
impl Config {
pub fn resolve_style(&self) -> Result<Style, ConfigError> {
let s = &self.style;
if s.thickness > 512 {
return Err(ConfigError::Thickness(s.thickness));
}
Ok(Style {
preview: parse_hex_color(&s.preview_color)?,
complete: parse_hex_color(&s.complete_color)?,
label: parse_hex_color(&s.label_color)?,
target: parse_hex_color(&s.target_color)?,
thickness: s.thickness as i32,
fill: s.fill,
})
}
pub fn resolve_bindings(&self, extra: &[String]) -> Result<Vec<Binding>, ConfigError> {
let mut user: Vec<Binding> = Vec::new();
for entry in &self.hotkeys {
let mut spec = format!("{}={}", entry.key, entry.action);
for part in [&entry.edge, &entry.when].into_iter().flatten() {
spec.push(',');
spec.push_str(part);
}
user.push(Binding::parse(&spec)?);
}
for spec in extra {
user.push(Binding::parse(spec)?);
}
let user_keys: std::collections::HashSet<_> = user.iter().map(|b| b.key).collect();
let mut bindings: Vec<Binding> = default_bindings()
.into_iter()
.filter(|b| !user_keys.contains(&b.key))
.collect();
bindings.extend(user);
Ok(bindings)
}
}
pub fn parse_hex_color(input: &str) -> Result<Color, ConfigError> {
let s = input
.trim()
.strip_prefix('#')
.unwrap_or_else(|| input.trim());
let expanded: String = match s.len() {
3 => s.chars().flat_map(|c| [c, c]).collect(),
6 => s.to_string(),
_ => return Err(ConfigError::Color(input.to_string())),
};
if !expanded.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ConfigError::Color(input.to_string()));
}
let channel = |range| u8::from_str_radix(&expanded[range], 16).unwrap_or_default();
Ok(Color {
r: channel(0..2),
g: channel(2..4),
b: channel(4..6),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hotkeys::{Action, Edge, KeyName, OverlayState, match_event};
#[test]
fn hex_six_digit_with_hash() {
assert_eq!(
parse_hex_color("#FF8000").unwrap(),
Color {
r: 255,
g: 128,
b: 0
}
);
}
#[test]
fn hex_without_hash_and_lowercase() {
assert_eq!(
parse_hex_color("00a0ff").unwrap(),
Color {
r: 0,
g: 160,
b: 255
}
);
}
#[test]
fn hex_three_digit_expands() {
assert_eq!(
parse_hex_color("#F80").unwrap(),
Color {
r: 255,
g: 136,
b: 0
}
);
}
#[test]
fn hex_rejects_bad_input() {
for bad in ["", "#", "12345", "1234567", "GGGGGG", "#12 456"] {
assert!(parse_hex_color(bad).is_err(), "{bad:?} should be rejected");
}
}
#[test]
fn default_config_resolves() {
let cfg = Config::default();
let style = cfg.resolve_style().unwrap();
assert_eq!(style.thickness, 2);
assert!(!style.fill);
assert_eq!(
style.label,
Color {
r: 255,
g: 255,
b: 255
}
);
}
#[test]
fn thickness_out_of_range_errors() {
let mut cfg = Config::default();
cfg.style.thickness = 513;
assert_eq!(
cfg.resolve_style().unwrap_err(),
ConfigError::Thickness(513)
);
}
#[test]
fn toml_round_trip_and_hotkey_merge() {
let toml_src = r##"
[style]
preview_color = "#F00"
thickness = 4
[[hotkeys]]
key = "x"
action = "save"
when = "has_selection"
"##;
let cfg: Config = toml::from_str(toml_src).unwrap();
let style = cfg.resolve_style().unwrap();
assert_eq!(style.preview, Color { r: 255, g: 0, b: 0 });
assert_eq!(style.thickness, 4);
assert!(!style.fill);
let bindings = cfg.resolve_bindings(&[]).unwrap();
let state = OverlayState {
has_selection: true,
cursor_in_shape: false,
};
assert_eq!(
match_event(&bindings, KeyName::Character('X'), Edge::Press, state),
Some(Action::Save)
);
}
#[test]
fn unknown_toml_field_is_rejected() {
let err = toml::from_str::<Config>("[style]\npreview_colour = \"#F00\"\n");
assert!(err.is_err());
}
#[test]
fn rebinding_a_key_removes_all_its_default_edges() {
let cfg = Config::default();
let bindings = cfg.resolve_bindings(&["q=next_tool".to_string()]).unwrap();
let state = OverlayState {
cursor_in_shape: true,
..OverlayState::default()
};
assert_eq!(
match_event(&bindings, KeyName::Character('Q'), Edge::Press, state),
Some(Action::NextTool)
);
assert_eq!(
match_event(&bindings, KeyName::Character('Q'), Edge::Repeat, state),
None,
"repeat-edge default must be gone"
);
assert_eq!(
match_event(&bindings, KeyName::Character('E'), Edge::Repeat, state),
Some(Action::RotateCw)
);
}
#[test]
fn cli_bind_shadows_defaults() {
let cfg = Config::default();
let bindings = cfg.resolve_bindings(&["q=undo".to_string()]).unwrap();
assert_eq!(
match_event(
&bindings,
KeyName::Character('Q'),
Edge::Press,
OverlayState::default()
),
Some(Action::Undo)
);
}
#[test]
fn bad_hotkey_entry_is_an_error() {
let mut cfg = Config::default();
cfg.hotkeys.push(HotkeyEntry {
key: "z".into(),
action: "teleport".into(),
edge: None,
when: None,
});
assert!(matches!(
cfg.resolve_bindings(&[]),
Err(ConfigError::Hotkey(_))
));
}
}