Skip to main content

neumann_shell/style/
theme.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Color theme system for terminal output.
3
4use owo_colors::{OwoColorize, Style};
5
6/// Color theme for terminal output.
7///
8/// Provides a consistent color palette for all shell output. Supports both
9/// dark and light terminal backgrounds with automatic detection.
10#[derive(Debug, Clone)]
11pub struct Theme {
12    // Status colors
13    pub success: Style,
14    pub error: Style,
15    pub warning: Style,
16    pub info: Style,
17
18    // Structural
19    pub header: Style,
20    pub border: Style,
21    pub muted: Style,
22
23    // Data types
24    pub keyword: Style,
25    pub string: Style,
26    pub number: Style,
27    pub id: Style,
28    pub label: Style,
29    pub null: Style,
30
31    // Special
32    pub highlight: Style,
33    pub link: Style,
34}
35
36#[allow(clippy::missing_const_for_fn)]
37impl Theme {
38    /// Creates a dark theme (Gruvbox-inspired).
39    #[must_use]
40    pub fn dark() -> Self {
41        Self {
42            // Status
43            success: Style::new().green(),
44            error: Style::new().red().bold(),
45            warning: Style::new().yellow(),
46            info: Style::new().cyan(),
47
48            // Structural
49            header: Style::new().magenta().bold(),
50            border: Style::new().bright_black(),
51            muted: Style::new().bright_black(),
52
53            // Data types
54            keyword: Style::new().blue(),
55            string: Style::new().green(),
56            number: Style::new().yellow(),
57            id: Style::new().cyan().bold(),
58            label: Style::new().magenta(),
59            null: Style::new().bright_black().italic(),
60
61            // Special
62            highlight: Style::new().white().bold(),
63            link: Style::new().blue().underline(),
64        }
65    }
66
67    /// Creates a light theme for light terminal backgrounds.
68    #[must_use]
69    pub fn light() -> Self {
70        Self {
71            // Status
72            success: Style::new().green(),
73            error: Style::new().red().bold(),
74            warning: Style::new().yellow(),
75            info: Style::new().cyan(),
76
77            // Structural
78            header: Style::new().magenta().bold(),
79            border: Style::new().bright_black(),
80            muted: Style::new().bright_black(),
81
82            // Data types
83            keyword: Style::new().blue(),
84            string: Style::new().green(),
85            number: Style::new().yellow(),
86            id: Style::new().cyan().bold(),
87            label: Style::new().magenta(),
88            null: Style::new().bright_black().italic(),
89
90            // Special
91            highlight: Style::new().black().bold(),
92            link: Style::new().blue().underline(),
93        }
94    }
95
96    /// Creates a plain theme with no colors (for piped output).
97    #[must_use]
98    pub fn plain() -> Self {
99        Self {
100            success: Style::new(),
101            error: Style::new(),
102            warning: Style::new(),
103            info: Style::new(),
104            header: Style::new(),
105            border: Style::new(),
106            muted: Style::new(),
107            keyword: Style::new(),
108            string: Style::new(),
109            number: Style::new(),
110            id: Style::new(),
111            label: Style::new(),
112            null: Style::new(),
113            highlight: Style::new(),
114            link: Style::new(),
115        }
116    }
117
118    /// Creates a phosphor green theme (matches boot sequence aesthetic).
119    #[must_use]
120    pub fn phosphor() -> Self {
121        Self {
122            // Status - green variants
123            success: Style::new().green().bold(),
124            error: Style::new().red().bold(),
125            warning: Style::new().yellow(),
126            info: Style::new().bright_green(),
127
128            // Structural
129            header: Style::new().bright_green().bold(),
130            border: Style::new().green(),
131            muted: Style::new().bright_black(),
132
133            // Data types - green palette
134            keyword: Style::new().bright_green(),
135            string: Style::new().green(),
136            number: Style::new().bright_green(),
137            id: Style::new().green().bold(),
138            label: Style::new().bright_green(),
139            null: Style::new().bright_black().italic(),
140
141            // Special
142            highlight: Style::new().bright_green().bold(),
143            link: Style::new().green().underline(),
144        }
145    }
146
147    /// Auto-detects the best theme based on terminal capabilities.
148    #[must_use]
149    pub fn auto() -> Self {
150        if console::Term::stdout().is_term() {
151            Self::phosphor() // Use phosphor theme by default for terminal
152        } else {
153            Self::plain()
154        }
155    }
156}
157
158impl Default for Theme {
159    fn default() -> Self {
160        Self::auto()
161    }
162}
163
164/// Applies a style to text, returning the styled string.
165pub fn styled<T: std::fmt::Display>(text: T, style: Style) -> String {
166    text.style(style).to_string()
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_dark_theme_has_colors() {
175        let theme = Theme::dark();
176        // Verify all fields are accessible
177        let _ = theme.success;
178        let _ = theme.error;
179        let _ = theme.warning;
180        let _ = theme.info;
181        let _ = theme.header;
182        let _ = theme.border;
183        let _ = theme.muted;
184        let _ = theme.keyword;
185        let _ = theme.string;
186        let _ = theme.number;
187        let _ = theme.id;
188        let _ = theme.label;
189        let _ = theme.null;
190        let _ = theme.highlight;
191        let _ = theme.link;
192    }
193
194    #[test]
195    fn test_light_theme_has_colors() {
196        let theme = Theme::light();
197        let _ = theme.success;
198        let _ = theme.error;
199        let _ = theme.warning;
200        let _ = theme.info;
201        let _ = theme.header;
202        let _ = theme.border;
203        let _ = theme.muted;
204        let _ = theme.keyword;
205        let _ = theme.string;
206        let _ = theme.number;
207        let _ = theme.id;
208        let _ = theme.label;
209        let _ = theme.null;
210        let _ = theme.highlight;
211        let _ = theme.link;
212    }
213
214    #[test]
215    fn test_plain_theme_no_colors() {
216        let theme = Theme::plain();
217        let _ = theme.success;
218        let _ = theme.error;
219        let _ = theme.warning;
220        let _ = theme.info;
221        let _ = theme.header;
222        let _ = theme.border;
223        let _ = theme.muted;
224        let _ = theme.keyword;
225        let _ = theme.string;
226        let _ = theme.number;
227        let _ = theme.id;
228        let _ = theme.label;
229        let _ = theme.null;
230        let _ = theme.highlight;
231        let _ = theme.link;
232    }
233
234    #[test]
235    fn test_phosphor_theme_has_colors() {
236        let theme = Theme::phosphor();
237        let _ = theme.success;
238        let _ = theme.error;
239        let _ = theme.warning;
240        let _ = theme.info;
241        let _ = theme.header;
242        let _ = theme.border;
243        let _ = theme.muted;
244        let _ = theme.keyword;
245        let _ = theme.string;
246        let _ = theme.number;
247        let _ = theme.id;
248        let _ = theme.label;
249        let _ = theme.null;
250        let _ = theme.highlight;
251        let _ = theme.link;
252    }
253
254    #[test]
255    fn test_styled_applies_style() {
256        let style = Style::new().green();
257        let result = styled("test", style);
258        assert!(!result.is_empty());
259    }
260
261    #[test]
262    fn test_styled_with_number() {
263        let style = Style::new().yellow();
264        let result = styled(42, style);
265        assert!(result.contains("42"));
266    }
267
268    #[test]
269    fn test_default_theme() {
270        let _theme = Theme::default();
271    }
272
273    #[test]
274    fn test_theme_clone() {
275        let theme = Theme::dark();
276        let cloned = theme;
277        let _ = cloned.success;
278    }
279
280    #[test]
281    fn test_theme_debug() {
282        let theme = Theme::dark();
283        let debug = format!("{theme:?}");
284        assert!(debug.contains("Theme"));
285    }
286
287    #[test]
288    fn test_auto_theme() {
289        let _theme = Theme::auto();
290    }
291}