Skip to main content

tca_types/
lib.rs

1//! Core types for Terminal Colors Architecture (TCA)
2//!
3//! This crate provides the foundational type definitions used across
4//! the TCA ecosystem for theme representation and manipulation.
5
6#![warn(missing_docs)]
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10mod builtin;
11pub use builtin::BuiltinTheme;
12
13/// Errors that can occur when parsing a hex color string.
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum HexColorError {
16    /// The hex string (excluding a leading `#`) was not exactly 6 characters.
17    #[error("hex color must be 6 characters (got {0})")]
18    InvalidLength(usize),
19    /// A hex digit could not be parsed.
20    #[error("invalid hex digit: {0}")]
21    InvalidHex(#[from] std::num::ParseIntError),
22}
23
24/// A complete TCA theme definition.
25#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
26pub struct Theme {
27    /// Theme metadata. Serde key is `theme` to match the TOML section name.
28    #[serde(rename = "theme")]
29    pub meta: Meta,
30    /// ANSI 16-color definitions.
31    pub ansi: Ansi,
32    /// Optional named color palette.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub palette: Option<Palette>,
35    /// Optional Base16 color scheme.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub base16: Option<Base16>,
38    /// Semantic color roles.
39    pub semantic: Semantic,
40    /// UI element colors.
41    pub ui: Ui,
42}
43
44/// Theme metadata (TOML section `[theme]`).
45#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
46pub struct Meta {
47    /// Human-readable theme name.
48    pub name: String,
49    /// URL-safe identifier for the theme.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub slug: Option<String>,
52    /// Theme author name or contact.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub author: Option<String>,
55    /// Semantic version string (e.g. `"1.0.0"`).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub version: Option<String>,
58    /// Short description of the theme.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub description: Option<String>,
61    /// `true` for dark themes, `false` for light themes.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub dark: Option<bool>,
64}
65
66/// ANSI 16-color definitions (TOML section `[ansi]`).
67///
68/// All values must be direct `#RRGGBB` hex strings — no palette references.
69#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
70pub struct Ansi {
71    /// ANSI color 0 — black.
72    pub black: String,
73    /// ANSI color 1 — red.
74    pub red: String,
75    /// ANSI color 2 — green.
76    pub green: String,
77    /// ANSI color 3 — yellow.
78    pub yellow: String,
79    /// ANSI color 4 — blue.
80    pub blue: String,
81    /// ANSI color 5 — magenta.
82    pub magenta: String,
83    /// ANSI color 6 — cyan.
84    pub cyan: String,
85    /// ANSI color 7 — white.
86    pub white: String,
87    /// ANSI color 8 — bright black (dark gray).
88    pub bright_black: String,
89    /// ANSI color 9 — bright red.
90    pub bright_red: String,
91    /// ANSI color 10 — bright green.
92    pub bright_green: String,
93    /// ANSI color 11 — bright yellow.
94    pub bright_yellow: String,
95    /// ANSI color 12 — bright blue.
96    pub bright_blue: String,
97    /// ANSI color 13 — bright magenta.
98    pub bright_magenta: String,
99    /// ANSI color 14 — bright cyan.
100    pub bright_cyan: String,
101    /// ANSI color 15 — bright white.
102    pub bright_white: String,
103}
104
105impl Ansi {
106    /// Return the hex color string for the given ANSI key name (e.g. `"red"`, `"bright_black"`).
107    ///
108    /// Returns `None` for unknown key names.
109    pub fn get(&self, key: &str) -> Option<&str> {
110        match key {
111            "black" => Some(&self.black),
112            "red" => Some(&self.red),
113            "green" => Some(&self.green),
114            "yellow" => Some(&self.yellow),
115            "blue" => Some(&self.blue),
116            "magenta" => Some(&self.magenta),
117            "cyan" => Some(&self.cyan),
118            "white" => Some(&self.white),
119            "bright_black" => Some(&self.bright_black),
120            "bright_red" => Some(&self.bright_red),
121            "bright_green" => Some(&self.bright_green),
122            "bright_yellow" => Some(&self.bright_yellow),
123            "bright_blue" => Some(&self.bright_blue),
124            "bright_magenta" => Some(&self.bright_magenta),
125            "bright_cyan" => Some(&self.bright_cyan),
126            "bright_white" => Some(&self.bright_white),
127            _ => None,
128        }
129    }
130}
131
132/// Color palette with named hue ramps (TOML section `[palette]`).
133///
134/// Each ramp is a 0-indexed `Vec<String>` where values are either
135/// `#RRGGBB` hex strings or `ansi.<key>` references.
136/// Ramps should be ordered darkest (index 0) to lightest.
137#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
138pub struct Palette(HashMap<String, Vec<String>>);
139
140impl Palette {
141    /// Create a palette from a map of ramp names to color vectors.
142    pub fn new(map: HashMap<String, Vec<String>>) -> Self {
143        Self(map)
144    }
145
146    /// Return the named ramp, or `None` if it doesn't exist.
147    pub fn get_ramp(&self, name: &str) -> Option<&[String]> {
148        self.0.get(name).map(|v| &**v)
149    }
150
151    /// Return all ramp names in sorted order.
152    pub fn ramp_names(&self) -> Vec<&str> {
153        let mut names: Vec<&str> = self.0.keys().map(String::as_str).collect();
154        names.sort();
155        names
156    }
157
158    /// Iterate over all `(ramp_name, colors)` pairs in arbitrary order.
159    pub fn entries(&self) -> impl Iterator<Item = (&str, &Vec<String>)> {
160        self.0.iter().map(|(k, v)| (k.as_str(), v))
161    }
162}
163
164/// Base16 color definitions (TOML section `[base16]`).
165#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
166pub struct Base16(HashMap<String, String>);
167
168impl Base16 {
169    /// Create a Base16 map from a raw key→value map.
170    pub fn new(map: HashMap<String, String>) -> Self {
171        Self(map)
172    }
173
174    /// Return the raw color reference for the given Base16 key, or `None`.
175    pub fn get(&self, key: &str) -> Option<&str> {
176        self.0.get(key).map(String::as_str)
177    }
178
179    /// Iterate over all `(key, value)` pairs in arbitrary order.
180    pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
181        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
182    }
183}
184
185/// Semantic color roles (TOML section `[semantic]`).
186#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
187pub struct Semantic {
188    /// Color for error states.
189    pub error: String,
190    /// Color for warning states.
191    pub warning: String,
192    /// Color for informational states.
193    pub info: String,
194    /// Color for success states.
195    pub success: String,
196    /// Color for highlighted text.
197    pub highlight: String,
198    /// Color for hyperlinks.
199    pub link: String,
200}
201
202/// Background colors (nested under `[ui.bg]`).
203#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
204pub struct UiBg {
205    /// Primary application background.
206    pub primary: String,
207    /// Secondary / sidebar background.
208    pub secondary: String,
209}
210
211/// Foreground colors (nested under `[ui.fg]`).
212#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
213pub struct UiFg {
214    /// Primary text color.
215    pub primary: String,
216    /// Secondary text color.
217    pub secondary: String,
218    /// De-emphasized / placeholder text color.
219    pub muted: String,
220}
221
222/// Border colors (nested under `[ui.border]`).
223#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
224pub struct UiBorder {
225    /// Active / focused border color.
226    pub primary: String,
227    /// Inactive / de-emphasized border color.
228    pub muted: String,
229}
230
231/// Cursor colors (nested under `[ui.cursor]`).
232#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
233pub struct UiCursor {
234    /// Active cursor color.
235    pub primary: String,
236    /// Inactive cursor color.
237    pub muted: String,
238}
239
240/// Selection colors (nested under `[ui.selection]`).
241#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
242pub struct UiSelection {
243    /// Selection background.
244    pub bg: String,
245    /// Selection foreground.
246    pub fg: String,
247}
248
249/// UI element colors (TOML section `[ui]` with nested sub-tables).
250#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
251pub struct Ui {
252    /// Background colors (`[ui.bg]`).
253    pub bg: UiBg,
254    /// Foreground / text colors (`[ui.fg]`).
255    pub fg: UiFg,
256    /// Border colors (`[ui.border]`).
257    pub border: UiBorder,
258    /// Cursor colors (`[ui.cursor]`).
259    pub cursor: UiCursor,
260    /// Selection colors (`[ui.selection]`).
261    pub selection: UiSelection,
262}
263
264impl Theme {
265    /// Resolve a color reference to its `#RRGGBB` hex value.
266    ///
267    /// Supported reference formats:
268    /// - Direct hex: `#ff0000`
269    /// - ANSI reference: `ansi.red`, `ansi.bright_black`
270    /// - Palette reference: `palette.neutral.0` (0-based index)
271    /// - Base16 reference: `base16.base08` (resolved recursively)
272    pub fn resolve(&self, color_ref: &str) -> Option<String> {
273        if color_ref.starts_with('#') {
274            return Some(color_ref.to_string());
275        }
276
277        // Split into at most 3 parts on the first two dots.
278        let parts: Vec<&str> = color_ref.splitn(3, '.').collect();
279
280        match parts.as_slice() {
281            ["palette", ramp, idx_str] => {
282                let idx: usize = idx_str.parse().ok()?;
283                let value = self.palette.as_ref()?.get_ramp(ramp)?.get(idx)?;
284                // Palette values may themselves be ansi.* refs or hex.
285                self.resolve(value)
286            }
287            ["ansi", key] => Some(self.ansi.get(key)?.to_string()),
288            ["base16", key] => {
289                let value = self.base16.as_ref()?.get(key)?;
290                self.resolve(value)
291            }
292            _ => None,
293        }
294    }
295}
296
297/// Convert a hex color string to RGB components.
298///
299/// Accepts colors in format `#RRGGBB` or `RRGGBB`.
300///
301/// # Examples
302///
303/// ```
304/// use tca_types::hex_to_rgb;
305///
306/// let (r, g, b) = hex_to_rgb("#ff5533").unwrap();
307/// assert_eq!((r, g, b), (255, 85, 51));
308///
309/// ```
310///
311/// # Errors
312///
313/// Returns [`HexColorError::InvalidLength`] if the hex string is not exactly
314/// 6 characters (excluding a leading `#`), or [`HexColorError::InvalidHex`]
315/// if any character is not a valid hex digit.
316pub fn hex_to_rgb(hex: &str) -> Result<(u8, u8, u8), HexColorError> {
317    let hex = hex.trim_start_matches('#');
318
319    if hex.len() != 6 {
320        return Err(HexColorError::InvalidLength(hex.len()));
321    }
322
323    let r = u8::from_str_radix(&hex[0..2], 16)?;
324    let g = u8::from_str_radix(&hex[2..4], 16)?;
325    let b = u8::from_str_radix(&hex[4..6], 16)?;
326    Ok((r, g, b))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn create_test_theme() -> Theme {
334        Theme {
335            meta: Meta {
336                name: "Test Theme".to_string(),
337                slug: Some("test".to_string()),
338                author: None,
339                version: None,
340                description: None,
341                dark: Some(true),
342            },
343            palette: Some(Palette(HashMap::from([
344                (
345                    "neutral".into(),
346                    vec![
347                        "#1a1a1a".to_string(),
348                        "#666666".to_string(),
349                        "#fafafa".to_string(),
350                    ],
351                ),
352                (
353                    "red".into(),
354                    vec!["#cc0000".to_string(), "ansi.bright_red".to_string()],
355                ),
356            ]))),
357            ansi: Ansi {
358                black: "#1a1a1a".to_string(),
359                red: "#cc0000".to_string(),
360                green: "#00ff00".to_string(),
361                yellow: "#ffff00".to_string(),
362                blue: "#0000ff".to_string(),
363                magenta: "#ff00ff".to_string(),
364                cyan: "#00ffff".to_string(),
365                white: "#fafafa".to_string(),
366                bright_black: "#666666".to_string(),
367                bright_red: "#ff5555".to_string(),
368                bright_green: "#00ff00".to_string(),
369                bright_yellow: "#ffff00".to_string(),
370                bright_blue: "#0000ff".to_string(),
371                bright_magenta: "#ff00ff".to_string(),
372                bright_cyan: "#00ffff".to_string(),
373                bright_white: "#ffffff".to_string(),
374            },
375            base16: None,
376            semantic: Semantic {
377                error: "palette.red.0".to_string(),
378                warning: "ansi.yellow".to_string(),
379                info: "ansi.blue".to_string(),
380                success: "ansi.green".to_string(),
381                highlight: "ansi.cyan".to_string(),
382                link: "palette.red.1".to_string(),
383            },
384            ui: Ui {
385                bg: UiBg {
386                    primary: "palette.neutral.0".to_string(),
387                    secondary: "palette.neutral.1".to_string(),
388                },
389                fg: UiFg {
390                    primary: "palette.neutral.2".to_string(),
391                    secondary: "palette.neutral.1".to_string(),
392                    muted: "palette.neutral.1".to_string(),
393                },
394                border: UiBorder {
395                    primary: "ansi.blue".to_string(),
396                    muted: "palette.neutral.1".to_string(),
397                },
398                cursor: UiCursor {
399                    primary: "ansi.white".to_string(),
400                    muted: "palette.neutral.1".to_string(),
401                },
402                selection: UiSelection {
403                    bg: "palette.neutral.1".to_string(),
404                    fg: "palette.neutral.2".to_string(),
405                },
406            },
407        }
408    }
409
410    #[test]
411    fn test_resolve_hex_color() {
412        let theme = create_test_theme();
413        assert_eq!(theme.resolve("#ff0000"), Some("#ff0000".to_string()));
414    }
415
416    #[test]
417    fn test_resolve_palette_reference() {
418        let theme = create_test_theme();
419        // palette.red.0 → first element of red ramp
420        assert_eq!(theme.resolve("palette.red.0"), Some("#cc0000".to_string()));
421        assert_eq!(theme.resolve("palette.red.1"), Some("#ff5555".to_string()));
422        assert_eq!(
423            theme.resolve("palette.neutral.0"),
424            Some("#1a1a1a".to_string())
425        );
426    }
427
428    #[test]
429    fn test_resolve_ansi_reference() {
430        let theme = create_test_theme();
431        assert_eq!(theme.resolve("ansi.red"), Some("#cc0000".to_string()));
432        assert_eq!(
433            theme.resolve("ansi.bright_black"),
434            Some("#666666".to_string())
435        );
436    }
437
438    #[test]
439    fn test_resolve_invalid() {
440        let theme = create_test_theme();
441        assert_eq!(theme.resolve("$nonexistent.3"), None);
442        assert_eq!(theme.resolve("invalid"), None);
443        assert_eq!(theme.resolve("palette.red.99"), None); // out of bounds
444    }
445
446    #[test]
447    fn test_hex_to_rgb_with_hash() {
448        let (r, g, b) = hex_to_rgb("#ff5533").unwrap();
449        assert_eq!((r, g, b), (255, 85, 51));
450    }
451
452    #[test]
453    fn test_hex_to_rgb_without_hash() {
454        let (r, g, b) = hex_to_rgb("ff5533").unwrap();
455        assert_eq!((r, g, b), (255, 85, 51));
456    }
457
458    #[test]
459    fn test_hex_to_rgb_black() {
460        let (r, g, b) = hex_to_rgb("#000000").unwrap();
461        assert_eq!((r, g, b), (0, 0, 0));
462    }
463
464    #[test]
465    fn test_hex_to_rgb_white() {
466        let (r, g, b) = hex_to_rgb("#ffffff").unwrap();
467        assert_eq!((r, g, b), (255, 255, 255));
468    }
469
470    #[test]
471    fn test_hex_to_rgb_too_short() {
472        assert!(hex_to_rgb("#fff").is_err());
473        assert!(hex_to_rgb("abc").is_err());
474    }
475
476    #[test]
477    fn test_hex_to_rgb_too_long() {
478        assert!(hex_to_rgb("#ff5533aa").is_err());
479    }
480
481    #[test]
482    fn test_hex_to_rgb_invalid_chars() {
483        assert!(hex_to_rgb("#gggggg").is_err());
484        assert!(hex_to_rgb("#xyz123").is_err());
485    }
486
487    #[test]
488    fn test_hex_to_rgb_empty() {
489        assert!(hex_to_rgb("").is_err());
490        assert!(hex_to_rgb("#").is_err());
491    }
492}