fix: avoid redraw backlogs during rapid mouse scrolling

This commit is contained in:
2026-10-10 01:42:50 +02:00
parent d8e2425c65
commit 6152ae6d36
3 changed files with 102 additions and 6 deletions
+2
View File
@@ -47,6 +47,8 @@ The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a o
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.
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys. Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys.
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column. The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column.
+2 -1
View File
@@ -38,7 +38,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup. - Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during synchronized redraw, including original panic-payload propagation. - Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during synchronized redraw, including original panic-payload propagation.
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. - Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible.
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. - Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses. - Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. - Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state.
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets. - Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets.
@@ -48,6 +48,7 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
## Performance and process limits ## Performance and process limits
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance. - Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
- Rapid-wheel verification must replay bursts against the running editor and measure arrival at the final viewport after input stops, not just individual frame duration. Cheap frames can still accumulate a visible backlog if each queued report requires its own redraw.
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work. - Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing. - Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
+95 -2
View File
@@ -217,6 +217,27 @@ impl BufferViewports {
} }
true true
} }
fn scroll_ready(
&mut self,
session: &mut EditorSession,
mut mouse: MouseEvent,
mut next: impl FnMut() -> io::Result<Option<Event>>,
) -> io::Result<(bool, Option<Event>)> {
let mut redraw = false;
// Bound input work so drawing and background replies cannot starve.
for index in 0..64 {
redraw |= self.scroll(session, mouse);
if index == 63 {
break;
}
match next()? {
Some(Event::Mouse(report)) => mouse = report,
pending => return Ok((redraw, pending)),
}
}
Ok((redraw, None))
}
} }
fn draw_frame<W: io::Write>( fn draw_frame<W: io::Write>(
@@ -240,6 +261,7 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
Terminal::new(CrosstermBackend::new(io::stdout()))?; Terminal::new(CrosstermBackend::new(io::stdout()))?;
let mut viewports = BufferViewports::default(); let mut viewports = BufferViewports::default();
let mut redraw = true; let mut redraw = true;
let mut pending = None;
while !session.editor().should_quit() { while !session.editor().should_quit() {
redraw |= session.poll(); redraw |= session.poll();
if redraw { if redraw {
@@ -248,10 +270,15 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
})?; })?;
redraw = false; redraw = false;
} }
let input = if let Some(input) = pending.take() {
input
} else {
if !event::poll(Duration::from_millis(25))? { if !event::poll(Duration::from_millis(25))? {
continue; continue;
} }
match event::read()? { event::read()?
};
match input {
Event::Key(key) if key.kind != KeyEventKind::Release => { Event::Key(key) if key.kind != KeyEventKind::Release => {
if let Some(input) = translate(key) { if let Some(input) = translate(key) {
viewports.panels.follow_files(); viewports.panels.follow_files();
@@ -264,7 +291,17 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
session.handle(Input::Paste(text)); session.handle(Input::Paste(text));
redraw = true; redraw = true;
} }
Event::Mouse(mouse) => redraw |= viewports.scroll(session, mouse), Event::Mouse(mouse) => {
let (changed, following) = viewports.scroll_ready(session, mouse, || {
if event::poll(Duration::ZERO)? {
event::read().map(Some)
} else {
Ok(None)
}
})?;
redraw |= changed;
pending = following;
}
Event::Resize(_, _) => redraw = true, Event::Resize(_, _) => redraw = true,
_ => {} _ => {}
} }
@@ -4182,6 +4219,62 @@ end
assert!(terminal.backend().cursor_visible()); assert!(terminal.backend().cursor_visible());
} }
#[test]
fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() {
let directory = Directory::new();
let text = (0..24)
.map(|row| format!("row-{row:03}\n"))
.collect::<String>();
let path = directory.file("rows.txt", &text);
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap();
let mut viewports = BufferViewports::default();
let down = Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1));
let up = Event::Mouse(document_wheel(MouseEventKind::ScrollUp, 1, 1));
for barrier in [
Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)),
Event::Resize(40, 12),
] {
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
let mut queued = [
down.clone(),
up.clone(),
down.clone(),
barrier.clone(),
down.clone(),
]
.into_iter();
let (redraw, pending) = viewports
.scroll_ready(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 1, 1),
|| Ok(queued.next()),
)
.unwrap();
assert!(redraw);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-006")
);
assert_eq!(pending, Some(barrier));
assert_eq!(queued.next(), Some(down.clone()));
assert_eq!(session.editor().cursor(), Cursor::default());
assert_eq!(session.editor().mode(), Mode::Normal);
assert!(!terminal.backend().cursor_visible());
session.handle(Input::Up);
}
assert_eq!(session.editor().lines().join("\n"), text);
}
#[test] #[test]
fn no_op_navigation_restores_caret_following_through_core_dispatch() { fn no_op_navigation_restores_caret_following_through_core_dispatch() {
let directory = Directory::new(); let directory = Directory::new();