From ceb6994a0dd03e2b3256e6bfd8f1de25d71c21c2 Mon Sep 17 00:00:00 2001 From: Mohamed Hamidi Date: Sat, 1 Aug 2026 15:07:45 +0200 Subject: [PATCH] fix: render OSC 4 palette overrides instead of forwarding the index --- src/ghostty/mod.rs | 15 ++++++ src/pane/terminal.rs | 106 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index c454194832..fa3bead5c8 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -824,6 +824,21 @@ impl Terminal { } } + pub fn default_palette(&self) -> Result<[RgbColor; 256], Error> { + let mut out = [ffi::GhosttyColorRgb::default(); 256]; + // SAFETY: self.raw is a live terminal handle, and out is exactly the + // 256-entry array this data kind writes. + unsafe { + ffi::ghostty_terminal_get( + self.raw, + ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT, + out.as_mut_ptr().cast(), + ) + .into_result()?; + } + Ok(out.map(Into::into)) + } + pub fn resize( &mut self, cols: u16, diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 3f8d8aeae9..fd0b0cb017 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1888,6 +1888,9 @@ impl GhosttyPaneTerminal { .and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground)); let resolved_fg = colors.map(|c| ghostty_color(c.foreground)); let resolved_bg = colors.map(|c| ghostty_color(c.background)); + let palette_overrides = colors + .zip(terminal.default_palette().ok()) + .and_then(|(colors, default)| PaletteOverrides::new(&colors.palette, &default)); let hide_kitty_placeholders = crate::kitty_graphics::is_enabled(); let mut row_iterator = match crate::ghostty::RowIterator::new() { @@ -1922,6 +1925,7 @@ impl GhosttyPaneTerminal { default_bg, resolved_fg, resolved_bg, + palette_overrides.as_ref(), ); let symbol = match ghostty_buffer_symbol_into( &cells, @@ -2147,6 +2151,9 @@ fn ghostty_collect_dirty_patch( .and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground)); let resolved_fg = colors.map(|c| ghostty_color(c.foreground)); let resolved_bg = colors.map(|c| ghostty_color(c.background)); + let palette_overrides = colors + .zip(terminal.default_palette().ok()) + .and_then(|(colors, default)| PaletteOverrides::new(&colors.palette, &default)); let hide_kitty_placeholders = crate::kitty_graphics::is_enabled(); let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else { @@ -2191,6 +2198,7 @@ fn ghostty_collect_dirty_patch( default_bg, resolved_fg, resolved_bg, + palette_overrides.as_ref(), ); let symbol = match ghostty_buffer_symbol_into( &cells, @@ -2705,11 +2713,12 @@ fn ghostty_cell_style( default_bg: Option, resolved_fg: Option, resolved_bg: Option, + palette_overrides: Option<&PaletteOverrides>, ) -> Style { let mut fg = basic .style .fg_color - .map(ghostty_cell_color) + .map(|color| ghostty_cell_color(color, palette_overrides)) .or_else(|| cells.fg_color().ok().flatten().map(ghostty_color)) .or(default_fg); let mut bg = cells @@ -2717,7 +2726,7 @@ fn ghostty_cell_style( .ok() .flatten() .or(basic.style.bg_color) - .map(ghostty_cell_color) + .map(|color| ghostty_cell_color(color, palette_overrides)) .or_else(|| cells.bg_color().ok().flatten().map(ghostty_color)) .or(default_bg); if basic.style.invisible { @@ -2739,7 +2748,11 @@ fn ghostty_cell_style( } let mut style = ghostty_default_style(fg, bg); - if let Some(underline_color) = basic.style.underline_color.map(ghostty_cell_color) { + if let Some(underline_color) = basic + .style + .underline_color + .map(|color| ghostty_cell_color(color, palette_overrides)) + { style = style.underline_color(underline_color); } let mut modifiers = Modifier::empty(); @@ -2972,9 +2985,44 @@ fn terminal_theme_color(color: crate::ghostty::RgbColor) -> crate::terminal_them } } -fn ghostty_cell_color(color: crate::ghostty::CellColor) -> Color { +// Palette entries the program redefined with OSC 4. Forwarding a palette index to the +// host makes it resolve against the host's own palette, discarding the redefinition. +// Only overridden entries become RGB; the rest stay indexed and keep following the +// host theme. None when nothing was redefined, which is the common case. +struct PaletteOverrides([Option; 256]); + +impl PaletteOverrides { + fn new( + active: &[crate::ghostty::RgbColor; 256], + default: &[crate::ghostty::RgbColor; 256], + ) -> Option { + let mut overrides = [None; 256]; + let mut any = false; + for (index, (active, default)) in active.iter().zip(default.iter()).enumerate() { + if active != default { + overrides[index] = Some(*active); + any = true; + } + } + any.then_some(Self(overrides)) + } + + fn get(&self, index: u8) -> Option { + self.0[usize::from(index)] + } +} + +fn ghostty_cell_color( + color: crate::ghostty::CellColor, + palette_overrides: Option<&PaletteOverrides>, +) -> Color { match color { - crate::ghostty::CellColor::Palette(index) => Color::Indexed(index), + crate::ghostty::CellColor::Palette(index) => { + match palette_overrides.and_then(|overrides| overrides.get(index)) { + Some(color) => ghostty_color(color), + None => Color::Indexed(index), + } + } crate::ghostty::CellColor::Rgb(color) => ghostty_color(color), } } @@ -3037,6 +3085,54 @@ mod tests { } } + fn rgb(r: u8, g: u8, b: u8) -> crate::ghostty::RgbColor { + crate::ghostty::RgbColor { r, g, b } + } + + #[test] + fn palette_overrides_are_none_without_an_osc4_write() { + let default = [rgb(1, 2, 3); 256]; + assert!(PaletteOverrides::new(&default, &default).is_none()); + } + + #[test] + fn redefined_palette_entries_render_as_rgb_and_others_stay_indexed() { + let default = [rgb(1, 2, 3); 256]; + let mut active = default; + active[18] = rgb(169, 177, 214); + let overrides = PaletteOverrides::new(&active, &default).expect("index 18 differs"); + + assert_eq!( + ghostty_cell_color(crate::ghostty::CellColor::Palette(18), Some(&overrides)), + Color::Rgb(169, 177, 214) + ); + // Untouched entries keep being forwarded, so they still follow the host theme. + assert_eq!( + ghostty_cell_color(crate::ghostty::CellColor::Palette(19), Some(&overrides)), + Color::Indexed(19) + ); + // ...and so does everything when the program never wrote a palette at all. + assert_eq!( + ghostty_cell_color(crate::ghostty::CellColor::Palette(18), None), + Color::Indexed(18) + ); + } + + #[test] + fn direct_rgb_cells_are_unaffected_by_palette_overrides() { + let default = [rgb(1, 2, 3); 256]; + let mut active = default; + active[18] = rgb(169, 177, 214); + let overrides = PaletteOverrides::new(&active, &default).expect("index 18 differs"); + assert_eq!( + ghostty_cell_color( + crate::ghostty::CellColor::Rgb(rgb(122, 162, 247)), + Some(&overrides) + ), + Color::Rgb(122, 162, 247) + ); + } + fn wide_text_cells(text: &str) -> [crate::ghostty::ScreenTextCell; 2] { [ crate::ghostty::ScreenTextCell {