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}