diff --git a/src/core/config.rs b/src/core/config.rs index 0dcbadb6..18611eb8 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -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, } +/// 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, + pub color1: Option, + pub color2: Option, + pub color3: Option, + pub color4: Option, + pub color5: Option, + pub color6: Option, + pub color7: Option, + pub color8: Option, + pub color9: Option, + pub color10: Option, + pub color11: Option, + pub color12: Option, + pub color13: Option, + pub color14: Option, + pub color15: Option, +} + +impl AnsiColors { + pub fn get(&self, index: usize) -> Option<&Option> { + 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) { + 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 diff --git a/src/ui/app.rs b/src/ui/app.rs index c7707f56..dbb60a68 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, + window: &mut Window, + cx: &mut Context, + ) { + let new = value.map(hsla_to_hex6); + let cfg = cx.global_mut::(); + 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, + ) { + { + let cfg = cx.global_mut::(); + 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::(); + 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, + window: &mut Window, + cx: &mut Context, + ) -> Vec<(AnsiColorKey, Entity)> { + let cfg = cx.global::(); + 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) { + 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. diff --git a/src/ui/settings.rs b/src/ui/settings.rs index f61cdcdd..e6369511 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -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 { + colors.get(self.0).unwrap_or(&colors.color0) + } + + pub(crate) fn set(self, colors: &mut AnsiColors, val: Option) { + 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)>, + /// One color picker per overridable terminal ANSI color (`ansi_colors.*`). + pub(crate) ansi_color_pickers: Vec<(AnsiColorKey, Entity)>, /// 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, pub(crate) _subs: Vec, @@ -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::().cursor_style; - let cursor_blink = cx.global::().cursor_blink; - let colors = cx.global::().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::(); + 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)>, + colors: &AnsiColors, + cx: &mut Context, + ) -> 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, + colors: &AnsiColors, + cx: &mut Context, + ) -> 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 diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 15423f78..e5c03af0 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -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); + } +}