Skip to content

Commit 47b056c

Browse files
committed
feat: add per-mode theme overrides
allow [theme.custom.light] and [theme.custom.dark] blocks so custom overrides can differ per resolved appearance when auto_switch flips. precedence: theme defaults, then [theme.custom], then the block for the active mode. configs without the new blocks resolve unchanged. refs #837
1 parent ffc4e26 commit 47b056c

7 files changed

Lines changed: 458 additions & 8 deletions

File tree

‎docs/next/website/src/content/docs/configuration.mdx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,23 @@ yellow = "#f9e2af"
277277

278278
Color values accept hex, named colors, `rgb(r,g,b)`, or reset aliases like `reset`, `default`, `none`, and `transparent`.
279279

280+
When `auto_switch` is enabled, optional light and dark subtables layer on top of the shared custom colors:
281+
282+
```toml
283+
[theme.custom]
284+
accent = "#89b4fa"
285+
286+
[theme.custom.light]
287+
panel_bg = "#eff1f5"
288+
text = "#4c4f69"
289+
290+
[theme.custom.dark]
291+
panel_bg = "#1e1e2e"
292+
text = "#cdd6f4"
293+
```
294+
295+
The active palette is applied in this order: built-in theme, `[theme.custom]`, then `[theme.custom.light]` or `[theme.custom.dark]`. Omitting the mode subtables preserves the existing shared override behavior.
296+
280297
## UI and sidebar
281298

282299
The sidebar is the main Herdr dashboard. Search `ui.` in the [Config reference](/docs/config-reference/) for sizing, collapsed mode, Agent panel ordering, mouse behavior, pane borders, and other presentation settings.

‎docs/next/website/src/data/config-reference.json‎

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,234 @@
171171
"type": "color",
172172
"default": "unset",
173173
"description": "Override the peach color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
174+
},
175+
{
176+
"key": "theme.custom.light.accent",
177+
"type": "color",
178+
"default": "unset",
179+
"description": "Override the accent color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
180+
},
181+
{
182+
"key": "theme.custom.light.panel_bg",
183+
"type": "color",
184+
"default": "unset",
185+
"description": "Override the panel_bg color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
186+
},
187+
{
188+
"key": "theme.custom.light.sidebar_bg",
189+
"type": "color",
190+
"default": "unset",
191+
"description": "Override the sidebar_bg color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
192+
},
193+
{
194+
"key": "theme.custom.light.active_row_bg",
195+
"type": "color",
196+
"default": "unset",
197+
"description": "Override the active_row_bg color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
198+
},
199+
{
200+
"key": "theme.custom.light.selection_bg",
201+
"type": "color",
202+
"default": "unset",
203+
"description": "Override the selection_bg color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
204+
},
205+
{
206+
"key": "theme.custom.light.surface0",
207+
"type": "color",
208+
"default": "unset",
209+
"description": "Override the surface0 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
210+
},
211+
{
212+
"key": "theme.custom.light.surface1",
213+
"type": "color",
214+
"default": "unset",
215+
"description": "Override the surface1 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
216+
},
217+
{
218+
"key": "theme.custom.light.surface_dim",
219+
"type": "color",
220+
"default": "unset",
221+
"description": "Override the surface_dim color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
222+
},
223+
{
224+
"key": "theme.custom.light.overlay0",
225+
"type": "color",
226+
"default": "unset",
227+
"description": "Override the overlay0 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
228+
},
229+
{
230+
"key": "theme.custom.light.overlay1",
231+
"type": "color",
232+
"default": "unset",
233+
"description": "Override the overlay1 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
234+
},
235+
{
236+
"key": "theme.custom.light.text",
237+
"type": "color",
238+
"default": "unset",
239+
"description": "Override the text color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
240+
},
241+
{
242+
"key": "theme.custom.light.subtext0",
243+
"type": "color",
244+
"default": "unset",
245+
"description": "Override the subtext0 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
246+
},
247+
{
248+
"key": "theme.custom.light.mauve",
249+
"type": "color",
250+
"default": "unset",
251+
"description": "Override the mauve color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
252+
},
253+
{
254+
"key": "theme.custom.light.green",
255+
"type": "color",
256+
"default": "unset",
257+
"description": "Override the green color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
258+
},
259+
{
260+
"key": "theme.custom.light.yellow",
261+
"type": "color",
262+
"default": "unset",
263+
"description": "Override the yellow color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
264+
},
265+
{
266+
"key": "theme.custom.light.red",
267+
"type": "color",
268+
"default": "unset",
269+
"description": "Override the red color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
270+
},
271+
{
272+
"key": "theme.custom.light.blue",
273+
"type": "color",
274+
"default": "unset",
275+
"description": "Override the blue color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
276+
},
277+
{
278+
"key": "theme.custom.light.teal",
279+
"type": "color",
280+
"default": "unset",
281+
"description": "Override the teal color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
282+
},
283+
{
284+
"key": "theme.custom.light.peach",
285+
"type": "color",
286+
"default": "unset",
287+
"description": "Override the peach color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
288+
},
289+
{
290+
"key": "theme.custom.dark.accent",
291+
"type": "color",
292+
"default": "unset",
293+
"description": "Override the accent color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
294+
},
295+
{
296+
"key": "theme.custom.dark.panel_bg",
297+
"type": "color",
298+
"default": "unset",
299+
"description": "Override the panel_bg color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
300+
},
301+
{
302+
"key": "theme.custom.dark.sidebar_bg",
303+
"type": "color",
304+
"default": "unset",
305+
"description": "Override the sidebar_bg color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
306+
},
307+
{
308+
"key": "theme.custom.dark.active_row_bg",
309+
"type": "color",
310+
"default": "unset",
311+
"description": "Override the active_row_bg color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
312+
},
313+
{
314+
"key": "theme.custom.dark.selection_bg",
315+
"type": "color",
316+
"default": "unset",
317+
"description": "Override the selection_bg color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
318+
},
319+
{
320+
"key": "theme.custom.dark.surface0",
321+
"type": "color",
322+
"default": "unset",
323+
"description": "Override the surface0 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
324+
},
325+
{
326+
"key": "theme.custom.dark.surface1",
327+
"type": "color",
328+
"default": "unset",
329+
"description": "Override the surface1 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
330+
},
331+
{
332+
"key": "theme.custom.dark.surface_dim",
333+
"type": "color",
334+
"default": "unset",
335+
"description": "Override the surface_dim color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
336+
},
337+
{
338+
"key": "theme.custom.dark.overlay0",
339+
"type": "color",
340+
"default": "unset",
341+
"description": "Override the overlay0 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
342+
},
343+
{
344+
"key": "theme.custom.dark.overlay1",
345+
"type": "color",
346+
"default": "unset",
347+
"description": "Override the overlay1 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
348+
},
349+
{
350+
"key": "theme.custom.dark.text",
351+
"type": "color",
352+
"default": "unset",
353+
"description": "Override the text color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
354+
},
355+
{
356+
"key": "theme.custom.dark.subtext0",
357+
"type": "color",
358+
"default": "unset",
359+
"description": "Override the subtext0 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
360+
},
361+
{
362+
"key": "theme.custom.dark.mauve",
363+
"type": "color",
364+
"default": "unset",
365+
"description": "Override the mauve color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
366+
},
367+
{
368+
"key": "theme.custom.dark.green",
369+
"type": "color",
370+
"default": "unset",
371+
"description": "Override the green color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
372+
},
373+
{
374+
"key": "theme.custom.dark.yellow",
375+
"type": "color",
376+
"default": "unset",
377+
"description": "Override the yellow color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
378+
},
379+
{
380+
"key": "theme.custom.dark.red",
381+
"type": "color",
382+
"default": "unset",
383+
"description": "Override the red color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
384+
},
385+
{
386+
"key": "theme.custom.dark.blue",
387+
"type": "color",
388+
"default": "unset",
389+
"description": "Override the blue color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
390+
},
391+
{
392+
"key": "theme.custom.dark.teal",
393+
"type": "color",
394+
"default": "unset",
395+
"description": "Override the teal color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
396+
},
397+
{
398+
"key": "theme.custom.dark.peach",
399+
"type": "color",
400+
"default": "unset",
401+
"description": "Override the peach color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
174402
}
175403
]
176404
},

‎src/app/mod.rs‎

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ fn resolve_palette_for_theme_name(
337337
name: &str,
338338
fallback_name: &str,
339339
runtime: &state::ThemeRuntimeConfig,
340+
mode_custom: Option<&crate::config::ModeThemeColors>,
340341
) -> state::Palette {
341342
let mut palette = state::Palette::from_name(name).unwrap_or_else(|| {
342343
tracing::warn!(
@@ -353,6 +354,9 @@ fn resolve_palette_for_theme_name(
353354
if let Some(accent) = &runtime.legacy_accent {
354355
palette.accent = crate::config::parse_color(accent);
355356
}
357+
if let Some(custom) = mode_custom {
358+
palette = palette.with_mode_overrides(custom);
359+
}
356360

357361
palette
358362
}
@@ -361,18 +365,30 @@ fn resolve_effective_theme(
361365
runtime: &state::ThemeRuntimeConfig,
362366
appearance: Option<crate::terminal_theme::HostAppearance>,
363367
) -> (state::Palette, String) {
364-
let (name, fallback) = if runtime.auto_switch {
368+
let (name, fallback, mode_custom) = if runtime.auto_switch {
365369
match appearance.unwrap_or(crate::terminal_theme::HostAppearance::Dark) {
366-
crate::terminal_theme::HostAppearance::Dark => (&runtime.dark_name, "catppuccin"),
367-
crate::terminal_theme::HostAppearance::Light => {
368-
(&runtime.light_name, "catppuccin-latte")
369-
}
370+
crate::terminal_theme::HostAppearance::Dark => (
371+
&runtime.dark_name,
372+
"catppuccin",
373+
runtime
374+
.custom
375+
.as_ref()
376+
.and_then(|custom| custom.dark.as_ref()),
377+
),
378+
crate::terminal_theme::HostAppearance::Light => (
379+
&runtime.light_name,
380+
"catppuccin-latte",
381+
runtime
382+
.custom
383+
.as_ref()
384+
.and_then(|custom| custom.light.as_ref()),
385+
),
370386
}
371387
} else {
372-
(&runtime.manual_name, "catppuccin")
388+
(&runtime.manual_name, "catppuccin", None)
373389
};
374390
(
375-
resolve_palette_for_theme_name(name, fallback, runtime),
391+
resolve_palette_for_theme_name(name, fallback, runtime, mode_custom),
376392
name.clone(),
377393
)
378394
}
@@ -2807,6 +2823,17 @@ mod tests {
28072823
fn theme_auto_switch_is_opt_in_and_preserves_manual_default() {
28082824
let mut config = Config::default();
28092825
config.theme.name = Some("tokyo-night".to_string());
2826+
config.theme.custom = Some(crate::config::CustomThemeColors {
2827+
light: Some(crate::config::ModeThemeColors {
2828+
accent: Some("#010203".to_string()),
2829+
..Default::default()
2830+
}),
2831+
dark: Some(crate::config::ModeThemeColors {
2832+
accent: Some("#040506".to_string()),
2833+
..Default::default()
2834+
}),
2835+
..Default::default()
2836+
});
28102837
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
28112838

28122839
let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
@@ -2854,6 +2881,55 @@ mod tests {
28542881
);
28552882
}
28562883

2884+
#[test]
2885+
fn theme_auto_switch_layers_active_mode_overrides_last() {
2886+
let mut config = Config::default();
2887+
config.theme.name = Some("gruvbox".to_string());
2888+
config.theme.auto_switch = true;
2889+
config.theme.custom = Some(crate::config::CustomThemeColors {
2890+
accent: Some("#010203".to_string()),
2891+
text: Some("#040506".to_string()),
2892+
light: Some(crate::config::ModeThemeColors {
2893+
accent: Some("#070809".to_string()),
2894+
..Default::default()
2895+
}),
2896+
dark: Some(crate::config::ModeThemeColors {
2897+
text: Some("#0a0b0c".to_string()),
2898+
sidebar_bg: Some("#0d0e0f".to_string()),
2899+
active_row_bg: Some("#101112".to_string()),
2900+
..Default::default()
2901+
}),
2902+
..Default::default()
2903+
});
2904+
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
2905+
let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
2906+
2907+
assert_eq!(
2908+
app.state.palette.accent,
2909+
ratatui::style::Color::Rgb(1, 2, 3)
2910+
);
2911+
assert_eq!(
2912+
app.state.palette.text,
2913+
ratatui::style::Color::Rgb(10, 11, 12)
2914+
);
2915+
assert_eq!(
2916+
app.state.palette.sidebar_bg,
2917+
ratatui::style::Color::Rgb(13, 14, 15)
2918+
);
2919+
assert_eq!(
2920+
app.state.palette.active_row_bg,
2921+
ratatui::style::Color::Rgb(16, 17, 18)
2922+
);
2923+
2924+
app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true);
2925+
2926+
assert_eq!(
2927+
app.state.palette.accent,
2928+
ratatui::style::Color::Rgb(7, 8, 9)
2929+
);
2930+
assert_eq!(app.state.palette.text, ratatui::style::Color::Rgb(4, 5, 6));
2931+
}
2932+
28572933
#[test]
28582934
fn inferred_background_appearance_does_not_override_explicit_report() {
28592935
let mut config = Config::default();

0 commit comments

Comments
 (0)