Skip to main content

osp_cli/ui/
style.rs

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