diff --git a/docs/development.md b/docs/development.md index fe2fc0b..c64f7d6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -76,6 +76,7 @@ Diff styling checks cover independent snapshot language/shebang/injection detect - 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. +- Keyboard viewport scrolling coalesces up to 64 consecutive inputs for the same resolved scroll action into one redraw. Reversals, changes between row/half-page scrolling, and cursor motions end the batch so boundary clamping and caret reveal happen before the next action. Burst checks must include direction changes at the start/end of the buffer, not just throughput. - PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast. - 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. diff --git a/src/editor.rs b/src/editor.rs index 6e17ed0..432947e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -4457,6 +4457,10 @@ impl Editor { | Action::Right | Action::Down | Action::Up + | Action::ScrollUp + | Action::ScrollDown + | Action::ScrollHalfUp + | Action::ScrollHalfDown | Action::LineStart | Action::LineEnd | Action::DocumentStart @@ -4472,6 +4476,18 @@ impl Editor { ) } + pub(crate) fn viewport_scroll_action(&self, input: &Input) -> Option<&'static str> { + match self.bindings.get(&self.active.view.mode)?.get(input)? { + Binding::Action( + action @ (Action::ScrollUp + | Action::ScrollDown + | Action::ScrollHalfUp + | Action::ScrollHalfDown), + ) => Some(action.entry().name), + _ => None, + } + } + pub fn diff_editable(&self) -> bool { self.diff().is_some_and(|diff| { self.diff_buffer_focus() diff --git a/src/tui.rs b/src/tui.rs index 5bb3a50..325309d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1433,6 +1433,8 @@ fn keys_ready( mut next: impl FnMut() -> io::Result>, ) -> io::Result> { let mode = session.editor().mode(); + // Render before reversing/clamping a scroll or revealing a moved caret. + let scroll = session.editor().viewport_scroll_action(&input); for handled in 1..=64 { session.handle(input); if handled == 64 || session.editor().mode() != mode || session.editor().should_quit() { @@ -1442,6 +1444,7 @@ fn keys_ready( if let Some(Event::Key(key)) = &pending && let Some(next) = translate(*key) && batch_key(session.editor(), key, &next) + && session.editor().viewport_scroll_action(&next) == scroll { input = next; } else { @@ -17998,6 +18001,76 @@ end assert!(terminal.backend().cursor_visible()); } + #[test] + fn queued_scrolls_coalesce_but_preserve_reversals_and_motion_barriers() { + let directory = Directory::new(); + let text = (0..100) + .map(|row| format!("row-{row}\n")) + .collect::(); + for (first, repeated, barrier) in [ + ( + Input::Ctrl('u'), + 'u', + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + ), + ( + Input::Ctrl('d'), + 'd', + KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), + ), + ( + Input::Down, + 'j', + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + ), + ] { + let mut session = + EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let modifiers = if repeated == 'j' { + KeyModifiers::NONE + } else { + KeyModifiers::CONTROL + }; + let mut events = std::iter::repeat_n( + Event::Key(KeyEvent::new(KeyCode::Char(repeated), modifiers)), + 7, + ) + .chain([Event::Key(barrier)]); + assert_eq!( + keys_ready(&mut session, first, || Ok(events.next())).unwrap(), + Some(Event::Key(barrier)) + ); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + if repeated == 'u' { + assert_eq!(views.offsets[&session.editor().buffer_id()].0, 0); + session.handle(Input::Ctrl('d')); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!( + views.offsets[&session.editor().buffer_id()].0, + usize::from(views.document.geometry.unwrap().1.height) / 2 + ); + } else if repeated == 'd' { + assert_eq!(views.offsets[&session.editor().buffer_id()].0, 40); + session.handle(Input::Home); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.offsets[&session.editor().buffer_id()].0, 0); + } else { + assert_eq!(session.editor().cursor().row, 8); + } + } + } + #[test] fn queued_navigation_preserves_counted_motions_visual_ranges_and_remaps() { let directory = Directory::new();