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