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
+3 -1
View File
@@ -46,10 +46,12 @@ Frontends borrow `snippet_selections()` for the active argument's end-exclusive
## Terminal lifecycle and layout ## Terminal lifecycle and layout
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, mouse capture, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. The terminal adapter owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam. Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam.
`terminal_colors` intercepts crossterm's key-shaped OSC 11/4 replies before editor dispatch, assembles a bounded RGB background/red/green triple, and preserves unrelated events and opaque bracketed paste. Queries never block input and start after painting, so syntax initialization does not spend their reply deadline. Startup and focus-regain queries replace cached diff tints atomically; an unanswered/partial query expires after 250 ms and removes stale tints rather than assuming a dark theme. Valid late replies are consumed without becoming editing commands. The frontend precomputes rounded RGB blends from the original background (12% row, 28% character), propagating them to active, parked, and newly created panes.
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned. The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling. Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
+2
View File
@@ -300,6 +300,8 @@ In split diffs, changed text rows use muted red backgrounds in Before and muted
Character-wise highlighting is enabled automatically within paired changed lines: only changed graphemes receive a stronger red/green background, while common text retains the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting. Character-wise highlighting is enabled automatically within paired changed lines: only changed graphemes receive a stronger red/green background, while common text retains the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting.
Diff backgrounds blend the terminal's default background with its red/green palette colors: a subtle 12% accent for changed rows and a stronger 28% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint and again on terminal focus regain; cached colors apply across editor panes. If you switch terminal themes while vamp remains focused, switch away and back to refresh the tints. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes.
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
+1
View File
@@ -12,5 +12,6 @@ pub mod ruby;
pub mod session; pub mod session;
mod snippet; mod snippet;
pub mod syntax; pub mod syntax;
mod terminal_colors;
pub mod tui; pub mod tui;
mod wrap; mod wrap;
+512
View File
@@ -0,0 +1,512 @@
use std::{
collections::VecDeque,
io::{self, Write},
time::{Duration, Instant},
};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyEventState, KeyModifiers};
const QUERY_TIMEOUT: Duration = Duration::from_millis(250);
const ESCAPE_TIMEOUT: Duration = Duration::from_millis(25);
const HEADERS: [&[u8]; 3] = [b"11;", b"4;1;", b"4;2;"];
const MAX_BODY: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ThemeColors {
pub background: [u8; 3],
pub red: [u8; 3],
pub green: [u8; 3],
}
struct Query {
deadline: Instant,
colors: [Option<[u8; 3]>; 3],
}
#[derive(Default)]
enum Capture {
#[default]
Idle,
Escape(Instant),
Report(Instant),
Terminator,
}
/// Adapts crossterm's key-shaped OSC replies without reading from stdin itself.
/// Only complete color reports are consumed; rejected candidates retain their
/// original events, including modifiers, kind, and state.
#[derive(Default)]
pub(crate) struct TerminalColors {
pub colors: Option<ThemeColors>,
query: Option<Query>,
capture: Capture,
original: Vec<Event>,
body: [u8; MAX_BODY],
len: usize,
ready: VecDeque<Event>,
}
impl TerminalColors {
pub fn query(&mut self, output: &mut impl Write, now: Instant) -> io::Result<()> {
self.expire(now);
self.query = None;
if let Err(error) = output
.write_all(b"\x1b]11;?\x1b\\\x1b]4;1;?;2;?\x1b\\")
.and_then(|()| output.flush())
{
self.colors = None;
return Err(error);
}
self.query = Some(Query {
deadline: now + QUERY_TIMEOUT,
colors: [None; 3],
});
Ok(())
}
pub fn push(&mut self, event: Event, now: Instant) {
self.expire(now);
self.accept(event, now);
}
pub fn expire(&mut self, now: Instant) {
if self
.query
.as_ref()
.is_some_and(|query| now >= query.deadline)
{
self.query = None;
self.colors = None;
}
let ambiguous_expired = match self.capture {
Capture::Escape(deadline) => now >= deadline,
Capture::Report(deadline) => {
now >= deadline
&& !HEADERS
.iter()
.any(|header| self.body[..self.len].starts_with(header))
}
_ => false,
};
if ambiguous_expired {
self.replay();
}
// Retain a bounded, recognized OSC candidate across query expiry: a
// late remainder must still be swallowed, not inserted into the editor.
// Neither late replies nor partial triples can update the palette.
}
pub fn pop(&mut self) -> Option<Event> {
self.ready.pop_front()
}
fn accept(&mut self, event: Event, now: Instant) {
let token = token(&event);
match self.capture {
Capture::Idle => match token {
Token::Start => {
self.original.push(event);
self.capture = Capture::Report(now + QUERY_TIMEOUT);
}
Token::Escape => {
self.original.push(event);
self.capture = Capture::Escape(now + ESCAPE_TIMEOUT);
}
_ => self.ready.push_back(event),
},
Capture::Escape(_) if matches!(token, Token::Byte(b']')) => {
self.original.push(event);
self.capture = Capture::Report(now + QUERY_TIMEOUT);
}
Capture::Report(_) if matches!(token, Token::Escape) => {
self.original.push(event);
self.capture = Capture::Terminator;
}
Capture::Report(_) if matches!(token, Token::End) => {
self.original.push(event);
self.finish();
}
Capture::Terminator if matches!(token, Token::Byte(b'\\')) => {
self.original.push(event);
self.finish();
}
Capture::Report(_) => {
if let Token::Byte(byte) = token
&& self.len < MAX_BODY
{
self.body[self.len] = byte;
if report_prefix(&self.body[..self.len + 1]) {
self.len += 1;
self.original.push(event);
return;
}
}
self.replay();
self.accept(event, now);
}
_ => {
self.replay();
self.accept(event, now);
}
}
}
fn finish(&mut self) {
let Some((slot, color)) = parse_report(&self.body[..self.len]) else {
self.replay();
return;
};
self.original.clear();
self.len = 0;
self.capture = Capture::Idle;
let Some(query) = self.query.as_mut() else {
return;
};
query.colors[slot] = Some(color);
if let [Some(background), Some(red), Some(green)] = query.colors {
self.colors = Some(ThemeColors {
background,
red,
green,
});
self.query = None;
}
}
fn replay(&mut self) {
self.ready.extend(self.original.drain(..));
self.len = 0;
self.capture = Capture::Idle;
}
}
#[derive(Clone, Copy)]
enum Token {
Start,
End,
Escape,
Byte(u8),
Other,
}
fn token(event: &Event) -> Token {
let Event::Key(key) = event else {
return Token::Other;
};
if key.kind != KeyEventKind::Press || key.state != KeyEventState::NONE {
return Token::Other;
}
match (key.code, key.modifiers) {
(KeyCode::Char(']'), KeyModifiers::ALT) => Token::Start,
(KeyCode::Char('\\'), KeyModifiers::ALT) | (KeyCode::Char('g'), KeyModifiers::CONTROL) => {
Token::End
}
(KeyCode::Esc, KeyModifiers::NONE) => Token::Escape,
(KeyCode::Char(ch), modifiers)
if ch.is_ascii()
&& (modifiers == KeyModifiers::NONE
|| (ch.is_ascii_uppercase() && modifiers == KeyModifiers::SHIFT)) =>
{
Token::Byte(ch as u8)
}
_ => Token::Other,
}
}
fn report_prefix(body: &[u8]) -> bool {
HEADERS.iter().any(|header| {
header.starts_with(body) || body.strip_prefix(*header).is_some_and(color_prefix)
})
}
fn color_prefix(color: &[u8]) -> bool {
if b"rgb:".starts_with(color) {
return true;
}
if let Some(hex) = color.strip_prefix(b"#") {
return hex.len() <= 6 && hex.iter().all(u8::is_ascii_hexdigit);
}
let Some(rgb) = color.strip_prefix(b"rgb:") else {
return false;
};
let mut components = rgb.split(|&byte| byte == b'/');
for index in 0..3 {
let Some(component) = components.next() else {
return true;
};
if component.len() > 4 || !component.iter().all(u8::is_ascii_hexdigit) {
return false;
}
if component.is_empty() && (index < 2 && components.clone().next().is_some()) {
return false;
}
}
components.next().is_none()
}
fn parse_report(body: &[u8]) -> Option<(usize, [u8; 3])> {
for (slot, header) in HEADERS.iter().enumerate() {
if let Some(color) = body.strip_prefix(*header) {
return parse_color(color).map(|color| (slot, color));
}
}
None
}
fn parse_color(color: &[u8]) -> Option<[u8; 3]> {
let mut result = [0; 3];
if let Some(hex) = color.strip_prefix(b"#") {
if hex.len() != 6 {
return None;
}
for (channel, component) in result.iter_mut().zip(hex.chunks_exact(2)) {
*channel = hex_value(component)? as u8;
}
return Some(result);
}
let mut components = color.strip_prefix(b"rgb:")?.split(|&byte| byte == b'/');
for channel in &mut result {
let component = components.next()?;
if component.is_empty() || component.len() > 4 {
return None;
}
let value = hex_value(component)?;
let maximum = (1_u32 << (component.len() * 4)) - 1;
*channel = ((value * 255 + maximum / 2) / maximum) as u8;
}
components.next().is_none().then_some(result)
}
fn hex_value(component: &[u8]) -> Option<u32> {
component.iter().try_fold(0, |value, &byte| {
(byte as char).to_digit(16).map(|digit| value * 16 + digit)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
fn key(code: KeyCode, modifiers: KeyModifiers) -> Event {
Event::Key(KeyEvent::new(code, modifiers))
}
fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) {
for ch in text.chars() {
let modifiers = if ch.is_ascii_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
collector.push(key(KeyCode::Char(ch), modifiers), now);
}
}
fn report(collector: &mut TerminalColors, body: &str, now: Instant) {
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now);
bytes(collector, body, now);
collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now);
}
fn drain(collector: &mut TerminalColors) -> Vec<Event> {
std::iter::from_fn(|| collector.pop()).collect()
}
#[test]
fn fragmented_triple_accepts_bel_and_both_st_encodings() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now);
collector.push(
key(KeyCode::Char(']'), KeyModifiers::NONE),
now + Duration::from_millis(1),
);
bytes(
&mut collector,
"11;rgb:0/12/345",
now + Duration::from_millis(2),
);
collector.push(
key(KeyCode::Esc, KeyModifiers::NONE),
now + Duration::from_millis(3),
);
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::NONE),
now + Duration::from_millis(4),
);
assert_eq!(collector.colors, None);
report(
&mut collector,
"4;2;rgb:f/ffff/ABCD",
now + Duration::from_millis(5),
);
collector.push(
key(KeyCode::Char(']'), KeyModifiers::ALT),
now + Duration::from_millis(6),
);
bytes(
&mut collector,
"4;1;#FF1234",
now + Duration::from_millis(7),
);
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::ALT),
now + Duration::from_millis(8),
);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: [0, 18, 52],
red: [255, 18, 52],
green: [255, 255, 171],
})
);
assert!(drain(&mut collector).is_empty());
}
#[test]
fn focus_refresh_atomically_replaces_light_and_dark_triples() {
let now = Instant::now();
let mut collector = TerminalColors::default();
let palettes = [
("11;#FDF6E3", [253, 246, 227]),
("11;rgb:0000/0000/0000", [0, 0, 0]),
];
for (cycle, (background, expected)) in palettes.into_iter().enumerate() {
let time = now + Duration::from_secs(cycle as u64);
let focus = Event::FocusGained;
collector.push(focus.clone(), time);
assert_eq!(collector.pop(), Some(focus));
let previous = collector.colors;
collector.query(&mut Vec::new(), time).unwrap();
report(&mut collector, "4;1;rgb:ff/00/00", time);
report(&mut collector, background, time);
assert_eq!(collector.colors, previous);
report(&mut collector, "4;2;rgb:00/ff/00", time);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: expected,
red: [255, 0, 0],
green: [0, 255, 0],
})
);
assert!(drain(&mut collector).is_empty());
}
}
#[test]
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
report(&mut collector, "11;#101010", now);
report(&mut collector, "4;1;#FF0000", now);
report(&mut collector, "4;2;#00FF00", now);
assert!(collector.colors.is_some());
let refresh = now + Duration::from_secs(1);
collector.query(&mut Vec::new(), refresh).unwrap();
report(&mut collector, "11;#EEEEEE", refresh);
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh);
bytes(&mut collector, "4;1;rgb:ffff/", refresh);
collector.expire(refresh + QUERY_TIMEOUT);
assert_eq!(collector.colors, None);
let late = refresh + Duration::from_secs(1);
bytes(&mut collector, "0000/0000", late);
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late);
collector.expire(late + Duration::from_secs(1));
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::NONE),
late + Duration::from_secs(1),
);
report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1));
assert_eq!(collector.colors, None);
assert!(drain(&mut collector).is_empty());
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
collector.push(typed.clone(), late + Duration::from_secs(2));
assert_eq!(collector.pop(), Some(typed));
}
#[test]
fn unrelated_events_and_rejected_candidates_replay_exactly() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
let events = vec![
key(KeyCode::Char(']'), KeyModifiers::ALT),
key(KeyCode::Char('4'), KeyModifiers::NONE),
key(KeyCode::Char(';'), KeyModifiers::NONE),
key(KeyCode::Char('3'), KeyModifiers::NONE),
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT),
Event::Key(KeyEvent::new_with_kind(
KeyCode::Char(']'),
KeyModifiers::ALT,
KeyEventKind::Release,
)),
Event::Key(KeyEvent::new_with_kind(
KeyCode::Char('A'),
KeyModifiers::SHIFT,
KeyEventKind::Repeat,
)),
Event::Key(KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::CAPS_LOCK,
}),
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 2,
row: 3,
modifiers: KeyModifiers::CONTROL,
}),
Event::FocusLost,
Event::Resize(80, 24),
key(KeyCode::Char(']'), KeyModifiers::ALT),
Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()),
Event::FocusGained,
];
for event in &events {
collector.push(event.clone(), now);
}
assert_eq!(drain(&mut collector), events);
assert_eq!(collector.colors, None);
}
#[test]
fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() {
let now = Instant::now();
let mut collector = TerminalColors::default();
for body in [
"11;rgb:12345/0/0",
"11;rgb:1//3",
"11;#12345",
"11;rgb:1/2/3/4",
] {
let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)];
expected.extend(
body.chars()
.map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)),
);
expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL));
for event in &expected {
collector.push(event.clone(), now);
}
assert_eq!(drain(&mut collector), expected);
}
let escape = key(KeyCode::Esc, KeyModifiers::NONE);
collector.push(escape.clone(), now);
assert_eq!(collector.pop(), None);
collector.expire(now + ESCAPE_TIMEOUT);
assert_eq!(collector.pop(), Some(escape));
assert_eq!(collector.pop(), None);
let start = key(KeyCode::Char(']'), KeyModifiers::ALT);
collector.push(start.clone(), now + Duration::from_secs(1));
collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT);
assert_eq!(collector.pop(), Some(start));
assert_eq!(collector.pop(), None);
}
}
+198 -129
View File
@@ -5,14 +5,15 @@ use std::{
io::{self, Stdout}, io::{self, Stdout},
panic::{self, AssertUnwindSafe, PanicHookInfo}, panic::{self, AssertUnwindSafe, PanicHookInfo},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Duration, time::{Duration, Instant},
}; };
use crossterm::{ use crossterm::{
cursor::Show, cursor::Show,
event::{ event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseEvent, MouseEventKind,
}, },
execute, execute,
terminal::{ terminal::{
@@ -43,6 +44,7 @@ use crate::{
}, },
session::EditorSession, session::EditorSession,
syntax::{SyntaxCache, SyntaxKind, SyntaxSpan}, syntax::{SyntaxCache, SyntaxKind, SyntaxSpan},
terminal_colors::{TerminalColors, ThemeColors},
wrap::{Segment, WrapCache, caret_segment}, wrap::{Segment, WrapCache, caret_segment},
}; };
@@ -67,6 +69,7 @@ fn restore_terminal() {
EndSynchronizedUpdate, EndSynchronizedUpdate,
DisableBracketedPaste, DisableBracketedPaste,
DisableMouseCapture, DisableMouseCapture,
DisableFocusChange,
LeaveAlternateScreen, LeaveAlternateScreen,
Show Show
); );
@@ -105,7 +108,8 @@ impl TerminalSession {
io::stdout(), io::stdout(),
EnterAlternateScreen, EnterAlternateScreen,
EnableBracketedPaste, EnableBracketedPaste,
EnableMouseCapture EnableMouseCapture,
EnableFocusChange
)?; )?;
body() body()
})); }));
@@ -249,6 +253,7 @@ struct BufferViewports {
pane: Option<usize>, pane: Option<usize>,
parked: BTreeMap<usize, PaneViewport>, parked: BTreeMap<usize, PaneViewport>,
rects: Vec<(usize, Rect)>, rects: Vec<(usize, Rect)>,
theme: Option<ThemeColors>,
} }
#[derive(Default)] #[derive(Default)]
@@ -274,6 +279,18 @@ fn terminal_rect(area: PaneRect) -> Rect {
} }
impl BufferViewports { 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) { fn activate(&mut self, id: usize, editor: &Editor) {
if self.pane == Some(id) { if self.pane == Some(id) {
return; return;
@@ -283,7 +300,7 @@ impl BufferViewports {
return; return;
} }
if !self.parked.contains_key(&id) { if !self.parked.contains_key(&id) {
let inherited = (|| { let mut inherited = (|| {
let buffer = editor.pane_buffer_id(id)?; let buffer = editor.pane_buffer_id(id)?;
let source = editor.pane_origin(id); let source = editor.pane_origin(id);
let (offsets, document, document_buffer, documents) = if source == self.pane { let (offsets, document, document_buffer, documents) = if source == self.pane {
@@ -325,6 +342,7 @@ impl BufferViewports {
}) })
})() })()
.unwrap_or_default(); .unwrap_or_default();
inherited.panels.diff_colors = self.panels.diff_colors;
self.parked.insert(id, inherited); self.parked.insert(id, inherited);
} }
let outgoing = self let outgoing = self
@@ -711,34 +729,43 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
let mut output: Stdout = io::stdout(); let mut output: Stdout = io::stdout();
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?; let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
let mut viewports = BufferViewports::default(); let mut viewports = BufferViewports::default();
let mut colors = TerminalColors::default();
let mut query_theme = true;
let mut redraw = true; let mut redraw = true;
let mut pending = None; let mut pending = None;
while !session.editor().should_quit() { while !session.editor().should_quit() {
redraw |= session.poll(); redraw |= session.poll();
colors.expire(Instant::now());
if viewports.theme != colors.colors {
viewports.set_theme(colors.colors);
redraw = true;
}
if redraw { if redraw {
draw_frame(&mut terminal, &mut output, |frame| { draw_frame(&mut terminal, &mut output, |frame| {
viewports.draw_session(frame, session) viewports.draw_session(frame, session)
})?; })?;
redraw = false; 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() { let input = if let Some(input) = pending.take() {
input input
} else { } else {
if !event::poll(Duration::from_millis(25))? { let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))?
else {
continue; continue;
} };
event::read()? input
}; };
match input { match input {
Event::Key(key) if key.kind != KeyEventKind::Release => { Event::Key(key) if key.kind != KeyEventKind::Release => {
if session.editor().mode() == Mode::Insert && typing_key(&key) { if session.editor().mode() == Mode::Insert && typing_key(&key) {
viewports.panels.follow_files(); viewports.panels.follow_files();
pending = typing_ready(session, key, || { pending = typing_ready(session, key, || {
if event::poll(Duration::ZERO)? { next_terminal_event(&mut colors, Duration::ZERO)
event::read().map(Some)
} else {
Ok(None)
}
})?; })?;
redraw = true; redraw = true;
} else if let Some(input) = translate(key) { } else if let Some(input) = translate(key) {
@@ -754,16 +781,13 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
} }
Event::Mouse(mouse) => { Event::Mouse(mouse) => {
let (changed, following) = viewports.scroll_ready(session, mouse, || { let (changed, following) = viewports.scroll_ready(session, mouse, || {
if event::poll(Duration::ZERO)? { next_terminal_event(&mut colors, Duration::ZERO)
event::read().map(Some)
} else {
Ok(None)
}
})?; })?;
redraw |= changed; redraw |= changed;
pending = following; pending = following;
} }
Event::Resize(_, _) => redraw = true, 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 { fn typing_key(key: &KeyEvent) -> bool {
key.kind != KeyEventKind::Release key.kind != KeyEventKind::Release
&& matches!(key.code, KeyCode::Char(ch) if !ch.is_control()) && matches!(key.code, KeyCode::Char(ch) if !ch.is_control())
@@ -2236,6 +2279,7 @@ fn draw_diff(
previous_text_widths.map(|widths| widths[0]), previous_text_widths.map(|widths| widths[0]),
), ),
wrapped, wrapped,
panels.diff_colors,
); );
text_widths[0] = text_area.width; text_widths[0] = text_area.width;
if let Some(editor) = if let Some(editor) =
@@ -2268,6 +2312,7 @@ fn draw_diff(
previous_text_widths.map(|widths| widths[1]), previous_text_widths.map(|widths| widths[1]),
), ),
wrapped, wrapped,
panels.diff_colors,
); );
text_widths[1] = text_area.width; text_widths[1] = text_area.width;
if let Some(editor) = if let Some(editor) =
@@ -2342,6 +2387,7 @@ fn draw_diff_pane(
semantic: &[SemanticToken], semantic: &[SemanticToken],
viewport: (usize, &mut usize, Option<u16>), viewport: (usize, &mut usize, Option<u16>),
wrapped: Option<&[WrappedDiffRow]>, wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors,
) -> Rect { ) -> Rect {
if area.width == 0 || area.height == 0 { if area.width == 0 || area.height == 0 {
return area; return area;
@@ -2435,11 +2481,7 @@ fn draw_diff_pane(
|| (!before && row.kind == RowKind::Added); || (!before && row.kind == RowKind::Added);
let background = let background =
if changed && !matches!(file.status, FileStatus::Added | FileStatus::Removed) { if changed && !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
if before { colors.row[usize::from(!before)]
Color::Rgb(64, 24, 24)
} else {
Color::Rgb(24, 48, 32)
}
} else { } else {
Color::Reset Color::Reset
}; };
@@ -2484,11 +2526,7 @@ fn draw_diff_pane(
paint_syntax_line(frame, source_text, syntax.line(index), text_area, left); paint_syntax_line(frame, source_text, syntax.line(index), text_area, left);
paint_semantic_line(frame, source_text, semantic, index, text_area, left); paint_semantic_line(frame, source_text, semantic, index, text_area, left);
if !matches!(file.status, FileStatus::Added | FileStatus::Removed) { if !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
let style = Style::default().bg(if before { let style = Style::default().bg(colors.character[usize::from(!before)]);
Color::Rgb(112, 40, 40)
} else {
Color::Rgb(32, 88, 48)
});
for change in &row.changes { for change in &row.changes {
let range = &change[usize::from(!before)]; let range = &change[usize::from(!before)];
if !range.is_empty() { 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)] #[derive(Default)]
struct WheelPanels { struct WheelPanels {
workspace_managed: bool, workspace_managed: bool,
@@ -2930,6 +3004,7 @@ struct WheelPanels {
files: Option<(Rect, usize, usize)>, files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>, manual_files: Option<FileWheelOffset>,
diff: Option<DiffGeometry>, diff: Option<DiffGeometry>,
diff_colors: DiffColors,
diff_syntax: Option<DiffSyntaxCache>, diff_syntax: Option<DiffSyntaxCache>,
previous_diff: Option<DiffGeometry>, previous_diff: Option<DiffGeometry>,
diff_wrap: Option<DiffWrapViewport>, diff_wrap: Option<DiffWrapViewport>,
@@ -4026,7 +4101,10 @@ mod tests {
#[test] #[test]
fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() { 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 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] { for status in [FileStatus::Added, FileStatus::Removed] {
let added = status == FileStatus::Added; let added = status == FileStatus::Added;
let mut file = deletion_file(); let mut file = deletion_file();
@@ -4067,6 +4145,12 @@ mod tests {
terminal.backend().buffer()[(present.x, present.y)].fg, terminal.backend().buffer()[(present.x, present.y)].fg,
Color::Cyan 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); let text = screen_text(&terminal);
assert!(text.contains(if added { assert!(text.contains(if added {
"ded content" "ded content"
@@ -4150,69 +4234,89 @@ mod tests {
.unwrap(); .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] #[test]
fn character_diff_backgrounds_preserve_common_unicode_and_syntax_across_viewports() { fn character_diff_backgrounds_preserve_common_unicode_and_syntax_across_viewports() {
let mut diff = diff_comparison(vec![syntax_diff_file( for (theme, row, character) in [
"changes.rs", (
"let 界e\u{301} = old + old;", Some(LIGHT_DIFF_THEME),
"let 界e\u{301} = new + new;", [Color::Rgb(245, 219, 217), Color::Rgb(222, 234, 218)],
)]); [Color::Rgb(239, 185, 187), Color::Rgb(186, 218, 190)],
diff.files_visible = false; ),
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); (
let mut panels = WheelPanels::default(); Some(DARK_DIFF_THEME),
render_diff(&mut terminal, &diff, &mut panels); [Color::Rgb(44, 29, 36), Color::Rgb(22, 44, 38)],
for (side, base, strong) in [ [Color::Rgb(76, 35, 44), Color::Rgb(26, 68, 50)],
(1, Color::Rgb(64, 24, 24), Color::Rgb(112, 40, 40)), ),
(2, Color::Rgb(24, 48, 32), Color::Rgb(32, 88, 48)), (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(); let buffer = terminal.backend().buffer();
for column in [0, 3, 4, 6, 8, 14, 19] { assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "e");
assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, base); assert_eq!(buffer[(after.x + 4, after.y + 1)].bg, character[1]);
} assert_eq!(buffer[(after.x + 7, after.y + 1)].bg, row[1]);
for column in [10, 11, 12, 16, 17, 18] { diff.word_wrap = true;
assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, strong); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap();
} render_diff(&mut terminal, &diff, &mut panels);
assert_eq!(buffer[(pane.x + 4, pane.y + 1)].fg, Color::Magenta); let after = panels.diff.as_ref().unwrap().regions[2].0;
assert_eq!(buffer[(pane.x + 8, pane.y + 1)].symbol(), "界"); let buffer = terminal.backend().buffer();
assert_eq!(buffer[(pane.x + 10, pane.y + 1)].symbol(), "e\u{301}"); let mut words = 0;
} for y in after.y + 1..after.bottom() - 1 {
diff.left_before = 11; for x in after.x + 4..after.right().saturating_sub(3) {
diff.left_after = 11; if buffer[(x, y)].symbol() == "n"
render_diff(&mut terminal, &diff, &mut panels); && buffer[(x + 1, y)].symbol() == "e"
let after = panels.diff.as_ref().unwrap().regions[2].0; && buffer[(x + 2, y)].symbol() == "w"
assert_eq!( {
terminal.backend().buffer()[(after.x + 4, after.y + 1)].symbol(), words += 1;
"e" for column in x..x + 3 {
); assert_eq!(buffer[(column, y)].bg, character[1]);
assert_eq!( assert_eq!(buffer[(column, y)].fg, Color::Reset);
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!(words, 2);
} }
assert_eq!(words, 2);
} }
#[test] #[test]
@@ -4307,20 +4411,11 @@ mod tests {
assert!(!text.contains(hidden), "unexpected {hidden}: {text}"); assert!(!text.contains(hidden), "unexpected {hidden}: {text}");
} }
let before_area = panels.diff.as_ref().unwrap().regions[1].0; 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(); let buffer = terminal.backend().buffer();
assert_eq!( assert_eq!(
buffer[(before_area.x + 5, before_area.y + 3)].fg, buffer[(before_area.x + 5, before_area.y + 3)].fg,
Color::Magenta 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::Char('['));
diff.handle(Input::Home); diff.handle(Input::Home);
render_diff(&mut terminal, &diff, &mut panels); 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() { fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
let mut diff = diff_comparison(vec![deletion_file()]); let mut diff = diff_comparison(vec![deletion_file()]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); 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); render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap(); let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0; 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 + 4, y)].fg, Color::Reset);
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-"); assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
for x in before.x + 1..before.right() - 1 { 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); assert_eq!(buffer[(x, y + 1)].bg, Color::Reset);
} }
let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1) let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1)
@@ -4395,7 +4492,10 @@ mod tests {
notice: None, notice: None,
}]); }]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); 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); render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap(); let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0; 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 + 2, y)].fg, Color::Green);
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset); assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset);
for x in after.x + 1..after.right() - 1 { 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 { if offset == 1 && x >= after.x + 4 {
assert_eq!(buffer[(x, y)].symbol(), " "); assert_eq!(buffer[(x, y)].symbol(), " ");
} }
@@ -4506,18 +4605,10 @@ mod tests {
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), " "); 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)].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)].symbol(), "e\u{301}");
assert_eq!(buffer[(before.x + 5, before.y + 1)].fg, Color::Green); 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)].symbol(), "界");
assert_eq!(buffer[(after.x + 4, after.y + 1)].fg, Color::Green); 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)].symbol(), "e\u{301}");
assert_eq!(buffer[(after.x + 6, after.y + 1)].fg, Color::Green); 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 + 4, after.y + 1)].symbol(), " ");
assert_eq!(buffer[(after.x + 8, 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)].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')); diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels); render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("After focus")); assert!(screen_text(&terminal).contains("After focus"));
@@ -5516,6 +5599,7 @@ mod tests {
assert!(!editor.diff_editing()); assert!(!editor.diff_editing());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default(); let mut views = BufferViewports::default();
views.set_theme(Some(LIGHT_DIFF_THEME));
let caret = render_native_diff(&mut terminal, &mut views, &mut editor); let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
let before = views.panels.diff.as_ref().unwrap().regions[1].0; let before = views.panels.diff.as_ref().unwrap().regions[1].0;
assert!(before.contains(caret)); assert!(before.contains(caret));
@@ -5527,10 +5611,6 @@ mod tests {
terminal.backend().buffer()[(caret.x, caret.y)].fg, terminal.backend().buffer()[(caret.x, caret.y)].fg,
Color::Magenta 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::Char('v'));
editor.handle(Input::Right); editor.handle(Input::Right);
render_native_diff(&mut terminal, &mut views, &mut editor); 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), display_column(&editor.lines()[editor.cursor().row], editor.cursor().column),
column column
); );
assert_eq!(
terminal.backend().buffer()[(switched.x, switched.y)].bg,
Color::Rgb(24, 48, 32)
);
editor.handle(Input::Right); editor.handle(Input::Right);
let moved = render_native_diff(&mut terminal, &mut views, &mut editor); let moved = render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t'));
@@ -5762,6 +5838,7 @@ mod tests {
.unwrap(); .unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default(); let mut views = BufferViewports::default();
views.set_theme(Some(LIGHT_DIFF_THEME));
for _ in 0..4 { for _ in 0..4 {
terminal terminal
.draw(|frame| views.draw(frame, &mut editor)) .draw(|frame| views.draw(frame, &mut editor))
@@ -5775,14 +5852,6 @@ mod tests {
assert_eq!(cell.symbol(), "v"); assert_eq!(cell.symbol(), "v");
assert_eq!(cell.fg, color); assert_eq!(cell.fg, color);
assert_eq!(cell.modifier.contains(ratatui::style::Modifier::BOLD), bold); 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')); editor.handle(Input::Char('\t'));
} }