1use nu_ansi_term::{Color, Style};
2
3#[cfg(test)]
4use crate::ui::theme;
5use crate::ui::theme::ThemeDefinition;
6
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct StyleOverrides {
13 pub text: Option<String>,
15 pub key: Option<String>,
17 pub muted: Option<String>,
19 pub table_header: Option<String>,
21 pub mreg_key: Option<String>,
23 pub value: Option<String>,
25 pub number: Option<String>,
27 pub bool_true: Option<String>,
29 pub bool_false: Option<String>,
31 pub null_value: Option<String>,
33 pub ipv4: Option<String>,
35 pub ipv6: Option<String>,
37 pub panel_border: Option<String>,
39 pub panel_title: Option<String>,
41 pub code: Option<String>,
43 pub json_key: Option<String>,
45 pub message_error: Option<String>,
47 pub message_warning: Option<String>,
49 pub message_success: Option<String>,
51 pub message_info: Option<String>,
53 pub message_trace: Option<String>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum StyleToken {
60 None,
62 Key,
64 Muted,
66 PromptText,
68 PromptCommand,
70 TableHeader,
72 MregKey,
74 JsonKey,
76 Code,
78 PanelBorder,
80 PanelTitle,
82 Value,
84 Number,
86 BoolTrue,
88 BoolFalse,
90 Null,
92 Ipv4,
94 Ipv6,
96 MessageError,
98 MessageWarning,
100 MessageSuccess,
102 MessageInfo,
104 MessageTrace,
106}
107
108#[cfg(test)]
109pub 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)]
115pub 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
127pub 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
137pub 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
152pub 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
167pub 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}