Skip to main content

osp_cli/ui/style/
mod.rs

1use nu_ansi_term::{Color, Style};
2
3use crate::ui::theme::ThemeDefinition;
4
5/// Semantic style tokens used across the UI pipeline.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum StyleToken {
8    None,
9    Trace,
10    Info,
11    Warning,
12    Error,
13    Success,
14    Border,
15    PanelBorder,
16    PanelTitle,
17    Key,
18    TableHeader,
19    MregKey,
20    JsonKey,
21    Text,
22    Muted,
23    TextMuted,
24    PromptText,
25    PromptCommand,
26    Value,
27    Number,
28    ValueNumber,
29    BoolTrue,
30    BoolFalse,
31    Null,
32    Ipv4,
33    Ipv6,
34    Code,
35    MessageError,
36    MessageWarning,
37    MessageSuccess,
38    MessageInfo,
39    MessageTrace,
40    Punctuation,
41}
42
43/// Per-token style overrides layered over the resolved theme palette.
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct StyleOverrides {
46    pub text: Option<String>,
47    pub key: Option<String>,
48    pub muted: Option<String>,
49    pub table_header: Option<String>,
50    pub mreg_key: Option<String>,
51    pub value: Option<String>,
52    pub number: Option<String>,
53    pub bool_true: Option<String>,
54    pub bool_false: Option<String>,
55    pub null_value: Option<String>,
56    pub ipv4: Option<String>,
57    pub ipv6: Option<String>,
58    pub panel_border: Option<String>,
59    pub panel_title: Option<String>,
60    pub code: Option<String>,
61    pub json_key: Option<String>,
62    pub message_error: Option<String>,
63    pub message_warning: Option<String>,
64    pub message_success: Option<String>,
65    pub message_info: Option<String>,
66    pub message_trace: Option<String>,
67}
68
69/// Small styling facade used by emitters and message/chrome helpers.
70#[derive(Debug, Clone, Copy)]
71pub struct ThemeStyler<'a> {
72    enabled: bool,
73    theme: &'a ThemeDefinition,
74    overrides: &'a StyleOverrides,
75}
76
77impl<'a> ThemeStyler<'a> {
78    pub fn new(enabled: bool, theme: &'a ThemeDefinition, overrides: &'a StyleOverrides) -> Self {
79        Self {
80            enabled,
81            theme,
82            overrides,
83        }
84    }
85
86    pub fn paint(&self, text: &str, token: StyleToken) -> String {
87        apply_style_spec(
88            text,
89            style_spec(self.theme, self.overrides, token),
90            self.enabled,
91        )
92    }
93
94    pub fn paint_value(&self, text: &str) -> String {
95        self.paint(text, value_style_token(text))
96    }
97}
98
99#[cfg(test)]
100pub fn apply_style(text: &str, token: StyleToken, color: bool, theme_name: &str) -> String {
101    let theme = crate::ui::theme::resolve_theme(theme_name);
102    apply_style_with_theme(text, token, color, &theme)
103}
104
105#[cfg(test)]
106pub fn apply_style_with_overrides(
107    text: &str,
108    token: StyleToken,
109    color: bool,
110    theme_name: &str,
111    overrides: &StyleOverrides,
112) -> String {
113    let theme = crate::ui::theme::resolve_theme(theme_name);
114    apply_style_with_theme_overrides(text, token, color, &theme, overrides)
115}
116
117pub fn apply_style_with_theme(
118    text: &str,
119    token: StyleToken,
120    color: bool,
121    theme: &ThemeDefinition,
122) -> String {
123    apply_style_with_theme_overrides(text, token, color, theme, &StyleOverrides::default())
124}
125
126pub fn apply_style_with_theme_overrides(
127    text: &str,
128    token: StyleToken,
129    color: bool,
130    theme: &ThemeDefinition,
131    overrides: &StyleOverrides,
132) -> String {
133    if !color || text.is_empty() || matches!(token, StyleToken::None) {
134        return text.to_string();
135    }
136
137    apply_style_spec(text, style_spec(theme, overrides, token), color)
138}
139
140/// Returns the style specification for a semantic token under the selected theme.
141pub fn style_spec<'a>(
142    theme: &'a ThemeDefinition,
143    overrides: &'a StyleOverrides,
144    token: StyleToken,
145) -> &'a str {
146    if let Some(spec) = override_spec(overrides, token) {
147        return spec;
148    }
149
150    match token {
151        StyleToken::None => "",
152        StyleToken::Trace
153        | StyleToken::Border
154        | StyleToken::PanelBorder
155        | StyleToken::Ipv4
156        | StyleToken::Ipv6
157        | StyleToken::MessageTrace => theme.palette.border.as_str(),
158        StyleToken::Muted | StyleToken::TextMuted | StyleToken::Null | StyleToken::Punctuation => {
159            theme.palette.muted.as_str()
160        }
161        StyleToken::Info | StyleToken::MessageInfo => theme.palette.info.as_str(),
162        StyleToken::Warning | StyleToken::MessageWarning => theme.palette.warning.as_str(),
163        StyleToken::Error | StyleToken::MessageError | StyleToken::BoolFalse => {
164            theme.palette.error.as_str()
165        }
166        StyleToken::Success
167        | StyleToken::BoolTrue
168        | StyleToken::PromptCommand
169        | StyleToken::MessageSuccess => theme.palette.success.as_str(),
170        StyleToken::PanelTitle => theme.palette.title.as_str(),
171        StyleToken::Key | StyleToken::TableHeader | StyleToken::MregKey | StyleToken::JsonKey => {
172            theme.palette.accent.as_str()
173        }
174        StyleToken::Text | StyleToken::PromptText | StyleToken::Code | StyleToken::Value => {
175            theme.palette.text.as_str()
176        }
177        StyleToken::Number | StyleToken::ValueNumber => theme.value_number_spec(),
178    }
179}
180
181fn override_spec(overrides: &StyleOverrides, token: StyleToken) -> Option<&str> {
182    match token {
183        StyleToken::None | StyleToken::PromptCommand => None,
184        StyleToken::Trace | StyleToken::MessageTrace => overrides
185            .message_trace
186            .as_deref()
187            .or(overrides.panel_border.as_deref()),
188        StyleToken::Info | StyleToken::MessageInfo => overrides.message_info.as_deref(),
189        StyleToken::Warning | StyleToken::MessageWarning => overrides.message_warning.as_deref(),
190        StyleToken::Error | StyleToken::MessageError => overrides
191            .message_error
192            .as_deref()
193            .or(overrides.panel_border.as_deref()),
194        StyleToken::Success | StyleToken::MessageSuccess => overrides.message_success.as_deref(),
195        StyleToken::Border | StyleToken::PanelBorder => overrides.panel_border.as_deref(),
196        StyleToken::PanelTitle => overrides.panel_title.as_deref(),
197        StyleToken::Key => overrides.key.as_deref(),
198        StyleToken::TableHeader => overrides
199            .table_header
200            .as_deref()
201            .or(overrides.key.as_deref()),
202        StyleToken::MregKey => overrides.mreg_key.as_deref().or(overrides.key.as_deref()),
203        StyleToken::JsonKey => overrides.json_key.as_deref().or(overrides.key.as_deref()),
204        StyleToken::Text => overrides.text.as_deref(),
205        StyleToken::Muted | StyleToken::TextMuted | StyleToken::Punctuation => {
206            overrides.muted.as_deref()
207        }
208        StyleToken::Code => overrides.code.as_deref().or(overrides.text.as_deref()),
209        StyleToken::Value | StyleToken::PromptText => {
210            overrides.value.as_deref().or(overrides.text.as_deref())
211        }
212        StyleToken::Number | StyleToken::ValueNumber => overrides.number.as_deref(),
213        StyleToken::BoolTrue => overrides
214            .bool_true
215            .as_deref()
216            .or(overrides.message_success.as_deref()),
217        StyleToken::BoolFalse => overrides
218            .bool_false
219            .as_deref()
220            .or(overrides.message_error.as_deref()),
221        StyleToken::Null => overrides
222            .null_value
223            .as_deref()
224            .or(overrides.muted.as_deref()),
225        StyleToken::Ipv4 => overrides
226            .ipv4
227            .as_deref()
228            .or(overrides.panel_border.as_deref()),
229        StyleToken::Ipv6 => overrides
230            .ipv6
231            .as_deref()
232            .or(overrides.panel_border.as_deref()),
233    }
234}
235
236pub fn value_style_token(value: &str) -> StyleToken {
237    let trimmed = value.trim();
238    if trimmed.is_empty() {
239        return StyleToken::Value;
240    }
241
242    match trimmed.to_ascii_lowercase().as_str() {
243        "true" => StyleToken::BoolTrue,
244        "false" => StyleToken::BoolFalse,
245        "null" | "none" | "nil" | "n/a" => StyleToken::Null,
246        _ if trimmed.parse::<f64>().is_ok() => StyleToken::ValueNumber,
247        _ => StyleToken::Value,
248    }
249}
250
251pub fn apply_style_spec(text: &str, spec: &str, enabled: bool) -> String {
252    if !enabled || text.is_empty() {
253        return text.to_string();
254    }
255
256    let Some(style) = parse_style_spec(spec) else {
257        return text.to_string();
258    };
259    let prefix = style.prefix().to_string();
260    if prefix.is_empty() {
261        return text.to_string();
262    }
263    format!("{prefix}{text}{}", style.suffix())
264}
265
266/// Validates that a style specification uses syntax the renderer understands.
267pub fn is_valid_style_spec(value: &str) -> bool {
268    let trimmed = value.trim();
269    if trimmed.is_empty() {
270        return true;
271    }
272
273    trimmed.split_whitespace().all(|raw| {
274        let token = raw.trim().to_ascii_lowercase();
275        !token.is_empty() && (is_style_modifier(&token) || parse_color_token(&token).is_some())
276    })
277}
278
279fn parse_style_spec(spec: &str) -> Option<Style> {
280    let mut style = Style::new();
281    let mut changed = false;
282
283    for raw in spec.split_whitespace() {
284        let token = raw.trim().to_ascii_lowercase();
285        if token.is_empty() {
286            continue;
287        }
288        if let Some(updated) = apply_style_token(style, &token) {
289            style = updated;
290            changed = true;
291        }
292    }
293
294    changed.then_some(style)
295}
296
297fn is_style_modifier(token: &str) -> bool {
298    matches!(token, "bold" | "dim" | "dimmed" | "italic" | "underline")
299}
300
301fn apply_style_token(style: Style, token: &str) -> Option<Style> {
302    match token {
303        "bold" => Some(style.bold()),
304        "dim" | "dimmed" => Some(style.dimmed()),
305        "italic" => Some(style.italic()),
306        "underline" => Some(style.underline()),
307        _ => parse_color_token(token).map(|color| style.fg(color)),
308    }
309}
310
311fn parse_color_token(token: &str) -> Option<Color> {
312    match token {
313        "black" => Some(Color::Black),
314        "red" => Some(Color::Red),
315        "green" => Some(Color::Green),
316        "yellow" => Some(Color::Yellow),
317        "blue" => Some(Color::Blue),
318        "purple" | "magenta" => Some(Color::Purple),
319        "cyan" => Some(Color::Cyan),
320        "white" => Some(Color::White),
321        "bright-black" => Some(Color::DarkGray),
322        "bright-red" => Some(Color::LightRed),
323        "bright-green" => Some(Color::LightGreen),
324        "bright-yellow" => Some(Color::LightYellow),
325        "bright-blue" => Some(Color::LightBlue),
326        "bright-purple" | "bright-magenta" => Some(Color::LightPurple),
327        "bright-cyan" => Some(Color::LightCyan),
328        "bright-white" => Some(Color::LightGray),
329        _ => parse_hex_rgb(token).map(|(r, g, b)| Color::Rgb(r, g, b)),
330    }
331}
332
333fn parse_hex_rgb(value: &str) -> Option<(u8, u8, u8)> {
334    match value.as_bytes() {
335        [b'#', r, g, b] => Some((
336            expand_hex_nibble(*r)?,
337            expand_hex_nibble(*g)?,
338            expand_hex_nibble(*b)?,
339        )),
340        [b'#', r1, r2, g1, g2, b1, b2] => Some((
341            parse_hex_pair(*r1, *r2)?,
342            parse_hex_pair(*g1, *g2)?,
343            parse_hex_pair(*b1, *b2)?,
344        )),
345        _ => None,
346    }
347}
348
349fn expand_hex_nibble(value: u8) -> Option<u8> {
350    let nibble = parse_hex_digit(value)?;
351    Some((nibble << 4) | nibble)
352}
353
354fn parse_hex_pair(high: u8, low: u8) -> Option<u8> {
355    Some((parse_hex_digit(high)? << 4) | parse_hex_digit(low)?)
356}
357
358fn parse_hex_digit(value: u8) -> Option<u8> {
359    match value {
360        b'0'..=b'9' => Some(value - b'0'),
361        b'a'..=b'f' => Some(value - b'a' + 10),
362        b'A'..=b'F' => Some(value - b'A' + 10),
363        _ => None,
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use crate::ui::theme::resolve_theme;
370
371    use super::{
372        StyleOverrides, StyleToken, ThemeStyler, apply_style_spec, is_valid_style_spec, style_spec,
373        value_style_token,
374    };
375
376    #[test]
377    fn style_tokens_follow_palette_defaults_and_overrides_unit() {
378        let rose = resolve_theme("rose-pine-moon");
379        let overrides = StyleOverrides::default();
380        assert_eq!(
381            style_spec(&rose, &overrides, StyleToken::TextMuted),
382            rose.palette.muted
383        );
384        assert_eq!(
385            style_spec(&rose, &overrides, StyleToken::PanelTitle),
386            rose.palette.title
387        );
388
389        let overridden = StyleOverrides {
390            muted: Some("yellow".to_string()),
391            panel_title: Some("bold blue".to_string()),
392            ..StyleOverrides::default()
393        };
394        assert_eq!(
395            style_spec(&rose, &overridden, StyleToken::TextMuted),
396            "yellow"
397        );
398        assert_eq!(
399            style_spec(&rose, &overridden, StyleToken::PanelTitle),
400            "bold blue"
401        );
402    }
403
404    #[test]
405    fn value_tokens_cover_booleans_null_numbers_and_text_unit() {
406        assert_eq!(value_style_token("true"), StyleToken::BoolTrue);
407        assert_eq!(value_style_token("false"), StyleToken::BoolFalse);
408        assert_eq!(value_style_token("null"), StyleToken::Null);
409        assert_eq!(value_style_token("19.2"), StyleToken::ValueNumber);
410        assert_eq!(value_style_token("hello"), StyleToken::Value);
411    }
412
413    #[test]
414    fn style_helpers_cover_plain_and_colored_paths_unit() {
415        let rose = resolve_theme("rose-pine-moon");
416        let overrides = StyleOverrides::default();
417        let styler = ThemeStyler::new(true, &rose, &overrides);
418        let painted = styler.paint("Errors", StyleToken::MessageError);
419        assert!(painted.contains("\u{1b}["));
420        assert_eq!(apply_style_spec("x", "wat", true), "x");
421        assert!(is_valid_style_spec("bold #abcdef"));
422        assert!(!is_valid_style_spec("wat ???"));
423    }
424
425    #[test]
426    fn style_overrides_cover_value_painting_and_fallback_tokens_unit() {
427        let rose = resolve_theme("rose-pine-moon");
428        let overrides = StyleOverrides {
429            key: Some("green".to_string()),
430            number: Some("#123456".to_string()),
431            bool_true: Some("#0f0".to_string()),
432            message_error: Some("bold red".to_string()),
433            ..StyleOverrides::default()
434        };
435        let styler = ThemeStyler::new(true, &rose, &overrides);
436
437        assert_eq!(
438            style_spec(&rose, &overrides, StyleToken::TableHeader),
439            "green"
440        );
441        assert!(styler.paint_value("42").contains("\u{1b}["));
442        assert!(
443            styler
444                .paint("true", StyleToken::BoolTrue)
445                .contains("\u{1b}[")
446        );
447        assert!(
448            super::apply_style_with_theme_overrides(
449                "boom",
450                StyleToken::MessageError,
451                true,
452                &rose,
453                &overrides,
454            )
455            .contains("\u{1b}[")
456        );
457        assert!(super::apply_style_spec("x", "bold #abc", true).contains("\u{1b}["));
458    }
459}