fix: derive pane selection highlight colors

refs #298
This commit is contained in:
Ogulcan Celik 2026-06-01 21:22:09 +03:00
parent 6f9453a480
commit dde759853d
2 changed files with 187 additions and 2 deletions

View File

@ -12,6 +12,7 @@
### Fixed
- Pane input no longer waits behind the PTY actor's idle read poll, restoring responsive typing at quiet shell prompts. (#379)
- Pane apps that query OSC 4 ANSI palette colors now receive the active terminal palette response, so OpenCode and similar TUIs can enable system-theme behavior inside Herdr. (#387)
- Pane text selection now derives its highlight colors from the host terminal or active Herdr palette instead of forcing the theme's blue accent. (#298)
- Plain `herdr update` and remote binary replacement now ask before stopping running sessions, avoid protocol-heavy prompt text, and leave the current install untouched when the user chooses not to stop active pane processes. Explicit `--handoff` update flows try live handoff without a second handoff prompt.
## [0.6.6] - 2026-05-31

View File

@ -1,6 +1,6 @@
use ratatui::{
layout::Rect,
style::{Modifier, Style},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
@ -312,6 +312,7 @@ pub(super) fn render_panes(
info.inner_rect,
rt.scroll_metrics(),
&app.palette,
app.host_terminal_theme,
);
render_copy_mode_cursor(app, frame, info);
}
@ -350,15 +351,17 @@ fn render_selection_highlight(
inner: Rect,
scroll_metrics: Option<crate::pane::ScrollMetrics>,
p: &Palette,
host_theme: crate::terminal_theme::TerminalTheme,
) {
if let Some(sel) = selection {
if sel.is_visible() && sel.pane_id == pane_id {
let buf = frame.buffer_mut();
let style = automatic_selection_style(p, host_theme);
for y in 0..inner.height {
for x in 0..inner.width {
if sel.contains(y, x, scroll_metrics) {
let cell = &mut buf[(inner.x + x, inner.y + y)];
cell.set_style(Style::default().fg(panel_contrast_fg(p)).bg(p.blue));
cell.set_style(style);
}
}
}
@ -366,6 +369,101 @@ fn render_selection_highlight(
}
}
type Rgb = (u8, u8, u8);
fn automatic_selection_style(
p: &Palette,
host_theme: crate::terminal_theme::TerminalTheme,
) -> Style {
let bg = automatic_selection_bg(p, host_theme);
Style::reset().fg(selection_fg_for_bg(bg, p)).bg(bg)
}
fn automatic_selection_bg(p: &Palette, host_theme: crate::terminal_theme::TerminalTheme) -> Color {
let Some(background) = host_theme.background.map(terminal_theme_to_rgb) else {
return selection_palette_background(p);
};
let target = if relative_luminance(background) < 0.5 {
(255, 255, 255)
} else {
(0, 0, 0)
};
let selected = mix_rgb(background, target, 0.28);
Color::Rgb(selected.0, selected.1, selected.2)
}
fn selection_palette_background(p: &Palette) -> Color {
if p.panel_bg == Color::Reset {
p.surface_dim
} else {
p.panel_bg
}
}
fn terminal_theme_to_rgb(color: crate::terminal_theme::RgbColor) -> Rgb {
(color.r, color.g, color.b)
}
fn selection_fg_for_bg(bg: Color, p: &Palette) -> Color {
color_to_rgb(bg)
.map(|bg| {
if relative_luminance(bg) < 0.5 {
Color::White
} else {
Color::Black
}
})
.unwrap_or_else(|| panel_contrast_fg(p))
}
fn mix_rgb(base: Rgb, target: Rgb, amount: f32) -> Rgb {
fn channel(base: u8, target: u8, amount: f32) -> u8 {
(f32::from(base) + (f32::from(target) - f32::from(base)) * amount).round() as u8
}
(
channel(base.0, target.0, amount),
channel(base.1, target.1, amount),
channel(base.2, target.2, amount),
)
}
fn relative_luminance(color: Rgb) -> f32 {
fn channel(value: u8) -> f32 {
let value = f32::from(value) / 255.0;
if value <= 0.03928 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * channel(color.0) + 0.7152 * channel(color.1) + 0.0722 * channel(color.2)
}
fn color_to_rgb(color: Color) -> Option<Rgb> {
match color {
Color::Reset => None,
Color::Black => Some((0, 0, 0)),
Color::Red => Some((128, 0, 0)),
Color::Green => Some((0, 128, 0)),
Color::Yellow => Some((128, 128, 0)),
Color::Blue => Some((0, 0, 128)),
Color::Magenta => Some((128, 0, 128)),
Color::Cyan => Some((0, 128, 128)),
Color::Gray => Some((192, 192, 192)),
Color::DarkGray => Some((128, 128, 128)),
Color::LightRed => Some((255, 0, 0)),
Color::LightGreen => Some((0, 255, 0)),
Color::LightYellow => Some((255, 255, 0)),
Color::LightBlue => Some((0, 0, 255)),
Color::LightMagenta => Some((255, 0, 255)),
Color::LightCyan => Some((0, 255, 255)),
Color::White => Some((255, 255, 255)),
Color::Rgb(r, g, b) => Some((r, g, b)),
Color::Indexed(_) => None,
}
}
fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
let p = &app.palette;
let lines = vec![
@ -410,6 +508,8 @@ fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::PaneId;
use crate::selection::Selection;
use crate::terminal::TerminalRuntime;
use crate::workspace::Workspace;
@ -571,4 +671,88 @@ mod tests {
assert_eq!(info.scrollbar_rect, Some(Rect::new(49, 3, 1, 8)));
assert_eq!(info.inner_rect, Rect::new(10, 3, 39, 8));
}
#[test]
fn selection_highlight_uses_one_uniform_style() {
let palette = Palette::catppuccin();
let host_theme = crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 12,
g: 14,
b: 16,
}),
};
let expected_style = automatic_selection_style(&palette, host_theme);
let selection = Some(Selection::range(PaneId::from_raw(1), 0, 0, 2, None));
let backend = ratatui::backend::TestBackend::new(4, 1);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let buf = frame.buffer_mut();
buf[(0, 0)].set_style(
Style::default()
.fg(Color::Rgb(10, 220, 120))
.bg(Color::Black),
);
buf[(1, 0)].set_style(
Style::default()
.fg(Color::Rgb(220, 180, 40))
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
buf[(2, 0)].set_style(Style::default().fg(Color::Blue).bg(Color::Reset));
render_selection_highlight(
&selection,
frame,
PaneId::from_raw(1),
Rect::new(0, 0, 4, 1),
None,
&palette,
host_theme,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let first = buffer[(0, 0)].style();
let second = buffer[(1, 0)].style();
let third = buffer[(2, 0)].style();
assert_eq!(first.fg, expected_style.fg);
assert_eq!(second.fg, expected_style.fg);
assert_eq!(third.fg, expected_style.fg);
assert_eq!(first.bg, expected_style.bg);
assert_eq!(second.bg, expected_style.bg);
assert_eq!(third.bg, expected_style.bg);
assert_eq!(first.add_modifier, expected_style.add_modifier);
assert_eq!(second.add_modifier, expected_style.add_modifier);
assert_eq!(third.add_modifier, expected_style.add_modifier);
assert!(!second.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn automatic_selection_background_uses_host_background() {
let bg = automatic_selection_bg(
&Palette::terminal(),
crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 230,
g: 230,
b: 230,
}),
background: Some(crate::terminal_theme::RgbColor {
r: 12,
g: 14,
b: 16,
}),
},
);
let Color::Rgb(r, g, b) = bg else {
panic!("selection background should resolve to rgb");
};
assert!(relative_luminance((r, g, b)) > relative_luminance((12, 14, 16)));
}
}