pixelcoords-core 0.1.0

Platform-free core logic for pixelcoords: geometry, selections, session schema, point verdicts, code emitters, template relocation, hotkeys, config, rasterizer
Documentation
//! Configuration types (serde) and their resolution into validated values.
//!
//! The structs here are plain data the binary deserializes from TOML; this
//! crate stays parser-agnostic. Resolution is strict: bad colors, silly
//! thicknesses, and unknown hotkey pieces are errors, never silently
//! defaulted (the predecessor's silent numeric fallbacks were a bug class).

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 {
    /// Outline color while dragging out a shape.
    pub preview_color: String,
    /// Outline color of committed shapes.
    pub complete_color: String,
    /// Label and HUD text color.
    pub label_color: String,
    /// Border color drawn around the `--target` window.
    pub target_color: String,
    /// Outline thickness in pixels; 0 hides outlines.
    pub thickness: u32,
    /// Fill shapes instead of outlining them.
    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>,
}

/// Validated, ready-to-use style values.
#[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,
        })
    }

    /// Defaults, then config-file entries, then `extra` (CLI `--bind`).
    /// Binding any key removes ALL default bindings for that key (every
    /// edge), so rebinding `[` doesn't leave the default's repeat-edge
    /// rotation alive; among user bindings, later entries shadow earlier
    /// ones per key + edge.
    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)
    }
}

/// Parse `RGB`/`RRGGBB` with optional `#`, matching the predecessor's rules.
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);
        // Unspecified fields keep defaults.
        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() {
        // 'Q' has press AND repeat defaults for rotate_ccw; rebinding it
        // must silence both, not leave the repeat default alive.
        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"
        );
        // Untouched keys keep their defaults.
        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(_))
        ));
    }
}