Skip to main content

osp_cli/ui/
style.rs

1use nu_ansi_term::{Color, Style};
2
3#[cfg(test)]
4use crate::ui::theme;
5use crate::ui::theme::ThemeDefinition;
6
7/// Per-token style overrides layered on top of theme-derived defaults.
8///
9/// `None` means "inherit the theme result for this token" rather than "disable
10/// styling entirely".
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct StyleOverrides {
13    /// Override for plain text fragments.
14    pub text: Option<String>,
15    /// Override for keys, labels, and headings.
16    pub key: Option<String>,
17    /// Override for muted or secondary text.
18    pub muted: Option<String>,
19    /// Override for table header cells.
20    pub table_header: Option<String>,
21    /// Override for MREG keys.
22    pub mreg_key: Option<String>,
23    /// Override for generic scalar values.
24    pub value: Option<String>,
25    /// Override for numeric values.
26    pub number: Option<String>,
27    /// Override for `true` boolean values.
28    pub bool_true: Option<String>,
29    /// Override for `false` boolean values.
30    pub bool_false: Option<String>,
31    /// Override for null-like values.
32    pub null_value: Option<String>,
33    /// Override for IPv4 addresses.
34    pub ipv4: Option<String>,
35    /// Override for IPv6 addresses.
36    pub ipv6: Option<String>,
37    /// Override for panel or section border chrome.
38    pub panel_border: Option<String>,
39    /// Override for panel or section titles.
40    pub panel_title: Option<String>,
41    /// Override for code blocks and inline code.
42    pub code: Option<String>,
43    /// Override for JSON keys.
44    pub json_key: Option<String>,
45    /// Override for error messages.
46    pub message_error: Option<String>,
47    /// Override for warning messages.
48    pub message_warning: Option<String>,
49    /// Override for success messages.
50    pub message_success: Option<String>,
51    /// Override for informational messages.
52    pub message_info: Option<String>,
53    /// Override for trace/debug-style messages.
54    pub message_trace: Option<String>,
55}
56
57/// Semantic style token used when rendering text fragments.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum StyleToken {
60    /// Disable styling for the fragment.
61    None,
62    /// Key or label text.
63    Key,
64    /// Muted or secondary text.
65    Muted,
66    /// Static prompt text.
67    PromptText,
68    /// The active command segment in a prompt.
69    PromptCommand,
70    /// Table header cells.
71    TableHeader,
72    /// MREG key names.
73    MregKey,
74    /// JSON object keys.
75    JsonKey,
76    /// Code blocks and inline code.
77    Code,
78    /// Section or panel borders.
79    PanelBorder,
80    /// Section or panel titles.
81    PanelTitle,
82    /// Generic values.
83    Value,
84    /// Numeric values.
85    Number,
86    /// Boolean true values.
87    BoolTrue,
88    /// Boolean false values.
89    BoolFalse,
90    /// Null-like values.
91    Null,
92    /// IPv4 addresses.
93    Ipv4,
94    /// IPv6 addresses.
95    Ipv6,
96    /// Error messages.
97    MessageError,
98    /// Warning messages.
99    MessageWarning,
100    /// Success messages.
101    MessageSuccess,
102    /// Informational messages.
103    MessageInfo,
104    /// Trace/debug-style messages.
105    MessageTrace,
106}
107
108#[cfg(test)]
109/// Applies a theme token by theme name.
110pub fn apply_style(text: &str, token: StyleToken, color: bool, theme_name: &str) -> String {
111    apply_style_with_overrides(text, token, color, theme_name, &StyleOverrides::default())
112}
113
114#[cfg(test)]
115/// Applies a theme token by theme name with explicit style overrides.
116pub fn apply_style_with_overrides(
117    text: &str,
118    token: StyleToken,
119    color: bool,
120    theme_name: &str,
121    overrides: &StyleOverrides,
122) -> String {
123    let theme = theme::resolve_theme(theme_name);
124    apply_style_with_theme_overrides(text, token, color, &theme, overrides)
125}
126
127/// Applies a theme token using an already resolved theme.
128pub fn apply_style_with_theme(
129    text: &str,
130    token: StyleToken,
131    color: bool,
132    theme: &ThemeDefinition,
133) -> String {
134    apply_style_with_theme_overrides(text, token, color, theme, &StyleOverrides::default())
135}
136
137/// Applies a theme token using a resolved theme and explicit overrides.
138pub fn apply_style_with_theme_overrides(
139    text: &str,
140    token: StyleToken,
141    color: bool,
142    theme: &ThemeDefinition,
143    overrides: &StyleOverrides,
144) -> String {
145    if !color || matches!(token, StyleToken::None) {
146        return text.to_string();
147    }
148
149    apply_style_spec(text, resolve_style_spec(token, theme, overrides), color)
150}
151
152/// Applies a raw style specification to a text fragment.
153pub fn apply_style_spec(text: &str, spec: &str, color: bool) -> String {
154    if !color {
155        return text.to_string();
156    }
157    let Some(style) = parse_style_spec(spec) else {
158        return text.to_string();
159    };
160    let prefix = style.prefix().to_string();
161    if prefix.is_empty() {
162        return text.to_string();
163    }
164    format!("{prefix}{text}{}", style.suffix())
165}
166
167/// Validates that a style specification uses syntax the renderer understands.
168pub fn is_valid_style_spec(value: &str) -> bool {
169    let trimmed = value.trim();
170    if trimmed.is_empty() {
171        return true;
172    }
173
174    trimmed.split_whitespace().all(|raw| {
175        let token = raw.trim().to_ascii_lowercase();
176        !token.is_empty() && (is_style_modifier(&token) || parse_color_token(&token).is_some())
177    })
178}
179
180fn resolve_style_spec<'a>(
181    token: StyleToken,
182    theme: &'a ThemeDefinition,
183    overrides: &'a StyleOverrides,
184) -> &'a str {
185    overrides
186        .spec_for(token)
187        .unwrap_or_else(|| token.theme_spec(theme))
188}
189
190impl StyleOverrides {
191    fn spec_for(&self, token: StyleToken) -> Option<&str> {
192        match token {
193            StyleToken::None | StyleToken::PromptText | StyleToken::PromptCommand => None,
194            StyleToken::Key => self.key.as_deref(),
195            StyleToken::Muted => self.muted.as_deref(),
196            StyleToken::TableHeader => self.table_header.as_deref().or(self.key.as_deref()),
197            StyleToken::MregKey => self.mreg_key.as_deref().or(self.key.as_deref()),
198            StyleToken::JsonKey => self.json_key.as_deref().or(self.key.as_deref()),
199            StyleToken::Code => self.code.as_deref().or(self.text.as_deref()),
200            StyleToken::PanelBorder => self.panel_border.as_deref(),
201            StyleToken::PanelTitle => self.panel_title.as_deref(),
202            StyleToken::Value => self.value.as_deref().or(self.text.as_deref()),
203            StyleToken::Number => self.number.as_deref(),
204            StyleToken::BoolTrue => self.bool_true.as_deref(),
205            StyleToken::BoolFalse => self.bool_false.as_deref(),
206            StyleToken::Null => self.null_value.as_deref(),
207            StyleToken::Ipv4 => self.ipv4.as_deref(),
208            StyleToken::Ipv6 => self.ipv6.as_deref(),
209            StyleToken::MessageError => self.message_error.as_deref(),
210            StyleToken::MessageWarning => self.message_warning.as_deref(),
211            StyleToken::MessageSuccess => self.message_success.as_deref(),
212            StyleToken::MessageInfo => self.message_info.as_deref(),
213            StyleToken::MessageTrace => self.message_trace.as_deref(),
214        }
215    }
216}
217
218impl StyleToken {
219    fn theme_spec(self, theme: &ThemeDefinition) -> &str {
220        match self {
221            StyleToken::None => "",
222            StyleToken::Key
223            | StyleToken::TableHeader
224            | StyleToken::MregKey
225            | StyleToken::JsonKey => &theme.palette.accent,
226            StyleToken::Muted | StyleToken::Null => &theme.palette.muted,
227            StyleToken::PromptText | StyleToken::Code | StyleToken::Value => &theme.palette.text,
228            StyleToken::PromptCommand | StyleToken::BoolTrue | StyleToken::MessageSuccess => {
229                &theme.palette.success
230            }
231            StyleToken::PanelBorder
232            | StyleToken::Ipv4
233            | StyleToken::Ipv6
234            | StyleToken::MessageTrace => &theme.palette.border,
235            StyleToken::PanelTitle => &theme.palette.title,
236            StyleToken::Number => theme.value_number_spec(),
237            StyleToken::BoolFalse | StyleToken::MessageError => &theme.palette.error,
238            StyleToken::MessageWarning => &theme.palette.warning,
239            StyleToken::MessageInfo => &theme.palette.info,
240        }
241    }
242}
243
244fn parse_style_spec(spec: &str) -> Option<Style> {
245    let mut style = Style::new();
246    let mut changed = false;
247
248    for raw in spec.split_whitespace() {
249        let token = raw.trim().to_ascii_lowercase();
250        if token.is_empty() {
251            continue;
252        }
253
254        if let Some(updated) = apply_style_token(style, &token) {
255            style = updated;
256            changed = true;
257        }
258    }
259
260    changed.then_some(style)
261}
262
263fn apply_style_token(style: Style, token: &str) -> Option<Style> {
264    match token {
265        "bold" => Some(style.bold()),
266        "dim" => Some(style.dimmed()),
267        "italic" => Some(style.italic()),
268        "underline" => Some(style.underline()),
269        _ => parse_color_token(token).map(|color| style.fg(color)),
270    }
271}
272
273fn is_style_modifier(token: &str) -> bool {
274    matches!(token, "bold" | "dim" | "italic" | "underline")
275}
276
277fn parse_color_token(token: &str) -> Option<Color> {
278    match token {
279        "black" => Some(Color::Black),
280        "red" => Some(Color::Red),
281        "green" => Some(Color::Green),
282        "yellow" => Some(Color::Yellow),
283        "blue" => Some(Color::Blue),
284        "purple" | "magenta" => Some(Color::Purple),
285        "cyan" => Some(Color::Cyan),
286        "white" => Some(Color::White),
287        "bright-black" => Some(Color::DarkGray),
288        "bright-red" => Some(Color::LightRed),
289        "bright-green" => Some(Color::LightGreen),
290        "bright-yellow" => Some(Color::LightYellow),
291        "bright-blue" => Some(Color::LightBlue),
292        "bright-purple" | "bright-magenta" => Some(Color::LightPurple),
293        "bright-cyan" => Some(Color::LightCyan),
294        "bright-white" => Some(Color::LightGray),
295        _ => parse_hex_rgb(token).map(|(r, g, b)| Color::Rgb(r, g, b)),
296    }
297}
298
299fn parse_hex_rgb(value: &str) -> Option<(u8, u8, u8)> {
300    if !value.starts_with('#') || value.len() != 7 {
301        return None;
302    }
303    let r = u8::from_str_radix(&value[1..3], 16).ok()?;
304    let g = u8::from_str_radix(&value[3..5], 16).ok()?;
305    let b = u8::from_str_radix(&value[5..7], 16).ok()?;
306    Some((r, g, b))
307}
308
309#[cfg(test)]
310mod tests {
311    use crate::ui::theme;
312
313    use super::{
314        StyleOverrides, StyleToken, apply_style, apply_style_spec, apply_style_with_overrides,
315        apply_style_with_theme, apply_style_with_theme_overrides,
316    };
317
318    #[test]
319    fn theme_defaults_and_color_toggle_cover_plain_nord_and_dracula_unit() {
320        assert_eq!(
321            apply_style("hello", StyleToken::MessageInfo, true, "plain"),
322            "hello"
323        );
324
325        let dracula_error = apply_style("oops", StyleToken::MessageError, true, "dracula");
326        assert!(dracula_error.starts_with("\x1b[1;38;2;255;85;85m"));
327        assert!(dracula_error.ends_with("\x1b[0m"));
328
329        let nord = apply_style("info", StyleToken::MessageInfo, true, "nord");
330        let dracula = apply_style("info", StyleToken::MessageInfo, true, "dracula");
331        assert_ne!(nord, dracula);
332
333        let number = apply_style("42", StyleToken::Number, true, "dracula");
334        assert!(number.starts_with("\x1b[38;2;255;121;198m"));
335
336        assert_eq!(
337            apply_style("warn", StyleToken::MessageWarning, false, "nord"),
338            "warn"
339        );
340
341        let theme = theme::resolve_theme("dracula");
342        let prompt = apply_style_with_theme("osp>", StyleToken::PromptText, true, &theme);
343        let trace = apply_style_with_theme("trace", StyleToken::MessageTrace, true, &theme);
344        assert_ne!(prompt, "osp>");
345        assert_ne!(trace, "trace");
346    }
347
348    #[test]
349    fn overrides_propagate_from_generic_specific_and_panel_tokens_unit() {
350        let explicit_header = apply_style_with_overrides(
351            "head",
352            StyleToken::TableHeader,
353            true,
354            "nord",
355            &StyleOverrides {
356                table_header: Some("#ff0000".to_string()),
357                ..Default::default()
358            },
359        );
360        assert!(explicit_header.starts_with("\x1b[38;2;255;0;0m"));
361
362        let text_overrides = StyleOverrides {
363            text: Some("#112233".to_string()),
364            ..Default::default()
365        };
366        let value =
367            apply_style_with_overrides("hello", StyleToken::Value, true, "nord", &text_overrides);
368        let code = apply_style_with_overrides(
369            "let x = 1;",
370            StyleToken::Code,
371            true,
372            "nord",
373            &text_overrides,
374        );
375        assert!(value.starts_with("\x1b[38;2;17;34;51m"));
376        assert!(code.starts_with("\x1b[38;2;17;34;51m"));
377
378        let key_overrides = StyleOverrides {
379            key: Some("#abcdef".to_string()),
380            ..Default::default()
381        };
382        let table = apply_style_with_overrides(
383            "host",
384            StyleToken::TableHeader,
385            true,
386            "nord",
387            &key_overrides,
388        );
389        let json = apply_style_with_overrides(
390            "\"uid\"",
391            StyleToken::JsonKey,
392            true,
393            "nord",
394            &key_overrides,
395        );
396        assert!(table.starts_with("\x1b[38;2;171;205;239m"));
397        assert!(json.starts_with("\x1b[38;2;171;205;239m"));
398
399        let warning = apply_style_with_overrides(
400            "careful",
401            StyleToken::MessageWarning,
402            true,
403            "nord",
404            &StyleOverrides {
405                message_warning: Some("#ffaa00".to_string()),
406                ..Default::default()
407            },
408        );
409        assert!(warning.starts_with("\x1b[38;2;255;170;0m"));
410
411        let theme = theme::resolve_theme("nord");
412        let prompt = apply_style_with_theme("osp", StyleToken::PromptCommand, true, &theme);
413        let ipv6 = apply_style_with_theme("::1", StyleToken::Ipv6, true, &theme);
414        assert_ne!(prompt, "osp");
415        assert_ne!(ipv6, "::1");
416
417        let overrides = StyleOverrides {
418            panel_border: Some("underline".to_string()),
419            panel_title: Some("#445566".to_string()),
420            ipv4: Some("bright-green".to_string()),
421            bool_false: Some("red".to_string()),
422            null_value: Some("dim".to_string()),
423            ..Default::default()
424        };
425
426        assert!(
427            apply_style_with_theme_overrides(
428                "border",
429                StyleToken::PanelBorder,
430                true,
431                &theme,
432                &overrides
433            )
434            .starts_with("\x1b[4m")
435        );
436        assert!(
437            apply_style_with_theme_overrides(
438                "title",
439                StyleToken::PanelTitle,
440                true,
441                &theme,
442                &overrides
443            )
444            .starts_with("\x1b[38;2;68;85;102m")
445        );
446        assert!(
447            apply_style_with_theme_overrides(
448                "127.0.0.1",
449                StyleToken::Ipv4,
450                true,
451                &theme,
452                &overrides
453            )
454            .starts_with("\x1b[92m")
455        );
456        assert!(
457            apply_style_with_theme_overrides(
458                "false",
459                StyleToken::BoolFalse,
460                true,
461                &theme,
462                &overrides
463            )
464            .starts_with("\x1b[31m")
465        );
466        assert!(
467            apply_style_with_theme_overrides("null", StyleToken::Null, true, &theme, &overrides)
468                .starts_with("\x1b[2m")
469        );
470    }
471
472    #[test]
473    fn none_token_and_invalid_specs_fall_back_safely_unit() {
474        assert_eq!(
475            apply_style("plain", StyleToken::None, true, "nord"),
476            "plain"
477        );
478        assert_eq!(apply_style_spec("plain", "mystery-token", true), "plain");
479        assert_eq!(
480            apply_style_spec("plain", "bold #zzzzzz", true),
481            "\x1b[1mplain\x1b[0m"
482        );
483    }
484}