1use ratatui::style::Color;
2
3#[derive(Debug, Clone)]
8#[non_exhaustive]
9pub struct Theme {
10 pub text: Color,
12 pub accent: Color,
14 pub result_accent: Color,
16 pub focus: Color,
18 pub success: Color,
20 pub info: Color,
22 pub warning: Color,
24 pub metadata: Color,
26 pub border: Color,
28 pub shell_border: Color,
30 pub panel_focus_border: Color,
32 pub error: Color,
34 pub dim: Color,
36 pub input_bg: Color,
38 pub shell_bg: Color,
40 pub sidebar_bg: Color,
42 pub workspace_bg: Color,
44 pub focus_bg: Color,
46 pub panel_bg: Color,
48 pub surface_raised: Color,
50 pub header_bg: Color,
52 pub selection_bg: Color,
54 pub selection_fg: Color,
56 pub selected_idle_bg: Color,
58 pub selected_idle_fg: Color,
60 pub pill_bg: Color,
62 pub badge_bg: Color,
64 pub primary_action_bg: Color,
66 pub primary_action_fg: Color,
68 pub secondary_action_bg: Color,
70 pub secondary_action_fg: Color,
72 pub preview_bg: Color,
74 pub overlay_bg: Color,
76 pub divider: Color,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum ThemePreset {
84 CalmDark,
86 HighContrastDark,
88 Light,
90}
91
92impl Theme {
93 #[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#[derive(Debug, Clone)]
272#[non_exhaustive]
273pub struct Keymap {
274 pub help: char,
276 pub search: char,
278}
279
280impl Default for Keymap {
281 fn default() -> Self {
282 Self {
283 help: '?',
284 search: '/',
285 }
286 }
287}
288
289#[derive(Debug, Clone)]
291#[non_exhaustive]
292pub struct LayoutConfig {
293 pub sidebar_ratio: u16,
298}
299
300impl Default for LayoutConfig {
301 fn default() -> Self {
302 Self { sidebar_ratio: 24 }
303 }
304}
305
306#[derive(Debug, Clone, Default)]
310#[non_exhaustive]
311pub struct TuiConfig {
312 pub theme: Theme,
314 pub keymap: Keymap,
316 pub start_command: Option<String>,
321 pub layout: LayoutConfig,
323}