perf: batch queued cursor navigation events
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
- [ ] jj conflict resolution with ui akin to the diff viewer
|
||||||
|
- [ ] improve the lsp hover floating window:
|
||||||
|
- it should be taller
|
||||||
|
- [ ] add `*` on word to highlights and searches in buffer in one action.
|
||||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ Mouse wheel input uses the last rendered document/panel rectangles for hit-testi
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
Already-ready Insert typing and directly bound Normal/Visual document motions also share a redraw, with the same 64-key bound and non-waiting readiness checks. The editor classifies motion bindings, including remaps and comparison text panes, so the TUI does not hard-code navigation keys. Each key still passes through session dispatch separately, preserving counts, intermediate column clamping, Visual ranges, and history. Mode changes end a batch; pending key/operator/seek prefixes, focused lists/popups, non-motion bindings, release reports, and non-key events remain redraw boundaries. No repeat timer or acceleration is added.
|
||||||
|
|
||||||
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. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add a blank cell and a marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. 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. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add a blank cell and a marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. The gutter is hidden when it would leave no text column.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
|
|||||||
- 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 frame construction, 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 frame construction, 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. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
- 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. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
||||||
- 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.
|
- 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.
|
||||||
|
- Queued-navigation checks cover counted ordered motions through short rows, Normal/Visual ranges, remapped press/repeat keys, rendered caret visibility, edit/layout/prefix and non-key barriers, and the 64-key fairness bound. Burst smoke must verify the final cursor position as well as frame count; batching reduces queued redraws without increasing the OS/terminal repeat rate.
|
||||||
- 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. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
|
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -2964,6 +2964,45 @@ impl Editor {
|
|||||||
self.active.view.mode
|
self.active.view.mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Direct document motions can share a frame without bypassing key dispatch.
|
||||||
|
pub(crate) fn can_batch_navigation(&self, input: &Input) -> bool {
|
||||||
|
matches!(self.active.view.mode, Mode::Normal | Mode::Visual)
|
||||||
|
&& self.document_actions_available()
|
||||||
|
&& self.startup_diagnostic.is_none()
|
||||||
|
&& self.hover.is_none()
|
||||||
|
&& self.palette.is_none()
|
||||||
|
&& self.completion.is_none()
|
||||||
|
&& !self
|
||||||
|
.file_picker
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|picker| picker.focused)
|
||||||
|
&& self.sequence_pending().is_none()
|
||||||
|
&& !self.g_pending
|
||||||
|
&& self.line_pending.is_none()
|
||||||
|
&& self.seek_pending.is_none()
|
||||||
|
&& matches!(
|
||||||
|
self.bindings
|
||||||
|
.get(&self.active.view.mode)
|
||||||
|
.and_then(|keys| keys.get(input)),
|
||||||
|
Some(Binding::Action(
|
||||||
|
Action::Left
|
||||||
|
| Action::Right
|
||||||
|
| Action::Down
|
||||||
|
| Action::Up
|
||||||
|
| Action::LineStart
|
||||||
|
| Action::LineEnd
|
||||||
|
| Action::DocumentStart
|
||||||
|
| Action::DocumentEnd
|
||||||
|
| Action::GoToLine
|
||||||
|
| Action::WordNext
|
||||||
|
| Action::WordPrevious
|
||||||
|
| Action::WordEnd
|
||||||
|
| Action::BigWordNext
|
||||||
|
| Action::BigWordPrevious
|
||||||
|
| Action::BigWordEnd
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn diff_editable(&self) -> bool {
|
pub fn diff_editable(&self) -> bool {
|
||||||
self.diff
|
self.diff
|
||||||
|
|||||||
+138
-22
@@ -846,15 +846,15 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
|
|||||||
};
|
};
|
||||||
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 let Some(input) = translate(key) {
|
||||||
viewports.panels.follow_files();
|
viewports.panels.follow_files();
|
||||||
pending = typing_ready(session, key, || {
|
if batch_key(session.editor(), &key, &input) {
|
||||||
|
pending = keys_ready(session, input, || {
|
||||||
next_terminal_event(&mut colors, Duration::ZERO)
|
next_terminal_event(&mut colors, Duration::ZERO)
|
||||||
})?;
|
})?;
|
||||||
redraw = true;
|
} else {
|
||||||
} else if let Some(input) = translate(key) {
|
|
||||||
viewports.panels.follow_files();
|
|
||||||
session.handle(input);
|
session.handle(input);
|
||||||
|
}
|
||||||
redraw = true;
|
redraw = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -913,24 +913,34 @@ fn typing_key(key: &KeyEvent) -> bool {
|
|||||||
| KeyModifiers::META,
|
| KeyModifiers::META,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn batch_key(editor: &Editor, key: &KeyEvent, input: &Input) -> bool {
|
||||||
|
if editor.mode() == Mode::Insert {
|
||||||
|
typing_key(key)
|
||||||
|
} else {
|
||||||
|
key.kind != KeyEventKind::Release && editor.can_batch_navigation(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Coalesce frames, not edits: bindings, snippets and history still see each key.
|
// Coalesce frames, not actions: bindings, selections and history still see each key.
|
||||||
fn typing_ready(
|
fn keys_ready(
|
||||||
session: &mut EditorSession,
|
session: &mut EditorSession,
|
||||||
mut key: KeyEvent,
|
mut input: Input,
|
||||||
mut next: impl FnMut() -> io::Result<Option<Event>>,
|
mut next: impl FnMut() -> io::Result<Option<Event>>,
|
||||||
) -> io::Result<Option<Event>> {
|
) -> io::Result<Option<Event>> {
|
||||||
|
let mode = session.editor().mode();
|
||||||
for handled in 1..=64 {
|
for handled in 1..=64 {
|
||||||
session.handle(translate(key).expect("plain typing key"));
|
session.handle(input);
|
||||||
if handled == 64
|
if handled == 64 || session.editor().mode() != mode || session.editor().should_quit() {
|
||||||
|| session.editor().mode() != Mode::Insert
|
|
||||||
|| session.editor().should_quit()
|
|
||||||
{
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
match next()? {
|
let pending = next()?;
|
||||||
Some(Event::Key(next)) if typing_key(&next) => key = next,
|
if let Some(Event::Key(key)) = &pending
|
||||||
pending => return Ok(pending),
|
&& let Some(next) = translate(*key)
|
||||||
|
&& batch_key(session.editor(), key, &next)
|
||||||
|
{
|
||||||
|
input = next;
|
||||||
|
} else {
|
||||||
|
return Ok(pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unreachable!()
|
unreachable!()
|
||||||
@@ -15291,6 +15301,117 @@ end
|
|||||||
assert!(terminal.backend().cursor_visible());
|
assert!(terminal.backend().cursor_visible());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_navigation_preserves_counted_motions_visual_ranges_and_remaps() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut rows = (0..100)
|
||||||
|
.map(|row| format!("row-{row:03}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
rows[1].clear();
|
||||||
|
let text = rows.join("\n");
|
||||||
|
let path = directory.file("rows.txt", &text);
|
||||||
|
for mode in [Mode::Normal, Mode::Visual] {
|
||||||
|
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
|
||||||
|
session
|
||||||
|
.editor_mut()
|
||||||
|
.bind("normal", "z", Some("core.down".into()))
|
||||||
|
.unwrap();
|
||||||
|
session
|
||||||
|
.editor_mut()
|
||||||
|
.bind("visual", "z", Some("core.down".into()))
|
||||||
|
.unwrap();
|
||||||
|
session.handle(Input::End);
|
||||||
|
if mode == Mode::Visual {
|
||||||
|
session.handle(Input::Char('v'));
|
||||||
|
}
|
||||||
|
session.handle(Input::Char('3'));
|
||||||
|
let key = |code| Event::Key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||||
|
let mut events = std::iter::once(key(KeyCode::Right))
|
||||||
|
.chain(std::iter::repeat_n(key(KeyCode::Down), 18))
|
||||||
|
.chain([
|
||||||
|
key(KeyCode::Up),
|
||||||
|
Event::Key(KeyEvent::new_with_kind(
|
||||||
|
KeyCode::Char('z'),
|
||||||
|
KeyModifiers::NONE,
|
||||||
|
KeyEventKind::Repeat,
|
||||||
|
)),
|
||||||
|
key(KeyCode::Char('i')),
|
||||||
|
key(KeyCode::Char('x')),
|
||||||
|
]);
|
||||||
|
let pending = keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap();
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 21, column: 1 });
|
||||||
|
assert_eq!(session.editor().mode(), mode);
|
||||||
|
assert_eq!(session.editor().lines(), rows);
|
||||||
|
if mode == Mode::Visual {
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().selections(),
|
||||||
|
vec![Selection {
|
||||||
|
start: Cursor { row: 0, column: 6 },
|
||||||
|
end: Cursor { row: 21, column: 2 },
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(pending, Some(key(KeyCode::Char('i'))));
|
||||||
|
assert_eq!(events.next(), Some(key(KeyCode::Char('x'))));
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 8)).unwrap();
|
||||||
|
let mut views = BufferViewports::default();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw_session(frame, &mut session))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains("row-021"));
|
||||||
|
assert!(terminal.backend().cursor_visible());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_navigation_stops_at_event_binding_and_batch_boundaries() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let text = (0..100)
|
||||||
|
.map(|row| format!("row-{row:03}\n"))
|
||||||
|
.collect::<String>();
|
||||||
|
let path = directory.file("rows.txt", &text);
|
||||||
|
let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||||
|
for barrier in [
|
||||||
|
Event::Resize(40, 12),
|
||||||
|
Event::Paste("literal".into()),
|
||||||
|
Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1)),
|
||||||
|
Event::Key(KeyEvent::new_with_kind(
|
||||||
|
KeyCode::Down,
|
||||||
|
KeyModifiers::NONE,
|
||||||
|
KeyEventKind::Release,
|
||||||
|
)),
|
||||||
|
Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
|
||||||
|
Event::Key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
|
||||||
|
Event::Key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
|
||||||
|
] {
|
||||||
|
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
|
||||||
|
session
|
||||||
|
.editor_mut()
|
||||||
|
.bind("normal", "j", Some("core.insert".into()))
|
||||||
|
.unwrap();
|
||||||
|
session
|
||||||
|
.editor_mut()
|
||||||
|
.bind("normal", "z", Some("core.pane.split_horizontal".into()))
|
||||||
|
.unwrap();
|
||||||
|
let mut events = [barrier.clone(), down.clone()].into_iter();
|
||||||
|
let pending = keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap();
|
||||||
|
assert_eq!(pending, Some(barrier));
|
||||||
|
assert_eq!(events.next(), Some(down.clone()));
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
|
||||||
|
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||||
|
assert_eq!(session.editor().pane_layout().len(), 1);
|
||||||
|
assert_eq!(session.editor().lines().join("\n"), text);
|
||||||
|
}
|
||||||
|
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||||
|
let mut events = std::iter::repeat_n(down, 80);
|
||||||
|
assert_eq!(
|
||||||
|
keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 64, column: 0 });
|
||||||
|
assert_eq!(events.count(), 17);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queued_typing_preserves_graphemes_history_and_mode_barriers() {
|
fn queued_typing_preserves_graphemes_history_and_mode_barriers() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
@@ -15303,12 +15424,7 @@ end
|
|||||||
Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
|
Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
|
||||||
]
|
]
|
||||||
.into_iter();
|
.into_iter();
|
||||||
let pending = typing_ready(
|
let pending = keys_ready(&mut session, Input::Char('a'), || Ok(events.next())).unwrap();
|
||||||
&mut session,
|
|
||||||
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
|
|
||||||
|| Ok(events.next()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(session.editor().lines(), ["a界seed"]);
|
assert_eq!(session.editor().lines(), ["a界seed"]);
|
||||||
assert_eq!(session.editor().mode(), Mode::Insert);
|
assert_eq!(session.editor().mode(), Mode::Insert);
|
||||||
assert!(matches!(pending, Some(Event::Key(key)) if key.code == KeyCode::Esc));
|
assert!(matches!(pending, Some(Event::Key(key)) if key.code == KeyCode::Esc));
|
||||||
|
|||||||
Reference in New Issue
Block a user