Skip to main content

clap_tui/
config.rs

1use ratatui::style::Color;
2
3/// UI colors used across the TUI.
4///
5/// Most applications can start from [`Theme::from_preset`] and override only the fields they
6/// want to change.
7#[derive(Debug, Clone)]
8#[non_exhaustive]
9pub struct Theme {
10    /// Primary text color.
11    pub text: Color,
12    /// Accent color for interactive UI chrome.
13    pub accent: Color,
14    /// Accent color for breadcrumb and command/result emphasis.
15    pub result_accent: Color,
16    /// Focus treatment color for active controls and panels.
17    pub focus: Color,
18    /// Success-oriented feedback color.
19    pub success: Color,
20    /// Informative accent for source and state metadata.
21    pub info: Color,
22    /// Caution-oriented color reserved for non-error warning states.
23    pub warning: Color,
24    /// Passive metadata and descriptive text color.
25    pub metadata: Color,
26    /// Border color.
27    pub border: Color,
28    /// Border color for passive shell chrome.
29    pub shell_border: Color,
30    /// Focused interactive panel border color.
31    pub panel_focus_border: Color,
32    /// Error color.
33    pub error: Color,
34    /// Dimmed text color.
35    pub dim: Color,
36    /// Input background color.
37    pub input_bg: Color,
38    /// Background for the outer shell frame.
39    pub shell_bg: Color,
40    /// Background for the navigation surface.
41    pub sidebar_bg: Color,
42    /// Background for the active editing surface.
43    pub workspace_bg: Color,
44    /// Focused row background color.
45    pub focus_bg: Color,
46    /// Panel background color.
47    pub panel_bg: Color,
48    /// Raised surface for selected rows and compact controls.
49    pub surface_raised: Color,
50    /// Header band background color.
51    pub header_bg: Color,
52    /// Filled background for selected items.
53    pub selection_bg: Color,
54    /// Foreground for selected items.
55    pub selection_fg: Color,
56    /// Background for selected but unfocused items.
57    pub selected_idle_bg: Color,
58    /// Foreground for selected but unfocused items.
59    pub selected_idle_fg: Color,
60    /// Pill background color.
61    pub pill_bg: Color,
62    /// Badge background color for compact metadata chips.
63    pub badge_bg: Color,
64    /// Primary action background color.
65    pub primary_action_bg: Color,
66    /// Primary action foreground color.
67    pub primary_action_fg: Color,
68    /// Secondary action background color.
69    pub secondary_action_bg: Color,
70    /// Secondary action foreground color.
71    pub secondary_action_fg: Color,
72    /// Background for the read-only preview band.
73    pub preview_bg: Color,
74    /// Background for overlays such as dropdowns and help.
75    pub overlay_bg: Color,
76    /// Divider color.
77    pub divider: Color,
78}
79
80/// Built-in theme presets.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum ThemePreset {
84    /// Balanced dark preset with muted surfaces and teal accents.
85    CalmDark,
86    /// Higher-contrast dark preset for brighter terminals.
87    HighContrastDark,
88    /// Light preset with subdued neutral surfaces.
89    Light,
90}
91
92impl Theme {
93    /// Build a theme from a built-in preset.
94    #[must_use]
95    #[allow(clippy::too_many_lines)]
96    pub fn from_preset(preset: ThemePreset) -> Self {
97        match preset {
98            ThemePreset::CalmDark => Self {
99                text: Color::Rgb(214, 225, 228),
100                accent: Color::Rgb(67, 225, 232),
101                result_accent: Color::Rgb(102, 236, 242),
102                focus: Color::Rgb(198, 245, 248),
103                success: Color::Rgb(110, 214, 154),
104                info: Color::Rgb(118, 176, 224),
105                warning: Color::Rgb(232, 186, 92),
106                metadata: Color::Rgb(132, 160, 166),
107                border: Color::Rgb(45, 88, 96),
108                shell_border: Color::Rgb(25, 49, 56),
109                panel_focus_border: Color::Rgb(102, 236, 242),
110                error: Color::Rgb(255, 99, 110),
111                dim: Color::Rgb(105, 132, 138),
112                input_bg: Color::Rgb(15, 34, 38),
113                shell_bg: Color::Rgb(5, 14, 17),
114                sidebar_bg: Color::Rgb(8, 20, 24),
115                workspace_bg: Color::Rgb(10, 24, 29),
116                focus_bg: Color::Rgb(18, 58, 66),
117                panel_bg: Color::Rgb(10, 24, 29),
118                surface_raised: Color::Rgb(17, 47, 53),
119                header_bg: Color::Rgb(14, 47, 54),
120                selection_bg: Color::Rgb(12, 88, 97),
121                selection_fg: Color::Rgb(245, 251, 252),
122                selected_idle_bg: Color::Rgb(12, 61, 68),
123                selected_idle_fg: Color::Rgb(230, 243, 245),
124                pill_bg: Color::Rgb(12, 28, 32),
125                badge_bg: Color::Rgb(16, 39, 44),
126                primary_action_bg: Color::Rgb(72, 156, 164),
127                primary_action_fg: Color::Rgb(5, 14, 17),
128                secondary_action_bg: Color::Rgb(16, 39, 44),
129                secondary_action_fg: Color::Rgb(188, 208, 212),
130                preview_bg: Color::Rgb(8, 20, 24),
131                overlay_bg: Color::Rgb(12, 26, 31),
132                divider: Color::Rgb(38, 75, 82),
133            },
134            ThemePreset::HighContrastDark => Self {
135                text: Color::Rgb(245, 247, 250),
136                accent: Color::Rgb(102, 218, 194),
137                result_accent: Color::Rgb(245, 185, 96),
138                focus: Color::Rgb(210, 225, 236),
139                success: Color::Rgb(125, 229, 171),
140                info: Color::Rgb(136, 201, 222),
141                warning: Color::Rgb(242, 195, 102),
142                metadata: Color::Rgb(175, 188, 202),
143                border: Color::Rgb(90, 106, 122),
144                shell_border: Color::Rgb(62, 75, 90),
145                panel_focus_border: Color::Rgb(136, 201, 222),
146                error: Color::Rgb(255, 99, 110),
147                dim: Color::Rgb(128, 142, 156),
148                input_bg: Color::Rgb(26, 34, 42),
149                shell_bg: Color::Rgb(10, 15, 22),
150                sidebar_bg: Color::Rgb(16, 22, 30),
151                workspace_bg: Color::Rgb(20, 28, 36),
152                focus_bg: Color::Rgb(44, 64, 78),
153                panel_bg: Color::Rgb(20, 26, 34),
154                surface_raised: Color::Rgb(30, 40, 50),
155                header_bg: Color::Rgb(14, 20, 26),
156                selection_bg: Color::Rgb(44, 64, 78),
157                selection_fg: Color::Rgb(245, 247, 250),
158                selected_idle_bg: Color::Rgb(30, 40, 50),
159                selected_idle_fg: Color::Rgb(245, 247, 250),
160                pill_bg: Color::Rgb(18, 26, 34),
161                badge_bg: Color::Rgb(23, 31, 40),
162                primary_action_bg: Color::Rgb(74, 146, 158),
163                primary_action_fg: Color::Rgb(20, 26, 34),
164                secondary_action_bg: Color::Rgb(34, 45, 56),
165                secondary_action_fg: Color::Rgb(206, 216, 226),
166                preview_bg: Color::Rgb(14, 20, 26),
167                overlay_bg: Color::Rgb(18, 25, 33),
168                divider: Color::Rgb(72, 88, 102),
169            },
170            ThemePreset::Light => Self {
171                text: Color::Rgb(24, 32, 40),
172                accent: Color::Rgb(34, 149, 132),
173                result_accent: Color::Rgb(168, 110, 36),
174                focus: Color::Rgb(90, 122, 142),
175                success: Color::Rgb(49, 148, 96),
176                info: Color::Rgb(70, 120, 150),
177                warning: Color::Rgb(160, 113, 25),
178                metadata: Color::Rgb(96, 108, 120),
179                border: Color::Rgb(180, 188, 196),
180                shell_border: Color::Rgb(204, 210, 218),
181                panel_focus_border: Color::Rgb(34, 149, 132),
182                error: Color::Rgb(199, 58, 71),
183                dim: Color::Rgb(130, 140, 150),
184                input_bg: Color::Rgb(243, 246, 250),
185                shell_bg: Color::Rgb(236, 240, 244),
186                sidebar_bg: Color::Rgb(242, 246, 250),
187                workspace_bg: Color::Rgb(250, 252, 255),
188                focus_bg: Color::Rgb(223, 233, 243),
189                panel_bg: Color::Rgb(248, 250, 252),
190                surface_raised: Color::Rgb(238, 243, 248),
191                header_bg: Color::Rgb(238, 242, 246),
192                selection_bg: Color::Rgb(223, 233, 243),
193                selection_fg: Color::Rgb(24, 32, 40),
194                selected_idle_bg: Color::Rgb(238, 243, 248),
195                selected_idle_fg: Color::Rgb(24, 32, 40),
196                pill_bg: Color::Rgb(235, 240, 245),
197                badge_bg: Color::Rgb(231, 237, 243),
198                primary_action_bg: Color::Rgb(52, 138, 128),
199                primary_action_fg: Color::Rgb(248, 250, 252),
200                secondary_action_bg: Color::Rgb(226, 233, 239),
201                secondary_action_fg: Color::Rgb(56, 70, 84),
202                preview_bg: Color::Rgb(238, 242, 246),
203                overlay_bg: Color::Rgb(244, 247, 251),
204                divider: Color::Rgb(200, 208, 216),
205            },
206        }
207    }
208}
209
210impl Default for Theme {
211    fn default() -> Self {
212        Theme::from_preset(ThemePreset::CalmDark)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::{Theme, ThemePreset};
219
220    #[test]
221    fn presets_keep_secondary_text_and_focus_roles_distinct() {
222        for preset in [
223            ThemePreset::CalmDark,
224            ThemePreset::HighContrastDark,
225            ThemePreset::Light,
226        ] {
227            let theme = Theme::from_preset(preset);
228
229            assert_ne!(theme.metadata, theme.dim);
230            assert_ne!(theme.focus, theme.panel_focus_border);
231        }
232    }
233
234    #[test]
235    fn calm_dark_uses_distinct_result_accent() {
236        let theme = Theme::from_preset(ThemePreset::CalmDark);
237
238        assert_ne!(theme.accent, theme.result_accent);
239    }
240
241    #[test]
242    fn presets_keep_primary_action_stronger_than_passive_hint_roles() {
243        for preset in [
244            ThemePreset::CalmDark,
245            ThemePreset::HighContrastDark,
246            ThemePreset::Light,
247        ] {
248            let theme = Theme::from_preset(preset);
249
250            assert_ne!(theme.primary_action_bg, theme.badge_bg);
251            assert_ne!(theme.primary_action_bg, theme.shell_bg);
252            assert_ne!(theme.secondary_action_fg, theme.dim);
253        }
254    }
255
256    #[test]
257    fn presets_keep_primary_action_distinct_from_success_feedback() {
258        for preset in [
259            ThemePreset::CalmDark,
260            ThemePreset::HighContrastDark,
261            ThemePreset::Light,
262        ] {
263            let theme = Theme::from_preset(preset);
264
265            assert_ne!(theme.primary_action_bg, theme.success);
266        }
267    }
268}
269
270/// Key bindings for main actions.
271#[derive(Debug, Clone)]
272#[non_exhaustive]
273pub struct Keymap {
274    /// Toggle help tab.
275    pub help: char,
276    /// Activate search.
277    pub search: char,
278}
279
280impl Default for Keymap {
281    fn default() -> Self {
282        Self {
283            help: '?',
284            search: '/',
285        }
286    }
287}
288
289/// Layout configuration.
290#[derive(Debug, Clone)]
291#[non_exhaustive]
292pub struct LayoutConfig {
293    /// Preferred sidebar width as a percentage of the terminal width.
294    ///
295    /// The rendered sidebar is clamped to fit the active layout so the main pane keeps a
296    /// usable width. Compact layouts clamp more aggressively than roomy layouts.
297    pub sidebar_ratio: u16,
298}
299
300impl Default for LayoutConfig {
301    fn default() -> Self {
302        Self { sidebar_ratio: 24 }
303    }
304}
305
306/// Top-level configuration for [`crate::Tui`] and [`crate::TuiApp`].
307///
308/// Most applications only need to customize the theme or `start_command`.
309#[derive(Debug, Clone, Default)]
310#[non_exhaustive]
311pub struct TuiConfig {
312    /// Theme configuration.
313    pub theme: Theme,
314    /// Key bindings.
315    pub keymap: Keymap,
316    /// Initial command path to select, using `::`-separated command names such as
317    /// `build::release`.
318    ///
319    /// Unknown paths leave the root command selected and show a non-error toast at startup.
320    pub start_command: Option<String>,
321    /// Layout configuration.
322    pub layout: LayoutConfig,
323}