diff --git a/docs/architecture.md b/docs/architecture.md index 72fd81f..fdd5968 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,7 @@ One crate; no workspace or terminal-coordinate types in the editing backend: Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()` even without input, and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `poll()` reports whether background activity requires redrawing. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point. Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module. +The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness. Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session. Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy. Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned. @@ -38,12 +39,14 @@ Frontends inspect `hover()` for optional read-only content and logical-line/grap ## Terminal lifecycle and layout -The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. +The terminal adapter owns raw mode, alternate-screen state, bracketed paste, mouse capture, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. Redraws bracket Ratatui's buffer diff and final cursor placement with DEC synchronized updates (mode 2026), presenting complete frames on supporting terminals rather than exposing intermediate rows. The hardware cursor is hidden before painting even when that extension is unsupported. Each frame releases synchronization; terminal cleanup also releases it before leaving the alternate screen or invoking caller panic diagnostics. The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned. +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. + 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. diff --git a/docs/development.md b/docs/development.md index b189232..39f7fe1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,8 +36,9 @@ Rust tests live alongside their modules; native LSP integration checks are in [` - Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides. - Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location. - 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 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. +- 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. - 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. - 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. diff --git a/docs/editing.md b/docs/editing.md index ceb520a..a5265a5 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -107,9 +107,15 @@ Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-comma Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text. +## Mouse wheel scrolling + +Mouse-wheel up/down scrolls the document view three rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates and resizing preserve a wheel-scrolled view, clamped to the document. + +Wheel input over a hover popup or floating/docked file explorer scrolls that panel instead. Explorer scrolling preserves the selected file, filter, and keyboard focus; keyboard input resumes selection-following. An offscreen selected entry has no browsing cursor, while the filter input keeps its own cursor. Command palettes and startup notices consume wheel input without scrolling the background. Mouse clicks and dragging are not editing actions. + ## Buffers -One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID, adjusting them only as needed to keep the primary visible after resizing. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer. +One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID. Caret-following adjusts them as needed; mouse-wheel scrolling can temporarily detach the view from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer. | Command | Behavior | | --- | --- | diff --git a/src/editor.rs b/src/editor.rs index 641cbca..3b3afa5 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -889,6 +889,7 @@ pub struct Editor { next_buffer_id: usize, jumps: VecDeque, jump_index: usize, + caret_reveal_revision: u64, command_line: String, clipboard: Vec, clipboard_mode: ClipboardMode, @@ -931,6 +932,7 @@ impl Editor { next_buffer_id: 2, jumps: VecDeque::new(), jump_index: 0, + caret_reveal_revision: 0, command_line: String::new(), clipboard: Vec::new(), clipboard_mode: ClipboardMode::System, @@ -1329,6 +1331,7 @@ impl Editor { } fn place_navigation(&mut self, position: TextPosition) { + self.reveal_caret(); self.park_source(); self.active.mode = Mode::Normal; let row = position.row.min(self.active.lines.len() - 1); @@ -1422,6 +1425,14 @@ impl Editor { pub fn cursor(&self) -> Cursor { self.active.targets[self.active.primary].active } + /// Navigation requests caret following even when its logical position stays unchanged. + pub(crate) fn caret_reveal_revision(&self) -> u64 { + self.caret_reveal_revision + } + + fn reveal_caret(&mut self) { + self.caret_reveal_revision = self.caret_reveal_revision.wrapping_add(1); + } pub fn mode(&self) -> Mode { self.active.mode } @@ -2580,6 +2591,7 @@ impl Editor { } fn seek_search(&mut self, forward: bool, count: usize) { + self.reveal_caret(); let source = self.jump_location(); self.active.search_match = None; if self.active.search_query.is_empty() { @@ -2671,6 +2683,7 @@ impl Editor { } fn traverse_jumps(&mut self, forward: bool, count: usize) { + self.reveal_caret(); let departure = self.jump_location(); let mut destination = departure; let mut index = self.jump_index; @@ -2752,6 +2765,7 @@ impl Editor { fn switch_buffer(&mut self, id: usize) { if id == self.active.id { + self.reveal_caret(); self.park_source(); self.show_buffer(); } else if let Some(buffer) = self.parked.remove(&id) { @@ -3981,6 +3995,7 @@ impl Editor { } fn move_targets(&mut self, action: Action, count: usize) { + self.reveal_caret(); for index in 0..self.active.targets.len() { let mut cursor = self.active.targets[index].active; match action { diff --git a/src/tui.rs b/src/tui.rs index 93706a7..80f8705 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -11,8 +11,8 @@ use std::{ use crossterm::{ cursor::Show, event::{ - self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind, - KeyModifiers, + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, }, execute, terminal::{ @@ -32,7 +32,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ - editor::{Editor, Hover, Input, Mode, Palette, StartupDiagnostic}, + editor::{Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic}, files::{DockSide, FilePicker, FilePresentation, FileView}, plugin::{ Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier, @@ -61,6 +61,7 @@ fn restore_terminal() { io::stdout(), EndSynchronizedUpdate, DisableBracketedPaste, + DisableMouseCapture, LeaveAlternateScreen, Show ); @@ -95,7 +96,12 @@ impl TerminalSession { .lock() .unwrap_or_else(|error| error.into_inner()) .active = true; - execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?; + execute!( + io::stdout(), + EnterAlternateScreen, + EnableBracketedPaste, + EnableMouseCapture + )?; body() })); drop(session); @@ -122,10 +128,28 @@ impl Drop for TerminalSession { } } +#[derive(Default)] +struct DocumentViewport { + geometry: Option<(usize, Rect)>, + manual: Option, +} + +struct ManualViewport { + buffer_id: usize, + revision: u64, + caret_reveal_revision: u64, + mode: Mode, + cursor: Cursor, + carets: Vec, + selections: Vec, +} + #[derive(Default)] struct BufferViewports { offsets: BTreeMap, syntax: BTreeMap, + document: DocumentViewport, + panels: WheelPanels, } impl BufferViewports { @@ -134,7 +158,64 @@ impl BufferViewports { self.syntax.retain(|id, _| editor.has_buffer(*id)); let (top, left) = self.offsets.entry(editor.buffer_id()).or_default(); let syntax = self.syntax.entry(editor.buffer_id()).or_default(); - draw(frame, editor, top, left, syntax); + draw( + frame, + editor, + top, + left, + syntax, + &mut self.document, + &mut self.panels, + ); + } + + fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { + if !matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + return false; + } + if self.panels.scroll(session, mouse) { + return true; + } + let editor = session.editor(); + if editor.palette().is_some() || editor.startup_diagnostic().is_some() { + return true; + } + let Some((buffer_id, viewport)) = self.document.geometry else { + return false; + }; + if buffer_id != editor.buffer_id() + || viewport.width == 0 + || viewport.height == 0 + || !viewport.contains((mouse.column, mouse.row).into()) + { + return false; + } + let (top, _) = self.offsets.entry(buffer_id).or_default(); + let last_page = editor + .lines() + .len() + .saturating_sub(usize::from(viewport.height)); + *top = match mouse.kind { + MouseEventKind::ScrollUp => top.saturating_sub(3), + MouseEventKind::ScrollDown => top.saturating_add(3), + _ => unreachable!(), + } + .min(last_page); + if self.document.manual.is_none() { + self.document.manual = Some(ManualViewport { + buffer_id, + revision: editor.text_revision(), + caret_reveal_revision: editor.caret_reveal_revision(), + mode: editor.mode(), + cursor: editor.cursor(), + carets: editor.carets(), + selections: editor.selections(), + }); + } + true } } @@ -173,14 +254,17 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> { match event::read()? { Event::Key(key) if key.kind != KeyEventKind::Release => { if let Some(input) = translate(key) { + viewports.panels.follow_files(); session.handle(input); redraw = true; } } Event::Paste(text) => { + viewports.panels.follow_files(); session.handle(Input::Paste(text)); redraw = true; } + Event::Mouse(mouse) => redraw |= viewports.scroll(session, mouse), Event::Resize(_, _) => redraw = true, _ => {} } @@ -280,7 +364,11 @@ fn draw( top: &mut usize, left: &mut usize, syntax: &mut SyntaxCache, + document: &mut DocumentViewport, + panels: &mut WheelPanels, ) { + panels.begin_frame(); + document.geometry = None; syntax.update(editor.lines(), editor.path(), editor.text_revision()); let area = frame.area(); if area.width == 0 || area.height == 0 { @@ -316,17 +404,42 @@ fn draw( let height = usize::from(viewport.height); let width = usize::from(viewport.width); let cursor = editor.cursor(); + let carets = editor.carets(); + let selections = editor.selections(); + if document.manual.as_ref().is_some_and(|manual| { + manual.buffer_id != editor.buffer_id() + || manual.revision != editor.text_revision() + || manual.caret_reveal_revision != editor.caret_reveal_revision() + || manual.mode != editor.mode() + || manual.cursor != cursor + || manual.carets != carets + || manual.selections != selections + }) { + document.manual = None; + } + document.geometry = Some(( + editor.buffer_id(), + Rect::new( + gutter.x, + viewport.y, + gutter.width + viewport.width, + viewport.height, + ), + )); + *top = (*top).min(editor.lines().len().saturating_sub(height)); if height > 0 && width > 0 { - if cursor.row < *top { - *top = cursor.row; - } else if cursor.row >= top.saturating_add(height) { - *top = cursor.row + 1 - height; - } - let column = display_column(&editor.lines()[cursor.row], cursor.column); - if column < *left { - *left = column; - } else if column >= left.saturating_add(width) { - *left = column + 1 - width; + if document.manual.is_none() { + if cursor.row < *top { + *top = cursor.row; + } else if cursor.row >= top.saturating_add(height) { + *top = cursor.row + 1 - height; + } + let column = display_column(&editor.lines()[cursor.row], cursor.column); + if column < *left { + *left = column; + } else if column >= left.saturating_add(width) { + *left = column + 1 - width; + } } if gutter.width > 0 { let numbers: Vec = (*top..top.saturating_add(height).min(editor.lines().len())) @@ -519,7 +632,7 @@ fn draw( buffer[(viewport.x + (column - *left) as u16, y)].set_style(style); } } - for selection in editor.selections() { + for selection in &selections { let first = selection.start.row.max(*top); let last = selection.end.row.min(top.saturating_add(height - 1)); let style = Style::default().fg(Color::White).bg(Color::Blue); @@ -544,7 +657,7 @@ fn draw( } } } - for caret in editor.carets().into_iter().filter(|caret| *caret != cursor) { + for caret in carets.iter().filter(|caret| **caret != cursor) { if caret.row < *top || caret.row >= top.saturating_add(height) { continue; } @@ -629,10 +742,12 @@ fn draw( Paragraph::new(visible_line(&command, offset, prompt_width)), prompt_area, ); - frame.set_cursor_position(( - prompt_area.x + (command_width - offset) as u16, - prompt_area.y, - )); + if !editor.file_picker().is_some_and(|picker| picker.focused) { + frame.set_cursor_position(( + prompt_area.x + (command_width - offset) as u16, + prompt_area.y, + )); + } } else { let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() { let (_, _, label) = diagnostic_severity(diagnostic.severity); @@ -646,21 +761,27 @@ fn draw( Cow::Borrowed(editor.message()) }; frame.render_widget(Paragraph::new(message), prompt_area); - if height > 0 && width > 0 { + if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) { let column = display_column(&editor.lines()[cursor.row], cursor.column); - frame.set_cursor_position(( - viewport.x + (column - *left) as u16, - viewport.y + (cursor.row - *top) as u16, - )); + if cursor.row >= *top + && cursor.row < top.saturating_add(height) + && column >= *left + && column < left.saturating_add(width) + { + frame.set_cursor_position(( + viewport.x + (column - *left) as u16, + viewport.y + (cursor.row - *top) as u16, + )); + } } } if let Some(picker) = editor.file_picker() { if picker.presentation == FilePresentation::Floating { let panel = floating_panel(area); frame.render_widget(Clear, panel); - draw_file_list(frame, picker, panel, editor.file_navigation_hint()); + draw_file_list(frame, picker, panel, editor.file_navigation_hint(), panels); } else if let Some(panel) = dock { - draw_file_list(frame, picker, panel, editor.file_navigation_hint()); + draw_file_list(frame, picker, panel, editor.file_navigation_hint(), panels); } } if let Some(palette) = editor.palette() { @@ -672,7 +793,7 @@ fn draw( ); } if let Some(hover) = editor.hover() { - draw_hover(frame, hover, viewport); + draw_hover(frame, hover, viewport, panels); } if let Some(diagnostic) = editor.startup_diagnostic() { draw_startup_diagnostic(frame, diagnostic, area); @@ -741,7 +862,134 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar } } -fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect) { +#[derive(Default)] +struct WheelPanels { + hover: Option<(Rect, usize)>, + files: Option<(Rect, usize, usize)>, + manual_files: Option, +} + +struct FileWheelOffset { + top: usize, + root: std::path::PathBuf, + query: String, + state: (usize, usize, FilePresentation, FileView, bool, bool), +} + +impl FileWheelOffset { + fn state(picker: &FilePicker) -> (usize, usize, FilePresentation, FileView, bool, bool) { + ( + picker.selected, + picker.entries().len(), + picker.presentation, + picker.view, + picker.searching, + picker.focused, + ) + } + + fn matches(&self, picker: &FilePicker) -> bool { + self.state == Self::state(picker) && self.root == picker.root && self.query == picker.query + } +} + +impl WheelPanels { + fn begin_frame(&mut self) { + if self.files.is_none() { + self.manual_files = None; + } + self.hover = None; + self.files = None; + } + + // Keyboard/paste may change the catalog without changing its length. + fn follow_files(&mut self) { + self.manual_files = None; + } + + fn file_top(&mut self, picker: &FilePicker, panel: Rect, height: usize) -> usize { + if self + .manual_files + .as_ref() + .is_some_and(|offset| !offset.matches(picker)) + { + self.manual_files = None; + } + let count = picker.entries().len(); + let following = picker + .selected + .min(count.saturating_sub(1)) + .saturating_sub(height.saturating_sub(1)); + let top = self.manual_files.as_mut().map_or(following, |offset| { + offset.top = offset.top.min(count.saturating_sub(height.max(1))); + offset.top + }); + self.files = Some((panel, height, top)); + top + } + + fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { + let down = match mouse.kind { + MouseEventKind::ScrollDown => true, + MouseEventKind::ScrollUp => false, + _ => return false, + }; + let editor = session.editor(); + if editor.palette().is_some() || editor.startup_diagnostic().is_some() { + return true; + } + let position = ratatui::layout::Position::new(mouse.column, mouse.row); + if let Some(hover) = editor.hover() + && let Some((panel, height)) = self.hover + && panel.contains(position) + { + if height > 0 { + let top = hover.top; + let target = if down { + top.saturating_add(3) + } else { + top.saturating_sub(3) + } + .min(hover.lines().len().saturating_sub(height)); + let input = if target < top { Input::Up } else { Input::Down }; + for _ in 0..top.abs_diff(target) { + session.handle(input.clone()); + } + } + return true; + } + if let Some(picker) = editor.file_picker() + && let Some((panel, height, drawn_top)) = self.files + && panel.contains(position) + { + if height > 0 { + if self + .manual_files + .as_ref() + .is_some_and(|offset| !offset.matches(picker)) + { + self.manual_files = None; + } + let offset = self.manual_files.get_or_insert_with(|| FileWheelOffset { + top: drawn_top, + root: picker.root.clone(), + query: picker.query.clone(), + state: FileWheelOffset::state(picker), + }); + offset.top = if down { + offset.top.saturating_add(3) + } else { + offset.top.saturating_sub(3) + } + .min(picker.entries().len().saturating_sub(height)); + } + return true; + } + false + } +} + +fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPanels) { if area.width == 0 || area.height == 0 { return; } @@ -779,6 +1027,7 @@ fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect) { Constraint::Length(u16::from(content.height >= 3)), ]) .areas(content); + panels.hover = Some((panel, usize::from(text_area.height))); let lines: Vec = hover .lines() .skip(hover.top) @@ -839,7 +1088,13 @@ fn display_text(text: &str) -> String { .collect() } -fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigation_hint: &str) { +fn draw_file_list( + frame: &mut Frame, + picker: &FilePicker, + panel: Rect, + navigation_hint: &str, + panels: &mut WheelPanels, +) { if panel.width == 0 || panel.height == 0 { return; } @@ -881,6 +1136,7 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio Constraint::Length(u16::from(show_help)), ]) .areas(content); + let first = panels.file_top(picker, panel, usize::from(results_area.height)); let query = if browsing && picker.query.is_empty() { "i filter ยท ^U clear ยท ^T view".into() } else if browsing { @@ -905,7 +1161,11 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio )), query_area, ); - if picker.focused && query_area.width > 0 && query_area.height > 0 { + if picker.focused + && query_area.width > 0 + && query_area.height > 0 + && (!browsing || picker.entries().len() == 0 || results_area.height == 0) + { let cursor_x = if browsing { 0 } else { query_width - offset }; frame.set_cursor_position((query_area.x + cursor_x as u16, query_area.y)); } @@ -936,7 +1196,6 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio } else if results_area.height > 0 { let count = usize::from(results_area.height); let selected_index = picker.selected.min(entries.len() - 1); - let first = selected_index.saturating_sub(count - 1); let lines: Vec = entries .enumerate() .skip(first) @@ -974,7 +1233,12 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio }) .collect(); frame.render_widget(Paragraph::new(lines), results_area); - if browsing && picker.focused && results_area.width > 0 { + if browsing + && picker.focused + && results_area.width > 0 + && selected_index >= first + && selected_index - first < count + { frame.set_cursor_position(( results_area.x, results_area.y + (selected_index - first) as u16, @@ -1187,7 +1451,17 @@ mod tests { editor.handle(Input::Right); } let rendered = terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!(rendered.buffer[(2, 0)].symbol(), "e\u{301}"); assert_eq!(rendered.buffer[(3, 0)].symbol(), "๐Ÿ‘ฉโ€๐Ÿš€"); @@ -1200,14 +1474,34 @@ mod tests { editor.handle(Input::Down); editor.handle(Input::Home); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).ends_with("last")); for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); } assert_eq!(editor.cursor(), crate::editor::Cursor::default()); @@ -1303,7 +1597,17 @@ mod tests { .unwrap(); let (mut top, mut left) = (0, offset); let rendered = terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap() .buffer .clone(); @@ -1797,7 +2101,17 @@ end .unwrap(); let (mut top, mut left) = (0, offset); let rendered = terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap() .buffer .clone(); @@ -1841,7 +2155,17 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 4)).unwrap(); let (mut top, mut left) = (0, 0); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); assert!( @@ -2077,6 +2401,172 @@ end } } + #[test] + fn wheel_routes_to_floating_and_unfocused_docked_files_without_changing_selection() { + let directory = Directory::new(); + let config = directory.file( + "config.rb", + "Vamp.configure do |config|\n config.file_picker_view = 'list'\n config.file_explorer_view = 'list'\nend\n", + ); + for (key, focused) in [('o', true), ('e', true), ('e', false)] { + let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap(); + session.handle(Input::Char(' ')); + session.handle(Input::Char(key)); + if !focused { + session.handle(Input::Char('\t')); + } + let picker = session.editor().file_picker().unwrap(); + assert!(picker.entries().len() > 5); + let selected = picker.selected; + let selected_path = picker.entries().nth(selected).unwrap().path.to_owned(); + let query = picker.query.clone(); + let carets = session.editor().carets(); + let revision = session.editor().text_revision(); + let mut panels = WheelPanels::default(); + let area = Rect::new(3, 2, 40, 6); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap(); + terminal + .draw(|frame| draw_file_list(frame, picker, area, "", &mut panels)) + .unwrap(); + let mouse = MouseEvent { + kind: MouseEventKind::ScrollDown, + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }; + assert!(!panels.scroll( + &mut session, + MouseEvent { + column: 0, + row: 0, + ..mouse + } + )); + assert!(panels.scroll(&mut session, mouse)); + assert_eq!(panels.manual_files.as_ref().unwrap().top, 3); + terminal + .draw(|frame| { + panels.begin_frame(); + draw_file_list( + frame, + session.editor().file_picker().unwrap(), + area, + "", + &mut panels, + ); + }) + .unwrap(); + let picker = session.editor().file_picker().unwrap(); + let text = screen_text(&terminal); + assert!( + text.contains(picker.entries().nth(3).unwrap().label), + "{text}" + ); + assert!( + !text.contains(&format!( + "> {}", + picker.entries().nth(selected).unwrap().label + )), + "{text}" + ); + assert_eq!(picker.selected, selected); + assert_eq!(picker.entries().nth(selected).unwrap().path, selected_path); + assert_eq!(picker.query, query); + assert_eq!(picker.focused, focused); + assert_eq!(session.editor().carets(), carets); + assert_eq!(session.editor().text_revision(), revision); + let entry_count = picker.entries().len(); + assert!(!panels.scroll( + &mut session, + MouseEvent { + kind: MouseEventKind::Moved, + ..mouse + } + )); + for _ in 0..entry_count { + assert!(panels.scroll(&mut session, mouse)); + } + let (_, height, _) = panels.files.unwrap(); + assert_eq!( + panels.manual_files.as_ref().unwrap().top, + session.editor().file_picker().unwrap().entries().len() - height + ); + panels.follow_files(); + terminal + .draw(|frame| { + draw_file_list( + frame, + session.editor().file_picker().unwrap(), + area, + "", + &mut panels, + ) + }) + .unwrap(); + assert_eq!(panels.files.unwrap().2, 0); + session.handle(Input::Ctrl('p')); + assert!(session.editor().palette().is_some()); + assert!(panels.scroll(&mut session, mouse)); + assert!(panels.manual_files.is_none()); + session.handle(Input::Escape); + session.handle(Input::Char(' ')); + session.handle(Input::Char(key)); + session.handle(Input::Ctrl('w')); + assert!(session.editor().file_picker().is_none()); + assert!(!panels.scroll(&mut session, mouse)); + } + } + + #[test] + fn manually_scrolled_tree_does_not_place_cursor_or_highlight_offscreen_selection() { + let directory = Directory::new(); + for index in 0..12 { + directory.file(&format!("file-{index:02}"), ""); + } + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::Tree, + ) + .unwrap(); + picker.selected = 11; + let mut panels = WheelPanels { + manual_files: Some(FileWheelOffset { + top: 0, + root: picker.root.clone(), + query: picker.query.clone(), + state: FileWheelOffset::state(&picker), + }), + ..WheelPanels::default() + }; + let area = Rect::new(3, 2, 40, 6); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap(); + terminal + .draw(|frame| { + frame.set_cursor_position((59, 11)); + draw_file_list(frame, &picker, area, "", &mut panels); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("file-00"), "{text}"); + assert!(!text.contains("file-11"), "{text}"); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(59, 11) + ); + for y in area.y..area.bottom() { + for x in area.x..area.right() { + assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue); + } + } + picker.navigate(false); + terminal + .draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels)) + .unwrap(); + assert!(panels.manual_files.is_none()); + assert!(screen_text(&terminal).contains("> file-10")); + } + #[test] fn file_list_window_follows_control_j_k_selection() { let mut editor = Editor::open(None, vec![]).unwrap(); @@ -2098,7 +2588,13 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap(); terminal .draw(|frame| { - draw_file_list(frame, picker, frame.area(), editor.file_navigation_hint()) + draw_file_list( + frame, + picker, + frame.area(), + editor.file_navigation_hint(), + &mut WheelPanels::default(), + ) }) .unwrap(); let text = screen_text(&terminal); @@ -2129,7 +2625,17 @@ end editor.handle(Input::Char(' ')); editor.handle(Input::Char(key)); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&hint), "{text}"); @@ -2138,7 +2644,17 @@ end } editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&hint), "{text}"); @@ -2178,7 +2694,15 @@ end ) .unwrap(); terminal - .draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint())) + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(!text.contains("file-00"), "{text}"); @@ -2215,7 +2739,15 @@ end let area = Rect::new(0, 0, 100, 12); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); terminal - .draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint())) + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("Explorer"), "{text}"); @@ -2231,7 +2763,13 @@ end terminal .draw(|frame| { frame.set_cursor_position((99, 11)); - draw_file_list(frame, &picker, area, editor.selection_navigation_hint()); + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + ); }) .unwrap(); let text = screen_text(&terminal); @@ -2247,7 +2785,15 @@ end picker.presentation = FilePresentation::Floating; picker.focused = true; terminal - .draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint())) + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains("Files")); assert!(screen_text(&terminal).contains("Esc/^W")); @@ -2255,7 +2801,15 @@ end picker.set_view(FileView::Tree); picker.finish_search(); terminal - .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("No matching files"), "{text}"); @@ -2268,7 +2822,13 @@ end terminal .draw(|frame| { frame.set_cursor_position((99, 11)); - draw_file_list(frame, &picker, area, editor.file_navigation_hint()); + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + ); }) .unwrap(); assert_eq!( @@ -2299,7 +2859,13 @@ end .unwrap(); terminal .draw(|frame| { - draw_file_list(frame, &picker, area, editor.selection_navigation_hint()) + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + ) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); @@ -2355,6 +2921,7 @@ end &picker, frame.area(), editor.selection_navigation_hint(), + &mut WheelPanels::default(), ) }) .unwrap(); @@ -2398,7 +2965,15 @@ end ) .unwrap(); terminal - .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); for label in [ @@ -2441,7 +3016,15 @@ end ); picker.set_view(FileView::List); terminal - .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("chain/a/b/leaf.txt"), "{text}"); @@ -2480,7 +3063,15 @@ end ) .unwrap(); terminal - .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); @@ -2504,7 +3095,13 @@ end picker.refilter(); terminal .draw(|frame| { - draw_file_list(frame, &picker, area, editor.file_navigation_hint()) + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + ) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); @@ -2515,7 +3112,13 @@ end assert_eq!(picker.entries().len(), 0); terminal .draw(|frame| { - draw_file_list(frame, &picker, area, editor.file_navigation_hint()) + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + ) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); @@ -2610,7 +3213,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let query_cursor = terminal.get_cursor_position().unwrap(); assert!(screen_text(&terminal).contains(&format!("> {query}"))); @@ -2631,7 +3244,17 @@ end filtered ); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&format!("filter: {query}")), "{text}"); @@ -2652,7 +3275,17 @@ end editor.handle(Input::Char('i')); assert_eq!(editor.file_picker().unwrap().query, query); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor); editor.handle(Input::Escape); @@ -2664,7 +3297,17 @@ end Path::new(query) ); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( @@ -2684,7 +3327,17 @@ end Path::new(query) ); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!(cursor.y, query_cursor.y); @@ -2702,7 +3355,17 @@ end Path::new(query) ); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), cursor); editor.handle(Input::Ctrl('t')); @@ -2743,7 +3406,17 @@ end assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains("List")); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); @@ -2752,7 +3425,17 @@ end assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); editor.handle(Input::Escape); @@ -2764,7 +3447,17 @@ end assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains("filter: n")); let cursor = terminal.get_cursor_position().unwrap(); @@ -2816,7 +3509,17 @@ end ] { assert_eq!(editor.file_picker().unwrap().view, view); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(editor.file_navigation_hint()), "{text}"); @@ -2872,7 +3575,17 @@ end } editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains(editor.selection_navigation_hint())); } @@ -2909,7 +3622,17 @@ end ] { editor.set_file_explorer_side(name).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let workspace = Rect::new(area.x, area.y, area.width, area.height - 2); let (editor_area, panel) = dock_layout(workspace, side, true); @@ -2925,7 +3648,17 @@ end assert!(panel.contains(terminal.get_cursor_position().unwrap())); editor.handle(Input::Escape); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(editor_area.contains(terminal.get_cursor_position().unwrap())); let buffer = terminal.backend().buffer(); @@ -2979,13 +3712,33 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let before = (top, left); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let panel = Rect::new(area.x + 5, area.y + 1, 100, 24); assert_eq!((top, left), before); @@ -3172,7 +3925,17 @@ end terminal.resize(area).unwrap(); let area = terminal.get_frame().area(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); if width > 0 && height > 0 { let workspace = Rect::new( @@ -3190,7 +3953,17 @@ end } editor.handle(Input::Escape); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); if width > 0 && height > 0 { let cursor = terminal.get_cursor_position().unwrap(); @@ -3240,12 +4013,32 @@ end editor.handle(Input::Char(' ')); editor.handle(Input::Char('b')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains("leader: Space b")); editor.handle(Input::Char('x')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(!screen_text(&terminal).contains("leader:")); assert_eq!(editor.lines(), &["keep"]); @@ -3265,7 +4058,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains(", b n")); assert!(!screen_text(&terminal).contains("Space b n")); @@ -3273,12 +4076,372 @@ end .bind("normal", ",", Some("core.command".into())) .unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains("unbound")); assert!(!screen_text(&terminal).contains(", b n")); } + fn document_wheel(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() { + let directory = Directory::new(); + let path = directory.file( + "rows.txt", + &(0..12) + .map(|row| format!("abc row-{row:02}")) + .collect::>() + .join("\n"), + ); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session.handle(Input::Char('v')); + session.handle(Input::Char('e')); + session.handle(Input::Ctrl('a')); + assert_eq!(session.editor().selection_count(), 12); + let carets = session.editor().carets(); + let selections = session.editor().selections(); + let cursor = session.editor().cursor(); + let revision = session.editor().text_revision(); + let text = session.editor().lines().to_vec(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + for (kind, top) in [ + (MouseEventKind::ScrollDown, 3), + (MouseEventKind::ScrollDown, 6), + (MouseEventKind::ScrollDown, 8), + (MouseEventKind::ScrollDown, 8), + (MouseEventKind::ScrollUp, 5), + (MouseEventKind::ScrollUp, 2), + (MouseEventKind::ScrollUp, 0), + (MouseEventKind::ScrollUp, 0), + ] { + // The gutter belongs to the rendered document hit region too. + assert!(viewports.scroll(&mut session, document_wheel(kind, 0, 0))); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains(&format!("row-{top:02}")) + ); + assert_eq!( + terminal.backend().cursor_visible(), + cursor.row >= top && cursor.row < top + 4 + ); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().carets(), carets); + assert_eq!(session.editor().selections(), selections); + assert_eq!(session.editor().text_revision(), revision); + assert_eq!(session.editor().lines(), text); + } + assert!(!viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 0, 5) + )); + assert!(!viewports.scroll(&mut session, document_wheel(MouseEventKind::Moved, 0, 0))); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 0, 0) + )); + session.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-00") + ); + assert!(terminal.backend().cursor_visible()); + } + + #[test] + fn no_op_navigation_restores_caret_following_through_core_dispatch() { + let directory = Directory::new(); + let path = directory.file("rows.txt", &["row"; 12].join("\n")); + let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for motion in [Input::Char('k'), Input::Up, Input::Home] { + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 0) + )); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + session.handle(motion); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert_eq!(session.editor().cursor(), Cursor::default()); + assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); + assert!(terminal.backend().cursor_visible()); + } + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + // A remapped motion and a disabled default demonstrate that intent is + // emitted by the resolved core action, not frontend key spelling. + editor.bind("normal", "x", Some("core.up".into())).unwrap(); + editor.bind("normal", "k", None).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for navigation in 0..3 { + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let buffer_id = editor.buffer_id(); + viewports.offsets.get_mut(&buffer_id).unwrap().0 = 3; + viewports.document.manual = Some(ManualViewport { + buffer_id, + revision: editor.text_revision(), + caret_reveal_revision: editor.caret_reveal_revision(), + mode: editor.mode(), + cursor: editor.cursor(), + carets: editor.carets(), + selections: editor.selections(), + }); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + editor.handle(Input::Char('k')); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + match navigation { + 0 => { + editor.handle(Input::Char('x')); + } + 1 => editor + .navigate_to( + path.clone(), + crate::plugin::TextPosition { row: 0, column: 0 }, + ) + .unwrap(), + 2 => { + editor.execute(&format!("buffer {buffer_id}")); + } + _ => unreachable!(), + } + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(editor.cursor(), Cursor::default()); + assert_eq!(viewports.offsets[&buffer_id].0, 0); + assert!(terminal.backend().cursor_visible()); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row") + ); + } + } + + #[test] + fn manual_document_view_survives_idle_resize_and_palette_then_follows_navigation_and_edits() { + let directory = Directory::new(); + let path = directory.file( + "rows.txt", + &(0..20) + .map(|row| format!("row-{row:02}")) + .collect::>() + .join("\n"), + ); + let other = directory.file("other.txt", "other"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let buffer_id = session.editor().buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + for _ in 0..2 { + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + } + session.poll(); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-06") + ); + assert!(!terminal.backend().cursor_visible()); + terminal.backend_mut().resize(30, 8); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-06") + ); + session.handle(Input::Ctrl('p')); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + session.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-06") + ); + assert_eq!(session.editor().cursor(), Cursor::default()); + session.handle(Input::Down); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-01") + ); + assert!(terminal.backend().cursor_visible()); + + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + session.handle(Input::Char('i')); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + session.handle(Input::Char('z')); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + session.handle(Input::Escape); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + session.execute(&format!("edit {}", other.display())); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(screen_text(&terminal).starts_with("1 other")); + session.execute(&format!("buffer {buffer_id}")); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("zrow-01") + ); + } + + #[test] + fn manual_document_view_hides_horizontal_offscreen_caret_and_clamps_tiny_viewports() { + let directory = Directory::new(); + let path = directory.file("long.txt", "abcdefghijk"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + for _ in 0..8 { + session.handle(Input::Right); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 0) + )); + terminal.backend_mut().resize(8, 6); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + assert!(screen_text(&terminal).starts_with("1 abcdef")); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 }); + session.handle(Input::Left); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + assert_eq!(terminal.backend().cursor_position().x, 7); + for (width, height) in [(1, 1), (2, 2), (0, 0)] { + terminal.backend_mut().resize(width, height); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert_eq!( + viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 0, 0) + ), + width > 0 && height > 0, + ); + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); + } + } + #[test] fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() { let directory = Directory::new(); @@ -3516,13 +4679,33 @@ end editor.handle(Input::Char('1')); editor.handle(Input::Char('0')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains("count: 10")); editor.handle(Input::Escape); editor.handle(Input::Down); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(!screen_text(&terminal).contains("count:")); assert_eq!( @@ -3554,7 +4737,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!((top, left), (8, 8)); let buffer = terminal.backend().buffer(); @@ -3572,7 +4765,17 @@ end ); editor.handle(Input::Down); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); let last_number: String = (3..6).map(|x| buffer[(x, 4)].symbol()).collect(); @@ -3585,7 +4788,17 @@ end editor.handle(Input::Up); editor.handle(Input::Up); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); for (y, expected) in [(2, "10 "), (3, " 1 "), (4, " 2 ")] { @@ -3595,7 +4808,17 @@ end editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); for (y, expected) in [(2, " 1 "), (3, "11 "), (4, " 1 ")] { @@ -3621,7 +4844,17 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 4)).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); let buffer = terminal.backend().buffer(); @@ -3646,7 +4879,17 @@ end .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 5)).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, 0)].symbol(), "1"); @@ -3673,7 +4916,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("> no-such-command"), "{text}"); @@ -3686,7 +4939,17 @@ end assert_eq!(editor.mode(), Mode::Search); assert_eq!(editor.command_line(), "saved search"); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert!(screen_text(&terminal).contains("/saved search")); } @@ -3708,7 +4971,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&selected.name), "{text}"); @@ -3744,7 +5017,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}"); @@ -3756,7 +5039,17 @@ end editor.handle(Input::Char(ch)); } terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("sample | unbound | NORMAL"), "{text}"); @@ -3855,17 +5148,47 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let viewport = (top, left); editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!((top, left), viewport); editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!((top, left), viewport); let cursor = terminal.get_cursor_position().unwrap(); @@ -3905,7 +5228,17 @@ end ) .unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); @@ -3953,7 +5286,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); for selection in editor.selections() { @@ -3985,7 +5328,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); for caret in editor.carets() { @@ -4024,7 +5377,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); for x in [1, 2, 3, 4] { @@ -4035,7 +5398,17 @@ end editor.handle(Input::Down); editor.handle(Input::Down); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(10, 0)].symbol(), " "); @@ -4049,7 +5422,17 @@ end editor.handle(Input::End); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 6)).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(left, 4); @@ -4062,7 +5445,17 @@ end assert_ne!(buffer[(2, 1)].bg, Color::Blue); editor.handle(Input::Escape); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(2, 0)].bg, Color::Yellow); @@ -4089,7 +5482,17 @@ end let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(left, 4); @@ -4106,7 +5509,17 @@ end editor.handle(Input::Char(ch)); } terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(1, 4)].symbol(), "็•Œ"); @@ -4115,7 +5528,17 @@ end editor.handle(Input::Backspace); editor.handle(Input::Char('\t')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) .unwrap(); assert_eq!( terminal.get_cursor_position().unwrap(), @@ -4338,6 +5761,18 @@ end entered, "{mode}: {before:?}" ); + for protocol in [1000, 1002, 1003, 1015, 1006] { + assert_eq!( + before.matches(&format!("\x1b[?{protocol}h")).count(), + entered, + "{mode}: mouse capture was not enabled: {before:?}", + ); + assert_eq!( + before.matches(&format!("\x1b[?{protocol}l")).count(), + entered, + "{mode}: mouse capture was not restored: {before:?}", + ); + } if mode == "panic" { let begin = before.find("\x1b[?2026h").unwrap(); let end = before.find("\x1b[?2026l").unwrap(); @@ -4346,6 +5781,10 @@ end before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(), "{before:?}", ); + assert!( + before.find("\x1b[?1006l").unwrap() < before.find("CALLER_HOOK").unwrap(), + "{before:?}", + ); } assert!(after.contains("CALLER_HOOK"), "{after:?}"); assert!(