feat(theme): support ANSI color overrides (#37)

This commit is contained in:
ayamir
2026-07-10 09:05:10 +08:00
committed by GitHub
parent 6b202bffc6
commit eeb8eed6ba
4 changed files with 420 additions and 18 deletions
+101
View File
@@ -39,6 +39,10 @@ pub struct Config {
pub theme_preset: String,
/// Optional per-color overrides layered on top of the active preset.
pub colors: Colors,
/// Optional ANSI 0-15 overrides layered on top of the active preset's
/// terminal palette. Each field maps to the familiar terminal `colorN`
/// slot; `None` keeps the preset's value.
pub ansi_colors: AnsiColors,
/// Optional keybinding overrides: action name (e.g. "NewTab") → keystroke
/// (e.g. "secondary-t", which is ⌘ on macOS and Ctrl elsewhere). Unknown
/// actions and unparseable keystrokes are ignored (with a warning) so a bad
@@ -233,6 +237,7 @@ impl Default for Config {
// depend on ui). Unknown ids fall back to it anyway.
theme_preset: "light".to_string(),
colors: Colors::default(),
ansi_colors: AnsiColors::default(),
keybindings: HashMap::new(),
// `None` → the platform default shell (login shell on Unix,
// PowerShell 7 / Windows PowerShell on Windows), chosen by the
@@ -284,6 +289,77 @@ pub struct Colors {
pub selection: Option<String>,
}
/// Optional overrides for terminal ANSI colors 0-15. These correspond to the
/// standard `color0`…`color15` slots that command-line tools address with SGR
/// foreground/background colors; unlike [`Colors`], they do not affect UI chrome
/// or the terminal's default foreground/background.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AnsiColors {
pub color0: Option<String>,
pub color1: Option<String>,
pub color2: Option<String>,
pub color3: Option<String>,
pub color4: Option<String>,
pub color5: Option<String>,
pub color6: Option<String>,
pub color7: Option<String>,
pub color8: Option<String>,
pub color9: Option<String>,
pub color10: Option<String>,
pub color11: Option<String>,
pub color12: Option<String>,
pub color13: Option<String>,
pub color14: Option<String>,
pub color15: Option<String>,
}
impl AnsiColors {
pub fn get(&self, index: usize) -> Option<&Option<String>> {
match index {
0 => Some(&self.color0),
1 => Some(&self.color1),
2 => Some(&self.color2),
3 => Some(&self.color3),
4 => Some(&self.color4),
5 => Some(&self.color5),
6 => Some(&self.color6),
7 => Some(&self.color7),
8 => Some(&self.color8),
9 => Some(&self.color9),
10 => Some(&self.color10),
11 => Some(&self.color11),
12 => Some(&self.color12),
13 => Some(&self.color13),
14 => Some(&self.color14),
15 => Some(&self.color15),
_ => None,
}
}
pub fn set(&mut self, index: usize, value: Option<String>) {
match index {
0 => self.color0 = value,
1 => self.color1 = value,
2 => self.color2 = value,
3 => self.color3 = value,
4 => self.color4 = value,
5 => self.color5 = value,
6 => self.color6 = value,
7 => self.color7 = value,
8 => self.color8 = value,
9 => self.color9 = value,
10 => self.color10 = value,
11 => self.color11 = value,
12 => self.color12 = value,
13 => self.color13 = value,
14 => self.color14 = value,
15 => self.color15 = value,
_ => {}
}
}
}
impl Global for Config {}
impl Config {
@@ -591,6 +667,31 @@ mod tests {
assert_eq!(color_or(&Some("#ffffff".to_string()), 0x000000), white);
}
#[test]
fn ansi_color_overrides_parse_and_default_independently() {
let cfg: Config =
serde_json::from_str(r##"{"ansi_colors":{"color0":"#575279","color15":"123456"}}"##)
.unwrap();
assert_eq!(
cfg.ansi_colors.get(0).and_then(|v| v.as_deref()),
Some("#575279")
);
assert_eq!(
cfg.ansi_colors.get(15).and_then(|v| v.as_deref()),
Some("123456")
);
assert!(cfg.ansi_colors.get(1).is_some_and(Option::is_none));
let mut colors = AnsiColors::default();
colors.set(0, Some("#111111".to_string()));
colors.set(15, Some("#eeeeee".to_string()));
assert_eq!(colors.get(0).and_then(|v| v.as_deref()), Some("#111111"));
assert_eq!(colors.get(15).and_then(|v| v.as_deref()), Some("#eeeeee"));
colors.set(0, None);
assert!(colors.get(0).is_some_and(Option::is_none));
assert!(colors.get(16).is_none());
}
#[test]
fn sanitize_clamps_degenerate_font_metrics() {
// A zero/negative/NaN font size or line height would round the row height
+104 -1
View File
@@ -19,7 +19,7 @@ use crate::daemon::protocol::ShellSpec;
use crate::terminal::view::{ChildExited, TerminalView};
use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView};
use crate::ui::pane::{CloseOutcome, Pane};
use crate::ui::settings::{ColorKey, SettingsSection, SettingsState};
use crate::ui::settings::{AnsiColorKey, ColorKey, SettingsSection, SettingsState};
use crate::ui::theme::{apply_theme, set_menus};
/// Global font-size bounds and step for the live zoom actions.
@@ -545,6 +545,61 @@ impl Tty7App {
cx.notify();
}
/// Apply a picked ANSI color override and re-paint the terminal palette live.
/// `None` clears the slot back to the active preset's ANSI value.
pub(crate) fn set_ansi_color_override(
&mut self,
key: AnsiColorKey,
value: Option<gpui::Hsla>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let new = value.map(hsla_to_hex6);
let cfg = cx.global_mut::<Config>();
if key.get(&cfg.ansi_colors) == &new {
return;
}
key.set(&mut cfg.ansi_colors, new);
cfg.save();
apply_theme(Some(window), cx);
cx.notify();
}
/// Clear one ANSI override back to the active preset default and sync the
/// picker swatch to that effective color.
pub(crate) fn reset_ansi_color_override(
&mut self,
key: AnsiColorKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
{
let cfg = cx.global_mut::<Config>();
if key.get(&cfg.ansi_colors).is_none() {
return;
}
key.set(&mut cfg.ansi_colors, None);
cfg.save();
}
apply_theme(Some(window), cx);
let default: gpui::Hsla = {
let cfg = cx.global::<Config>();
let p = crate::ui::presets::by_id(&cfg.theme_preset);
let (r, g, b) = p.ansi16[key.0];
gpui::rgb((r as u32) << 16 | (g as u32) << 8 | b as u32).into()
};
let picker = self.active_settings().and_then(|s| {
s.ansi_color_pickers
.iter()
.find(|(k, _)| *k == key)
.map(|(_, state)| state.clone())
});
if let Some(state) = picker {
state.update(cx, |s, cx| s.set_value(default, window, cx));
}
cx.notify();
}
/// Switch the cursor shape and repaint. The element reads `cursor_style` from
/// the global each frame, so we just persist and nudge every pane to redraw.
pub(crate) fn set_cursor_style(
@@ -1178,6 +1233,7 @@ impl Tty7App {
let (shell_program_input, shell_args_input, wd_path_input) =
self.build_shell_inputs(&mut subs, window, cx);
let color_pickers = self.build_color_pickers(&mut subs, window, cx);
let ansi_color_pickers = self.build_ansi_color_pickers(&mut subs, window, cx);
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
self.maximized = None;
@@ -1194,7 +1250,9 @@ impl Tty7App {
shell_args_input,
wd_path_input,
color_pickers,
ansi_color_pickers,
colors_expanded: false,
ansi_colors_expanded: false,
scroll_slider,
_subs: subs,
}),
@@ -1395,6 +1453,38 @@ impl Tty7App {
.collect()
}
/// One color picker per terminal ANSI color, seeded with the effective
/// current value (override if set, else active preset default).
fn build_ansi_color_pickers(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Vec<(AnsiColorKey, Entity<ColorPickerState>)> {
let cfg = cx.global::<Config>();
let theme_preset = cfg.theme_preset.clone();
let ansi_colors = cfg.ansi_colors.clone();
let preset = crate::ui::presets::by_id(&theme_preset);
AnsiColorKey::ALL
.iter()
.map(|&key| {
let (r, g, b) = preset.ansi16[key.0];
let default = (r as u32) << 16 | (g as u32) << 8 | b as u32;
let effective = color_or(key.get(&ansi_colors), default);
let state = cx.new(|cx| ColorPickerState::new(window, cx).default_value(effective));
subs.push(cx.subscribe_in(
&state,
window,
move |this, _picker, ev: &ColorPickerEvent, window, cx| {
let ColorPickerEvent::Change(value) = ev;
this.set_ansi_color_override(key, *value, window, cx);
},
));
(key, state)
})
.collect()
}
/// Mouse-scroll multiplier slider (0.5×–5×). Emits `Change` continuously as
/// the user drags; each writes + persists the multiplier.
fn build_scroll_slider(
@@ -1660,6 +1750,19 @@ impl Tty7App {
cx.notify();
}
/// Expand/collapse the ANSI Colors override group in the settings tab's
/// Appearance section (no-op elsewhere).
pub(crate) fn toggle_settings_ansi_colors(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self
.tabs
.get_mut(self.active)
.and_then(|t| t.settings.as_mut())
{
s.ansi_colors_expanded = !s.ansi_colors_expanded;
}
cx.notify();
}
/// Open `config.json` with the OS default handler (Settings → Keybindings).
/// A fresh install may never have saved yet, so write the current config
/// first — the button must not point at a missing file.
+163 -15
View File
@@ -22,7 +22,7 @@ use std::sync::Arc;
use gpui_component::color_picker::{ColorPicker, ColorPickerState};
use crate::core::config::{Colors, Config, CursorStyle, NewTabPosition, NotifyMode};
use crate::core::config::{AnsiColors, Colors, Config, CursorStyle, NewTabPosition, NotifyMode};
use crate::ui::app::{FONT_SIZE_STEP, LINE_HEIGHT_STEP, Tty7App};
use crate::ui::keymap::default_bindings;
use crate::ui::presets;
@@ -58,6 +58,49 @@ pub(crate) enum ColorKey {
Selection,
}
/// One terminal ANSI color slot (`ansi_colors.color0`…`color15`). These map to
/// SGR colors that terminal programs request explicitly, separate from the
/// default foreground/background.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) struct AnsiColorKey(pub(crate) usize);
impl AnsiColorKey {
pub(crate) const ALL: [AnsiColorKey; 16] = [
AnsiColorKey(0),
AnsiColorKey(1),
AnsiColorKey(2),
AnsiColorKey(3),
AnsiColorKey(4),
AnsiColorKey(5),
AnsiColorKey(6),
AnsiColorKey(7),
AnsiColorKey(8),
AnsiColorKey(9),
AnsiColorKey(10),
AnsiColorKey(11),
AnsiColorKey(12),
AnsiColorKey(13),
AnsiColorKey(14),
AnsiColorKey(15),
];
fn label(self) -> String {
format!("Color {}", self.0)
}
fn id(self) -> String {
format!("ansi-color{}", self.0)
}
pub(crate) fn get(self, colors: &AnsiColors) -> &Option<String> {
colors.get(self.0).unwrap_or(&colors.color0)
}
pub(crate) fn set(self, colors: &mut AnsiColors, val: Option<String>) {
colors.set(self.0, val);
}
}
impl ColorKey {
/// The nine keys, in the order they appear in the panel.
pub(crate) const ALL: [ColorKey; 9] = [
@@ -169,10 +212,14 @@ pub(crate) struct SettingsState {
/// order. Each is initialized to the effective color and emits a `Change` that
/// writes the override + re-applies the theme.
pub(crate) color_pickers: Vec<(ColorKey, Entity<ColorPickerState>)>,
/// One color picker per overridable terminal ANSI color (`ansi_colors.*`).
pub(crate) ansi_color_pickers: Vec<(AnsiColorKey, Entity<ColorPickerState>)>,
/// Whether the Colors override group (Appearance) is expanded. Collapsed by
/// default: its nine theme-internal slots are power-user surface, and open
/// they would dwarf the theme/typography settings everyone else came for.
pub(crate) colors_expanded: bool,
/// Whether the ANSI Colors override group (Appearance) is expanded.
pub(crate) ansi_colors_expanded: bool,
/// Mouse-scroll multiplier slider (Terminal section).
pub(crate) scroll_slider: Entity<SliderState>,
pub(crate) _subs: Vec<Subscription>,
@@ -434,20 +481,31 @@ impl Tty7App {
let hover_bg = theme.secondary.opacity(0.6);
let stepper_bg = theme.secondary.opacity(0.35);
let font_size = self.font_size;
let (font_select, font_bold_select, font_italic_select, color_pickers, colors_expanded) =
match self.active_settings() {
Some(s) => (
s.font_select.clone(),
s.font_bold_select.clone(),
s.font_italic_select.clone(),
s.color_pickers.clone(),
s.colors_expanded,
),
None => return div().into_any_element(),
};
let cursor_style = cx.global::<Config>().cursor_style;
let cursor_blink = cx.global::<Config>().cursor_blink;
let colors = cx.global::<Config>().colors.clone();
let (
font_select,
font_bold_select,
font_italic_select,
color_pickers,
colors_expanded,
ansi_color_pickers,
ansi_colors_expanded,
) = match self.active_settings() {
Some(s) => (
s.font_select.clone(),
s.font_bold_select.clone(),
s.font_italic_select.clone(),
s.color_pickers.clone(),
s.colors_expanded,
s.ansi_color_pickers.clone(),
s.ansi_colors_expanded,
),
None => return div().into_any_element(),
};
let cfg = cx.global::<Config>();
let cursor_style = cfg.cursor_style;
let cursor_blink = cfg.cursor_blink;
let colors = cfg.colors.clone();
let ansi_colors = cfg.ansi_colors.clone();
// Unified /value/+ stepper plus a quiet Reset.
let step = move |id: &'static str, glyph: &'static str, divider: bool| {
@@ -638,6 +696,13 @@ impl Tty7App {
))
.child(self.section_rule(cx))
.child(self.render_colors_group(colors_expanded, color_pickers, &colors, cx))
.child(self.section_rule(cx))
.child(self.render_ansi_colors_group(
ansi_colors_expanded,
ansi_color_pickers,
&ansi_colors,
cx,
))
.into_any_element()
}
@@ -725,6 +790,89 @@ impl Tty7App {
)
}
/// Terminal ANSI color overrides (`color0`…`color15`), separate from the
/// theme's default foreground/background. These are the slots CLI programs
/// address explicitly with SGR colors.
fn render_ansi_colors_group(
&self,
expanded: bool,
color_pickers: Vec<(AnsiColorKey, Entity<ColorPickerState>)>,
colors: &AnsiColors,
cx: &mut Context<Self>,
) -> AnyElement {
let muted_fg = cx.theme().muted_foreground;
let chevron = if expanded {
IconName::ChevronDown
} else {
IconName::ChevronRight
};
let header = v_flex()
.id("ansi-colors-toggle")
.gap_1()
.cursor_pointer()
.on_click(cx.listener(|this, _, _w, cx| this.toggle_settings_ansi_colors(cx)))
.child(
h_flex()
.items_center()
.gap_1p5()
.child(self.header_text("ANSI Colors", cx))
.child(Icon::new(chevron).small().text_color(muted_fg)),
)
.child(
div()
.text_xs()
.text_color(muted_fg)
.child("Advanced: override terminal color0-color15 slots used by CLI tools."),
);
Collapsible::new()
.open(expanded)
.child(header)
.content(
v_flex().mt_2().children(
color_pickers
.into_iter()
.map(|(key, state)| self.render_ansi_color_row(key, state, colors, cx)),
),
)
.into_any_element()
}
fn render_ansi_color_row(
&self,
key: AnsiColorKey,
state: Entity<ColorPickerState>,
colors: &AnsiColors,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let overridden = key.get(colors).is_some();
let control = h_flex()
.items_center()
.gap_3()
.w(px(240.))
.child(ColorPicker::new(&state).small())
.child(
Button::new(SharedString::from(format!("{}-reset", key.id())))
.label("Reset")
.ghost()
.small()
.disabled(!overridden)
.on_click(cx.listener(move |this, _, window, cx| {
this.reset_ansi_color_override(key, window, cx);
})),
)
.into_any_element();
self.settings_row(
key.label(),
if overridden {
"Custom"
} else {
"Theme default"
},
control,
cx,
)
}
/// Shell section: the program tty7 launches in each new terminal, plus its
/// launch arguments. Both apply to *newly spawned* panes/tabs — existing
/// shells keep running until closed. An empty program falls back to the
+52 -2
View File
@@ -7,7 +7,8 @@ use gpui::{App, Hsla, Menu, MenuItem, Pixels, Point, Window, point, px, rgb};
use gpui_component::{Theme, ThemeMode};
use crate::core::actions::*;
use crate::core::config::{Config, color_or};
use crate::core::config::{AnsiColors, Config, color_or, parse_hex_color};
use crate::terminal::palette::ActivePalette;
use crate::ui::presets;
/// The traffic-light origin, nudged down from the macOS default so the buttons
@@ -78,7 +79,8 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
// User `colors.*` overrides apply on top of the derived neutrals; a `None`
// field falls through to the theme.
let c = cfg.colors.clone();
let active = preset.active_palette();
let mut active = preset.active_palette();
apply_ansi_overrides(&mut active, &cfg.ansi_colors);
Theme::change(mode, window.as_deref_mut(), cx);
// Publish the terminal palette before borrowing the theme mutably.
@@ -167,6 +169,16 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
}
}
fn apply_ansi_overrides(active: &mut ActivePalette, overrides: &AnsiColors) {
for i in 0..16 {
if let Some(Some(hex)) = overrides.get(i) {
if let Some(rgb) = parse_hex_color(hex) {
active.ansi16[i] = crate::terminal::palette::hsla_to_rgb(rgb.into());
}
}
}
}
/// Apply `Config::mouse_hide_while_typing` to GPUI's cursor-hide policy: hide the
/// pointer while typing when on, never when off. Called at startup and whenever
/// the config changes (setter + hot-reload) so the switch takes effect live.
@@ -216,3 +228,41 @@ fn sync_native_appearance(dark: bool) {
#[cfg(not(target_os = "macos"))]
fn sync_native_appearance(_dark: bool) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ansi_overrides_replace_only_valid_slots() {
let mut active = presets::by_id("rose_pine_dawn").active_palette();
let original0 = active.ansi16[0];
let original1 = active.ansi16[1];
let original15 = active.ansi16[15];
let mut overrides = AnsiColors::default();
overrides.color0 = Some("#575279".to_string());
overrides.color1 = Some("not-a-color".to_string());
overrides.color15 = Some("123456".to_string());
apply_ansi_overrides(&mut active, &overrides);
assert_eq!(
(active.ansi16[0].r, active.ansi16[0].g, active.ansi16[0].b),
(0x57, 0x52, 0x79)
);
assert_eq!(
active.ansi16[1], original1,
"malformed overrides are ignored"
);
assert_eq!(
(
active.ansi16[15].r,
active.ansi16[15].g,
active.ansi16[15].b
),
(0x12, 0x34, 0x56)
);
assert_ne!(active.ansi16[0], original0);
assert_ne!(active.ansi16[15], original15);
}
}