fix: blend diff highlights with terminal theme colors

This commit is contained in:
2026-10-11 20:52:48 +02:00
parent a80f97309b
commit d6f6203fb6
5 changed files with 716 additions and 130 deletions
+198 -129
View File
@@ -5,14 +5,15 @@ use std::{
io::{self, Stdout},
panic::{self, AssertUnwindSafe, PanicHookInfo},
sync::{Arc, Mutex},
time::Duration,
time::{Duration, Instant},
};
use crossterm::{
cursor::Show,
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseEvent, MouseEventKind,
},
execute,
terminal::{
@@ -43,6 +44,7 @@ use crate::{
},
session::EditorSession,
syntax::{SyntaxCache, SyntaxKind, SyntaxSpan},
terminal_colors::{TerminalColors, ThemeColors},
wrap::{Segment, WrapCache, caret_segment},
};
@@ -67,6 +69,7 @@ fn restore_terminal() {
EndSynchronizedUpdate,
DisableBracketedPaste,
DisableMouseCapture,
DisableFocusChange,
LeaveAlternateScreen,
Show
);
@@ -105,7 +108,8 @@ impl TerminalSession {
io::stdout(),
EnterAlternateScreen,
EnableBracketedPaste,
EnableMouseCapture
EnableMouseCapture,
EnableFocusChange
)?;
body()
}));
@@ -249,6 +253,7 @@ struct BufferViewports {
pane: Option<usize>,
parked: BTreeMap<usize, PaneViewport>,
rects: Vec<(usize, Rect)>,
theme: Option<ThemeColors>,
}
#[derive(Default)]
@@ -274,6 +279,18 @@ fn terminal_rect(area: PaneRect) -> Rect {
}
impl BufferViewports {
fn set_theme(&mut self, theme: Option<ThemeColors>) {
if self.theme == theme {
return;
}
self.theme = theme;
let colors = DiffColors::new(theme);
self.panels.diff_colors = colors;
for pane in self.parked.values_mut() {
pane.panels.diff_colors = colors;
}
}
fn activate(&mut self, id: usize, editor: &Editor) {
if self.pane == Some(id) {
return;
@@ -283,7 +300,7 @@ impl BufferViewports {
return;
}
if !self.parked.contains_key(&id) {
let inherited = (|| {
let mut inherited = (|| {
let buffer = editor.pane_buffer_id(id)?;
let source = editor.pane_origin(id);
let (offsets, document, document_buffer, documents) = if source == self.pane {
@@ -325,6 +342,7 @@ impl BufferViewports {
})
})()
.unwrap_or_default();
inherited.panels.diff_colors = self.panels.diff_colors;
self.parked.insert(id, inherited);
}
let outgoing = self
@@ -711,34 +729,43 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
let mut output: Stdout = io::stdout();
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
let mut viewports = BufferViewports::default();
let mut colors = TerminalColors::default();
let mut query_theme = true;
let mut redraw = true;
let mut pending = None;
while !session.editor().should_quit() {
redraw |= session.poll();
colors.expire(Instant::now());
if viewports.theme != colors.colors {
viewports.set_theme(colors.colors);
redraw = true;
}
if redraw {
draw_frame(&mut terminal, &mut output, |frame| {
viewports.draw_session(frame, session)
})?;
redraw = false;
}
// Start the reply deadline after painting, not before expensive syntax initialization.
if query_theme {
colors.query(&mut output, Instant::now())?;
query_theme = false;
}
let input = if let Some(input) = pending.take() {
input
} else {
if !event::poll(Duration::from_millis(25))? {
let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))?
else {
continue;
}
event::read()?
};
input
};
match input {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if session.editor().mode() == Mode::Insert && typing_key(&key) {
viewports.panels.follow_files();
pending = typing_ready(session, key, || {
if event::poll(Duration::ZERO)? {
event::read().map(Some)
} else {
Ok(None)
}
next_terminal_event(&mut colors, Duration::ZERO)
})?;
redraw = true;
} else if let Some(input) = translate(key) {
@@ -754,16 +781,13 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
}
Event::Mouse(mouse) => {
let (changed, following) = viewports.scroll_ready(session, mouse, || {
if event::poll(Duration::ZERO)? {
event::read().map(Some)
} else {
Ok(None)
}
next_terminal_event(&mut colors, Duration::ZERO)
})?;
redraw |= changed;
pending = following;
}
Event::Resize(_, _) => redraw = true,
Event::FocusGained => query_theme = true,
_ => {}
}
}
@@ -771,6 +795,25 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
})
}
fn next_terminal_event(
colors: &mut TerminalColors,
mut timeout: Duration,
) -> io::Result<Option<Event>> {
colors.expire(Instant::now());
if let Some(input) = colors.pop() {
return Ok(Some(input));
}
while event::poll(timeout)? {
colors.push(event::read()?, Instant::now());
if let Some(input) = colors.pop() {
return Ok(Some(input));
}
timeout = Duration::ZERO;
}
colors.expire(Instant::now());
Ok(colors.pop())
}
fn typing_key(key: &KeyEvent) -> bool {
key.kind != KeyEventKind::Release
&& matches!(key.code, KeyCode::Char(ch) if !ch.is_control())
@@ -2236,6 +2279,7 @@ fn draw_diff(
previous_text_widths.map(|widths| widths[0]),
),
wrapped,
panels.diff_colors,
);
text_widths[0] = text_area.width;
if let Some(editor) =
@@ -2268,6 +2312,7 @@ fn draw_diff(
previous_text_widths.map(|widths| widths[1]),
),
wrapped,
panels.diff_colors,
);
text_widths[1] = text_area.width;
if let Some(editor) =
@@ -2342,6 +2387,7 @@ fn draw_diff_pane(
semantic: &[SemanticToken],
viewport: (usize, &mut usize, Option<u16>),
wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors,
) -> Rect {
if area.width == 0 || area.height == 0 {
return area;
@@ -2435,11 +2481,7 @@ fn draw_diff_pane(
|| (!before && row.kind == RowKind::Added);
let background =
if changed && !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
if before {
Color::Rgb(64, 24, 24)
} else {
Color::Rgb(24, 48, 32)
}
colors.row[usize::from(!before)]
} else {
Color::Reset
};
@@ -2484,11 +2526,7 @@ fn draw_diff_pane(
paint_syntax_line(frame, source_text, syntax.line(index), text_area, left);
paint_semantic_line(frame, source_text, semantic, index, text_area, left);
if !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
let style = Style::default().bg(if before {
Color::Rgb(112, 40, 40)
} else {
Color::Rgb(32, 88, 48)
});
let style = Style::default().bg(colors.character[usize::from(!before)]);
for change in &row.changes {
let range = &change[usize::from(!before)];
if !range.is_empty() {
@@ -2920,6 +2958,42 @@ fn diff_visible_rows(
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct DiffColors {
row: [Color; 2],
character: [Color; 2],
}
impl DiffColors {
fn new(theme: Option<ThemeColors>) -> Self {
let Some(theme) = theme else {
return Self {
row: [Color::Reset; 2],
character: [Color::Reset; 2],
};
};
let blend = |tint: [u8; 3], weight: u16| {
let channels = std::array::from_fn::<_, 3, _>(|index| {
((u16::from(theme.background[index]) * (100 - weight)
+ u16::from(tint[index]) * weight
+ 50)
/ 100) as u8
});
Color::Rgb(channels[0], channels[1], channels[2])
};
Self {
row: [blend(theme.red, 12), blend(theme.green, 12)],
character: [blend(theme.red, 28), blend(theme.green, 28)],
}
}
}
impl Default for DiffColors {
fn default() -> Self {
Self::new(None)
}
}
#[derive(Default)]
struct WheelPanels {
workspace_managed: bool,
@@ -2930,6 +3004,7 @@ struct WheelPanels {
files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>,
diff: Option<DiffGeometry>,
diff_colors: DiffColors,
diff_syntax: Option<DiffSyntaxCache>,
previous_diff: Option<DiffGeometry>,
diff_wrap: Option<DiffWrapViewport>,
@@ -4026,7 +4101,10 @@ mod tests {
#[test]
fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 12)).unwrap();
let mut panels = WheelPanels::default();
let mut panels = WheelPanels {
diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)),
..WheelPanels::default()
};
for status in [FileStatus::Added, FileStatus::Removed] {
let added = status == FileStatus::Added;
let mut file = deletion_file();
@@ -4067,6 +4145,12 @@ mod tests {
terminal.backend().buffer()[(present.x, present.y)].fg,
Color::Cyan
);
for x in present.x + 1..present.right() - 1 {
assert_eq!(
terminal.backend().buffer()[(x, present.y + 1)].bg,
Color::Reset
);
}
let text = screen_text(&terminal);
assert!(text.contains(if added {
"ded content"
@@ -4150,69 +4234,89 @@ mod tests {
.unwrap();
}
const LIGHT_DIFF_THEME: ThemeColors = ThemeColors {
background: [250, 245, 240],
red: [210, 30, 50],
green: [20, 150, 60],
};
const DARK_DIFF_THEME: ThemeColors = ThemeColors {
background: [20, 25, 30],
red: [220, 60, 80],
green: [40, 180, 100],
};
#[test]
fn character_diff_backgrounds_preserve_common_unicode_and_syntax_across_viewports() {
let mut diff = diff_comparison(vec![syntax_diff_file(
"changes.rs",
"let 界e\u{301} = old + old;",
"let 界e\u{301} = new + new;",
)]);
diff.files_visible = false;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
for (side, base, strong) in [
(1, Color::Rgb(64, 24, 24), Color::Rgb(112, 40, 40)),
(2, Color::Rgb(24, 48, 32), Color::Rgb(32, 88, 48)),
for (theme, row, character) in [
(
Some(LIGHT_DIFF_THEME),
[Color::Rgb(245, 219, 217), Color::Rgb(222, 234, 218)],
[Color::Rgb(239, 185, 187), Color::Rgb(186, 218, 190)],
),
(
Some(DARK_DIFF_THEME),
[Color::Rgb(44, 29, 36), Color::Rgb(22, 44, 38)],
[Color::Rgb(76, 35, 44), Color::Rgb(26, 68, 50)],
),
(None, [Color::Reset; 2], [Color::Reset; 2]),
] {
let pane = panels.diff.as_ref().unwrap().regions[side].0;
let mut diff = diff_comparison(vec![syntax_diff_file(
"changes.rs",
"let 界e\u{301} = old + old;",
"let 界e\u{301} = new + new;",
)]);
diff.files_visible = false;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels {
diff_colors: DiffColors::new(theme),
..WheelPanels::default()
};
render_diff(&mut terminal, &diff, &mut panels);
for index in 0..2 {
let pane = panels.diff.as_ref().unwrap().regions[index + 1].0;
let buffer = terminal.backend().buffer();
for column in [0, 3, 4, 6, 8, 14, 19] {
assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, row[index]);
}
for column in [10, 11, 12, 16, 17, 18] {
let cell = &buffer[(pane.x + 4 + column, pane.y + 1)];
assert_eq!(cell.bg, character[index]);
assert_eq!(cell.fg, Color::Reset);
}
assert_eq!(buffer[(pane.x + 4, pane.y + 1)].fg, Color::Magenta);
assert_eq!(buffer[(pane.x + 8, pane.y + 1)].symbol(), "界");
assert_eq!(buffer[(pane.x + 10, pane.y + 1)].symbol(), "e\u{301}");
}
diff.left_before = 11;
diff.left_after = 11;
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
let buffer = terminal.backend().buffer();
for column in [0, 3, 4, 6, 8, 14, 19] {
assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, base);
}
for column in [10, 11, 12, 16, 17, 18] {
assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, strong);
}
assert_eq!(buffer[(pane.x + 4, pane.y + 1)].fg, Color::Magenta);
assert_eq!(buffer[(pane.x + 8, pane.y + 1)].symbol(), "界");
assert_eq!(buffer[(pane.x + 10, pane.y + 1)].symbol(), "e\u{301}");
}
diff.left_before = 11;
diff.left_after = 11;
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].symbol(),
"e"
);
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].bg,
Color::Rgb(32, 88, 48)
);
assert_eq!(
terminal.backend().buffer()[(after.x + 7, after.y + 1)].bg,
Color::Rgb(24, 48, 32)
);
diff.word_wrap = true;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap();
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
let buffer = terminal.backend().buffer();
let mut words = 0;
for y in after.y + 1..after.bottom() - 1 {
for x in after.x + 4..after.right().saturating_sub(3) {
if buffer[(x, y)].symbol() == "n"
&& buffer[(x + 1, y)].symbol() == "e"
&& buffer[(x + 2, y)].symbol() == "w"
{
words += 1;
for column in x..x + 3 {
assert_eq!(buffer[(column, y)].bg, Color::Rgb(32, 88, 48));
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "e");
assert_eq!(buffer[(after.x + 4, after.y + 1)].bg, character[1]);
assert_eq!(buffer[(after.x + 7, after.y + 1)].bg, row[1]);
diff.word_wrap = true;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap();
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
let buffer = terminal.backend().buffer();
let mut words = 0;
for y in after.y + 1..after.bottom() - 1 {
for x in after.x + 4..after.right().saturating_sub(3) {
if buffer[(x, y)].symbol() == "n"
&& buffer[(x + 1, y)].symbol() == "e"
&& buffer[(x + 2, y)].symbol() == "w"
{
words += 1;
for column in x..x + 3 {
assert_eq!(buffer[(column, y)].bg, character[1]);
assert_eq!(buffer[(column, y)].fg, Color::Reset);
}
}
}
}
assert_eq!(words, 2);
}
assert_eq!(words, 2);
}
#[test]
@@ -4307,20 +4411,11 @@ mod tests {
assert!(!text.contains(hidden), "unexpected {hidden}: {text}");
}
let before_area = panels.diff.as_ref().unwrap().regions[1].0;
let after_area = panels.diff.as_ref().unwrap().regions[2].0;
let buffer = terminal.backend().buffer();
assert_eq!(
buffer[(before_area.x + 5, before_area.y + 3)].fg,
Color::Magenta
);
assert_eq!(
buffer[(before_area.x + 5, before_area.y + 3)].bg,
Color::Rgb(64, 24, 24)
);
assert_eq!(
buffer[(after_area.x + 5, after_area.y + 3)].bg,
Color::Rgb(24, 48, 32)
);
diff.handle(Input::Char('['));
diff.handle(Input::Home);
render_diff(&mut terminal, &diff, &mut panels);
@@ -4335,7 +4430,10 @@ mod tests {
fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
let mut diff = diff_comparison(vec![deletion_file()]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels::default();
let mut panels = WheelPanels {
diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)),
..WheelPanels::default()
};
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
@@ -4346,7 +4444,6 @@ mod tests {
assert_eq!(buffer[(before.x + 4, y)].fg, Color::Reset);
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
for x in before.x + 1..before.right() - 1 {
assert_eq!(buffer[(x, y)].bg, Color::Rgb(64, 24, 24));
assert_eq!(buffer[(x, y + 1)].bg, Color::Reset);
}
let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1)
@@ -4395,7 +4492,10 @@ mod tests {
notice: None,
}]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels::default();
let mut panels = WheelPanels {
diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)),
..WheelPanels::default()
};
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
@@ -4414,7 +4514,6 @@ mod tests {
assert_eq!(buffer[(after.x + 2, y)].fg, Color::Green);
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset);
for x in after.x + 1..after.right() - 1 {
assert_eq!(buffer[(x, y)].bg, Color::Rgb(24, 48, 32));
if offset == 1 && x >= after.x + 4 {
assert_eq!(buffer[(x, y)].symbol(), " ");
}
@@ -4506,18 +4605,10 @@ mod tests {
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), " ");
assert_eq!(buffer[(before.x + 4, before.y + 1)].fg, Color::Reset);
assert_eq!(
buffer[(before.x + 4, before.y + 1)].bg,
Color::Rgb(64, 24, 24)
);
assert_eq!(buffer[(before.x + 5, before.y + 1)].symbol(), "e\u{301}");
assert_eq!(buffer[(before.x + 5, before.y + 1)].fg, Color::Green);
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "界");
assert_eq!(buffer[(after.x + 4, after.y + 1)].fg, Color::Green);
assert_eq!(
buffer[(after.x + 4, after.y + 1)].bg,
Color::Rgb(24, 48, 32)
);
assert_eq!(buffer[(after.x + 6, after.y + 1)].symbol(), "e\u{301}");
assert_eq!(buffer[(after.x + 6, after.y + 1)].fg, Color::Green);
}
@@ -4562,14 +4653,6 @@ mod tests {
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " ");
assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界");
assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Reset);
assert_eq!(
buffer[(after.x + 8, after.y + 1)].bg,
Color::Rgb(24, 48, 32)
);
assert_eq!(
buffer[(before.x + 4, before.y + 1)].bg,
Color::Rgb(64, 24, 24)
);
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("After focus"));
@@ -5516,6 +5599,7 @@ mod tests {
assert!(!editor.diff_editing());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
views.set_theme(Some(LIGHT_DIFF_THEME));
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
let before = views.panels.diff.as_ref().unwrap().regions[1].0;
assert!(before.contains(caret));
@@ -5527,10 +5611,6 @@ mod tests {
terminal.backend().buffer()[(caret.x, caret.y)].fg,
Color::Magenta
);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].bg,
Color::Rgb(64, 24, 24)
);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
render_native_diff(&mut terminal, &mut views, &mut editor);
@@ -5594,10 +5674,6 @@ mod tests {
display_column(&editor.lines()[editor.cursor().row], editor.cursor().column),
column
);
assert_eq!(
terminal.backend().buffer()[(switched.x, switched.y)].bg,
Color::Rgb(24, 48, 32)
);
editor.handle(Input::Right);
let moved = render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Char('\t'));
@@ -5762,6 +5838,7 @@ mod tests {
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
views.set_theme(Some(LIGHT_DIFF_THEME));
for _ in 0..4 {
terminal
.draw(|frame| views.draw(frame, &mut editor))
@@ -5775,14 +5852,6 @@ mod tests {
assert_eq!(cell.symbol(), "v");
assert_eq!(cell.fg, color);
assert_eq!(cell.modifier.contains(ratatui::style::Modifier::BOLD), bold);
assert_eq!(
cell.bg,
if index == 1 {
Color::Rgb(64, 24, 24)
} else {
Color::Rgb(24, 48, 32)
}
);
}
editor.handle(Input::Char('\t'));
}