fix: refresh cached diff colors for external theme updates
This commit is contained in:
@@ -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.
|
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.
|
`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
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::{
|
|||||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyEventState, KeyModifiers};
|
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyEventState, KeyModifiers};
|
||||||
|
|
||||||
const QUERY_TIMEOUT: Duration = Duration::from_millis(250);
|
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 ESCAPE_TIMEOUT: Duration = Duration::from_millis(25);
|
||||||
const HEADERS: [&[u8]; 3] = [b"11;", b"4;1;", b"4;2;"];
|
const HEADERS: [&[u8]; 3] = [b"11;", b"4;1;", b"4;2;"];
|
||||||
const MAX_BODY: usize = 32;
|
const MAX_BODY: usize = 32;
|
||||||
@@ -39,6 +40,7 @@ enum Capture {
|
|||||||
pub(crate) struct TerminalColors {
|
pub(crate) struct TerminalColors {
|
||||||
pub colors: Option<ThemeColors>,
|
pub colors: Option<ThemeColors>,
|
||||||
query: Option<Query>,
|
query: Option<Query>,
|
||||||
|
refresh_at: Option<Instant>,
|
||||||
capture: Capture,
|
capture: Capture,
|
||||||
original: Vec<Event>,
|
original: Vec<Event>,
|
||||||
body: [u8; MAX_BODY],
|
body: [u8; MAX_BODY],
|
||||||
@@ -47,8 +49,13 @@ pub(crate) struct TerminalColors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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<()> {
|
pub fn query(&mut self, output: &mut impl Write, now: Instant) -> io::Result<()> {
|
||||||
self.expire(now);
|
self.expire(now);
|
||||||
|
self.refresh_at = Some(now + REFRESH_INTERVAL);
|
||||||
self.query = None;
|
self.query = None;
|
||||||
if let Err(error) = output
|
if let Err(error) = output
|
||||||
.write_all(b"\x1b]11;?\x1b\\\x1b]4;1;?;2;?\x1b\\")
|
.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]
|
#[test]
|
||||||
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
|
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|||||||
+16
-2
@@ -269,6 +269,7 @@ struct BufferViewports {
|
|||||||
parked: BTreeMap<usize, PaneViewport>,
|
parked: BTreeMap<usize, PaneViewport>,
|
||||||
rects: Vec<(usize, Rect)>,
|
rects: Vec<(usize, Rect)>,
|
||||||
theme: Option<ThemeColors>,
|
theme: Option<ThemeColors>,
|
||||||
|
diff_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -550,6 +551,16 @@ impl BufferViewports {
|
|||||||
} else {
|
} else {
|
||||||
draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels);
|
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) {
|
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
|
||||||
@@ -785,10 +796,13 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
|||||||
redraw = false;
|
redraw = false;
|
||||||
}
|
}
|
||||||
// Start the reply deadline after painting, not before expensive syntax initialization.
|
// Start the reply deadline after painting, not before expensive syntax initialization.
|
||||||
if query_theme {
|
if query_theme || viewports.diff_visible {
|
||||||
colors.query(&mut output, Instant::now())?;
|
let now = Instant::now();
|
||||||
|
if query_theme || colors.refresh_due(now) {
|
||||||
|
colors.query(&mut output, now)?;
|
||||||
query_theme = false;
|
query_theme = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let input = if let Some(input) = pending.take() {
|
let input = if let Some(input) = pending.take() {
|
||||||
input
|
input
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user