fix: refresh cached diff colors for external theme updates

This commit is contained in:
2026-10-11 21:53:28 +02:00
parent 5bf1f1df59
commit b41db31831
4 changed files with 75 additions and 5 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ The terminal adapter owns raw mode, alternate-screen state, bracketed paste, mou
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, color-scheme notifications, and fallback 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 (18% row, 38% character), propagating them to active, parked, and newly created panes.
`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, color-scheme notifications, and focus-regain queries replace cached diff tints atomically. While any rendered pane shows a diff, a one-second fallback probes for silent palette changes or missed notifications; ordinary editor views do not poll. Its injected-time deadline prevents overlapping periodic requests. 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 (18% row, 38% character), propagating them to active, parked, and newly created panes.
`vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications.
+1 -1
View File
@@ -302,7 +302,7 @@ 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.
Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint and whenever a subscribed DEC 2031 color-scheme notification arrives, so supported terminals update tints while the editor stays focused, including changes between two themes with the same light/dark preference. Focus-regain queries remain the fallback for terminals without notifications; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes.
Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. 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.
+56
View File
@@ -7,6 +7,7 @@ use std::{
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyEventState, KeyModifiers};
const QUERY_TIMEOUT: Duration = Duration::from_millis(250);
const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
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;
@@ -39,6 +40,7 @@ enum Capture {
pub(crate) struct TerminalColors {
pub colors: Option<ThemeColors>,
query: Option<Query>,
refresh_at: Option<Instant>,
capture: Capture,
original: Vec<Event>,
body: [u8; MAX_BODY],
@@ -47,8 +49,13 @@ pub(crate) struct TerminalColors {
}
impl TerminalColors {
pub fn refresh_due(&self, now: Instant) -> bool {
self.query.is_none() && self.refresh_at.is_none_or(|deadline| now >= deadline)
}
pub fn query(&mut self, output: &mut impl Write, now: Instant) -> io::Result<()> {
self.expire(now);
self.refresh_at = Some(now + REFRESH_INTERVAL);
self.query = None;
if let Err(error) = output
.write_all(b"\x1b]11;?\x1b\\\x1b]4;1;?;2;?\x1b\\")
@@ -397,6 +404,55 @@ mod tests {
}
}
#[test]
fn periodic_refresh_detects_silent_change_without_overlapping_queries() {
let now = Instant::now();
let mut collector = TerminalColors::default();
let mut output = Vec::new();
assert!(collector.refresh_due(now));
collector.query(&mut output, now).unwrap();
report(&mut collector, "11;#101010", now);
report(&mut collector, "4;1;#FF0000", now);
report(&mut collector, "4;2;#00FF00", now);
let previous = collector.colors;
assert!(previous.is_some());
// The terminal changes its palette without emitting a notification.
let refresh = now + Duration::from_secs(1);
assert!(!collector.refresh_due(refresh - Duration::from_nanos(1)));
assert!(collector.refresh_due(refresh));
collector.query(&mut output, refresh).unwrap();
report(&mut collector, "11;#EEEEEE", refresh);
report(&mut collector, "4;1;#AA1122", refresh);
assert_eq!(collector.colors, previous);
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
collector.push(typed.clone(), refresh);
assert_eq!(drain(&mut collector), vec![typed]);
// Even an overdue probe must wait until the incomplete query expires.
let retry = refresh + Duration::from_secs(1);
assert!(!collector.refresh_due(retry));
collector.expire(retry);
assert_eq!(collector.colors, None);
assert!(collector.refresh_due(retry));
collector.query(&mut output, retry).unwrap();
report(&mut collector, "11;#EEEEEE", retry);
report(&mut collector, "4;1;#AA1122", retry);
assert_eq!(collector.colors, None);
report(&mut collector, "4;2;#22BB33", retry);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: [238, 238, 238],
red: [170, 17, 34],
green: [34, 187, 51],
})
);
assert!(drain(&mut collector).is_empty());
assert!(!collector.refresh_due(retry + Duration::from_millis(999)));
assert!(collector.refresh_due(retry + Duration::from_secs(1)));
}
#[test]
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
let now = Instant::now();
+17 -3
View File
@@ -269,6 +269,7 @@ struct BufferViewports {
parked: BTreeMap<usize, PaneViewport>,
rects: Vec<(usize, Rect)>,
theme: Option<ThemeColors>,
diff_visible: bool,
}
#[derive(Default)]
@@ -550,6 +551,16 @@ impl BufferViewports {
} else {
draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels);
}
self.diff_visible = self
.panels
.diff
.iter()
.chain(
self.parked
.values()
.filter_map(|pane| pane.panels.diff.as_ref()),
)
.any(|geometry| geometry.viewport.is_some());
}
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
@@ -785,9 +796,12 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
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;
if query_theme || viewports.diff_visible {
let now = Instant::now();
if query_theme || colors.refresh_due(now) {
colors.query(&mut output, now)?;
query_theme = false;
}
}
let input = if let Some(input) = pending.take() {
input