Skip to main content

opys_engine/
palette.rs

1//! Configurable presentation rules for the TUI (`[palette]` in opys.toml).
2//!
3//! Each named entry has a list of `matchers` (`{status?, type?}`) and a `style`.
4//! A document matches an entry when any matcher matches (a matcher matches when
5//! every field it constrains equals the document's). For a document, the styles
6//! of all matching entries are merged field-wise in ascending **specificity**
7//! (number of constrained fields in the matched matcher; ties broken by entry
8//! name), so more-specific rules override less-specific ones — a `bug` that is
9//! `blocked` can take the bug icon *and* the blocked color.
10//!
11//! This module is part of the core (always compiled): the engine parses and
12//! validates the palette even without the `tui` feature. The TUI maps the
13//! resolved [`Style`] onto ratatui colors/modifiers; see `tui::theme`.
14
15use std::collections::BTreeMap;
16
17use serde::Deserialize;
18
19/// One palette rule: when any matcher matches, its style contributes.
20#[derive(Debug, Clone, Deserialize)]
21pub struct PaletteEntry {
22    #[serde(default)]
23    pub matchers: Vec<Matcher>,
24    #[serde(default)]
25    pub style: Style,
26}
27
28/// A match condition. All fields optional; an empty matcher matches everything.
29#[derive(Debug, Clone, Default, Deserialize)]
30pub struct Matcher {
31    #[serde(default)]
32    pub status: Option<String>,
33    #[serde(default, rename = "type")]
34    pub doc_type: Option<String>,
35    /// Matches documents carrying this tag (exact tag or tag key).
36    #[serde(default)]
37    pub tag: Option<String>,
38}
39
40/// The presentational attributes a rule can set. All optional so styles compose.
41#[derive(Debug, Clone, Default, Deserialize)]
42pub struct Style {
43    #[serde(default)]
44    pub fg_color: Option<String>,
45    #[serde(default)]
46    pub bg_color: Option<String>,
47    #[serde(default)]
48    pub icon: Option<String>,
49    #[serde(default)]
50    pub bold: Option<bool>,
51    #[serde(default)]
52    pub italic: Option<bool>,
53    #[serde(default)]
54    pub strikethrough: Option<bool>,
55}
56
57impl Style {
58    /// Overlay `other` onto `self`: every field `other` sets wins.
59    fn overlay(&mut self, other: &Style) {
60        if other.fg_color.is_some() {
61            self.fg_color = other.fg_color.clone();
62        }
63        if other.bg_color.is_some() {
64            self.bg_color = other.bg_color.clone();
65        }
66        if other.icon.is_some() {
67            self.icon = other.icon.clone();
68        }
69        if other.bold.is_some() {
70            self.bold = other.bold;
71        }
72        if other.italic.is_some() {
73            self.italic = other.italic;
74        }
75        if other.strikethrough.is_some() {
76            self.strikethrough = other.strikethrough;
77        }
78    }
79}
80
81impl Matcher {
82    /// Whether this matcher matches the given document, and if so its
83    /// specificity (the count of constrained fields). `None` when it does not.
84    /// `has_tag` answers the tag guard (exact tag or tag key — see
85    /// [`Frontmatter::has_tag`](crate::frontmatter::Frontmatter::has_tag)).
86    fn specificity(
87        &self,
88        doc_type: Option<&str>,
89        status: Option<&str>,
90        has_tag: &dyn Fn(&str) -> bool,
91    ) -> Option<usize> {
92        let mut spec = 0;
93        if let Some(want) = &self.doc_type {
94            if Some(want.as_str()) != doc_type {
95                return None;
96            }
97            spec += 1;
98        }
99        if let Some(want) = &self.status {
100            if Some(want.as_str()) != status {
101                return None;
102            }
103            spec += 1;
104        }
105        if let Some(want) = &self.tag {
106            if !has_tag(want) {
107                return None;
108            }
109            spec += 1;
110        }
111        Some(spec)
112    }
113}
114
115impl PaletteEntry {
116    /// The entry's specificity for a document: the highest specificity among its
117    /// matching matchers, or `None` if none match.
118    fn specificity(
119        &self,
120        doc_type: Option<&str>,
121        status: Option<&str>,
122        has_tag: &dyn Fn(&str) -> bool,
123    ) -> Option<usize> {
124        self.matchers
125            .iter()
126            .filter_map(|m| m.specificity(doc_type, status, has_tag))
127            .max()
128    }
129}
130
131/// Resolve the merged [`Style`] for a document from the whole palette. `has_tag`
132/// answers tag matchers (exact tag or tag key).
133pub fn resolve(
134    palette: &BTreeMap<String, PaletteEntry>,
135    doc_type: Option<&str>,
136    status: Option<&str>,
137    has_tag: &dyn Fn(&str) -> bool,
138) -> Style {
139    // (specificity, name, style) for every matching entry.
140    let mut matched: Vec<(usize, &str, &Style)> = palette
141        .iter()
142        .filter_map(|(name, entry)| {
143            entry
144                .specificity(doc_type, status, has_tag)
145                .map(|spec| (spec, name.as_str(), &entry.style))
146        })
147        .collect();
148    // Least specific first; ties by name. Later (more specific) entries override.
149    matched.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
150
151    let mut out = Style::default();
152    for (_, _, style) in matched {
153        out.overlay(style);
154    }
155    out
156}
157
158/// A parsed color spec. The TUI maps this onto a concrete ratatui color.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ColorSpec {
161    Rgb(u8, u8, u8),
162    Indexed(u8),
163    Named(NamedColor),
164}
165
166/// The ANSI-ish named colors a palette may reference (the ratatui base set).
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum NamedColor {
169    Black,
170    Red,
171    Green,
172    Yellow,
173    Blue,
174    Magenta,
175    Cyan,
176    Gray,
177    DarkGray,
178    LightRed,
179    LightGreen,
180    LightYellow,
181    LightBlue,
182    LightMagenta,
183    LightCyan,
184    White,
185}
186
187/// Parse a color string: a name (case-insensitive), `#rgb`/`#rrggbb` hex, or a
188/// `0`-`255` palette index. Returns `None` if it is not a valid color.
189pub fn parse_color(s: &str) -> Option<ColorSpec> {
190    let s = s.trim();
191    if let Some(hex) = s.strip_prefix('#') {
192        return parse_hex(hex);
193    }
194    if let Ok(idx) = s.parse::<u8>() {
195        return Some(ColorSpec::Indexed(idx));
196    }
197    let named = match s.to_ascii_lowercase().as_str() {
198        "black" => NamedColor::Black,
199        "red" => NamedColor::Red,
200        "green" => NamedColor::Green,
201        "yellow" => NamedColor::Yellow,
202        "blue" => NamedColor::Blue,
203        "magenta" => NamedColor::Magenta,
204        "cyan" => NamedColor::Cyan,
205        "gray" | "grey" => NamedColor::Gray,
206        "darkgray" | "darkgrey" => NamedColor::DarkGray,
207        "lightred" => NamedColor::LightRed,
208        "lightgreen" => NamedColor::LightGreen,
209        "lightyellow" => NamedColor::LightYellow,
210        "lightblue" => NamedColor::LightBlue,
211        "lightmagenta" => NamedColor::LightMagenta,
212        "lightcyan" => NamedColor::LightCyan,
213        "white" => NamedColor::White,
214        _ => return None,
215    };
216    Some(ColorSpec::Named(named))
217}
218
219fn parse_hex(hex: &str) -> Option<ColorSpec> {
220    let bytes = match hex.len() {
221        3 => {
222            let mut full = String::with_capacity(6);
223            for c in hex.chars() {
224                full.push(c);
225                full.push(c);
226            }
227            return parse_hex(&full);
228        }
229        6 => hex,
230        _ => return None,
231    };
232    let r = u8::from_str_radix(&bytes[0..2], 16).ok()?;
233    let g = u8::from_str_radix(&bytes[2..4], 16).ok()?;
234    let b = u8::from_str_radix(&bytes[4..6], 16).ok()?;
235    Some(ColorSpec::Rgb(r, g, b))
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn palette(toml: &str) -> BTreeMap<String, PaletteEntry> {
243        #[derive(Deserialize)]
244        struct Wrap {
245            #[serde(default)]
246            palette: BTreeMap<String, PaletteEntry>,
247        }
248        toml::from_str::<Wrap>(toml).unwrap().palette
249    }
250
251    #[test]
252    fn parses_colors() {
253        assert_eq!(parse_color("red"), Some(ColorSpec::Named(NamedColor::Red)));
254        assert_eq!(
255            parse_color("GREY"),
256            Some(ColorSpec::Named(NamedColor::Gray))
257        );
258        assert_eq!(parse_color("#fff"), Some(ColorSpec::Rgb(255, 255, 255)));
259        assert_eq!(parse_color("#112233"), Some(ColorSpec::Rgb(17, 34, 51)));
260        assert_eq!(parse_color("200"), Some(ColorSpec::Indexed(200)));
261        assert_eq!(parse_color("notacolor"), None);
262        assert_eq!(parse_color("#12"), None);
263        assert_eq!(parse_color("300"), None); // out of u8 range
264    }
265
266    #[test]
267    fn specificity_merge_prefers_more_specific() {
268        let p = palette(
269            "[palette.bug]\nmatchers = [ { type = \"bug\" } ]\n[palette.bug.style]\nicon = \"B\"\nfg_color = \"blue\"\n\
270[palette.blocked]\nmatchers = [ { status = \"blocked\" } ]\n[palette.blocked.style]\nfg_color = \"red\"\nbold = true\n\
271[palette.bug-blocked]\nmatchers = [ { type = \"bug\", status = \"blocked\" } ]\n[palette.bug-blocked.style]\nfg_color = \"magenta\"\n",
272        );
273        // A blocked bug: icon from `bug` (B), bold from `blocked`, and fg from the
274        // most specific (type+status) rule — magenta.
275        let style = resolve(&p, Some("bug"), Some("blocked"), &no_tags);
276        assert_eq!(style.icon.as_deref(), Some("B"));
277        assert_eq!(style.bold, Some(true));
278        assert_eq!(style.fg_color.as_deref(), Some("magenta"));
279    }
280
281    #[test]
282    fn empty_matcher_matches_everything() {
283        let p = palette(
284            "[palette.base]\nmatchers = [ {} ]\n[palette.base.style]\nfg_color = \"gray\"\n",
285        );
286        let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
287        assert_eq!(style.fg_color.as_deref(), Some("gray"));
288    }
289
290    #[test]
291    fn non_matching_doc_gets_empty_style() {
292        let p = palette(
293            "[palette.bug]\nmatchers = [ { type = \"bug\" } ]\n[palette.bug.style]\nicon = \"B\"\n",
294        );
295        let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
296        assert!(style.icon.is_none());
297    }
298
299    #[test]
300    fn tag_matcher_colors_by_tag_key() {
301        let p = palette(
302            "[palette.area]\nmatchers = [ { tag = \"area\" } ]\n[palette.area.style]\nfg_color = \"cyan\"\n",
303        );
304        // A doc with `area:parsing` matches the `area` tag matcher by key.
305        let has = |t: &str| t == "area";
306        let style = resolve(&p, Some("feature"), Some("planned"), &has);
307        assert_eq!(style.fg_color.as_deref(), Some("cyan"));
308        // A doc without the tag is unstyled.
309        let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
310        assert!(style.fg_color.is_none());
311    }
312
313    fn no_tags(_: &str) -> bool {
314        false
315    }
316}