1use nu_ansi_term::{Color, Style};
2
3use crate::ui::theme::ThemeDefinition;
4
5#[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#[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#[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
140pub 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
266pub 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}