diff --git a/TODO.md b/TODO.md index 604e5ca..159c6ce 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,5 @@ - [x] navigation control/actions: seek occurrence of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrence instead of first). -- [ ] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view). +- [x] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view). - [ ] buffer ux improvement: add toggleable word wrap (no keybinding by default). - [ ] diff view: add character-wise change highlight (enabled by default). - [ ] diff view: change diff view modes; choose between split and unified diff view (split by default). diff --git a/docs/editing.md b/docs/editing.md index 5078c91..34eb978 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -162,7 +162,9 @@ Paste is backend input: it is ignored in Normal, Command, Search, and Visual mod 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. Completion lists, command palettes, and startup notices consume wheel input without scrolling the background or editing text. Mouse clicks and dragging are not editing actions. +Mouse-wheel left/right scrolls three display columns per event. **Shift+wheel up/down** provides the same left/right scrolling when the terminal does not send horizontal wheel events. Tabs and wide Unicode characters use terminal-cell widths; scrolling stops at the longest line and remains detached from the caret until navigation or editing resumes. + +Vertical 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. Horizontal wheel input over these panels is consumed without panning the document. Completion lists, command palettes, and startup notices consume wheel input without scrolling the background or editing text. Mouse clicks and dragging are not editing actions. ## Buffers @@ -251,7 +253,7 @@ In split diffs, changed text rows use muted red backgrounds in Before and muted Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. -In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Wheel input scrolls the visible panes without moving the focused text caret; switching sides by wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. +In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input pans only the hovered text pane, with independent Before/After offsets and no change to focus, cursor, or selections; over Files it is consumed. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file. diff --git a/src/diff.rs b/src/diff.rs index 97f7ae0..5d7e5e4 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -609,6 +609,35 @@ impl DiffState { } } + pub(crate) fn bound_horizontal(&self, focus: Focus, left: usize, width: usize) -> usize { + let columns = match focus { + Focus::Before => self.max_before, + Focus::After => self.max_after, + Focus::Files => return 0, + }; + left.min(columns.saturating_add(1).saturating_sub(width.max(1))) + } + + pub(crate) fn scroll_horizontal(&mut self, focus: Focus, right: bool, width: usize) { + let left = match focus { + Focus::Before => self.left_before, + Focus::After => self.left_after, + Focus::Files => return, + }; + let left = if right { + left.saturating_add(3) + } else { + left.saturating_sub(3) + }; + let left = self.bound_horizontal(focus, left, width); + match focus { + Focus::Before => self.left_before = left, + Focus::After => self.left_after = left, + Focus::Files => unreachable!(), + } + self.follow_caret = false; + } + pub(crate) fn first_text_focus(&self) -> Focus { if self.pane_layout() == PaneLayout::After { Focus::After diff --git a/src/editor.rs b/src/editor.rs index 370a98a..11619d1 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1735,6 +1735,14 @@ impl Editor { } } + pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) { + if let Some(diff) = &mut self.diff + && diff.phase == Phase::View + { + diff.scroll_horizontal(focus, right, width); + } + } + pub(crate) fn set_diff_viewport( &mut self, comparison_id: u64, diff --git a/src/session.rs b/src/session.rs index 7edb2cb..8a8dbb3 100644 --- a/src/session.rs +++ b/src/session.rs @@ -274,6 +274,10 @@ impl EditorSession { self.editor.scroll_diff(focus, down); } + pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) { + self.editor.scroll_diff_horizontal(focus, right, width); + } + pub fn handle(&mut self, input: Input) { self.cancel_pending_popup_request(); let invocation = self.editor.handle(input); diff --git a/src/tui.rs b/src/tui.rs index 71ed78c..e31af9c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -135,6 +135,8 @@ impl Drop for TerminalSession { struct DocumentViewport { geometry: Option<(usize, Rect)>, manual: Option, + text_width: usize, + horizontal_extent: Option<(usize, u64, usize)>, } struct ManualViewport { @@ -184,13 +186,12 @@ impl BufferViewports { } } + // Shift-wheel is the fallback for terminals without native horizontal reports. + // Keep classification shared with popup routing. fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { - if !matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) { + let Some(kind) = wheel_kind(&mouse) else { return false; - } + }; if self.panels.scroll(session, mouse) { return true; } @@ -208,17 +209,47 @@ impl BufferViewports { { 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!(), + let (top, left) = self.offsets.entry(buffer_id).or_default(); + if matches!( + kind, + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight + ) { + let key = (buffer_id, editor.text_revision()); + let columns = match self.document.horizontal_extent { + Some((id, revision, columns)) if (id, revision) == key => columns, + _ => { + let columns = editor + .lines() + .iter() + .map(|line| display_column(line, usize::MAX)) + .max() + .unwrap_or(0); + self.document.horizontal_extent = Some((key.0, key.1, columns)); + columns + } + }; + *left = if kind == MouseEventKind::ScrollRight { + left.saturating_add(3) + } else { + left.saturating_sub(3) + } + .min( + columns + .saturating_add(1) + .saturating_sub(self.document.text_width.max(1)), + ); + } else { + let last_page = editor + .lines() + .len() + .saturating_sub(usize::from(viewport.height)); + *top = if kind == MouseEventKind::ScrollDown { + top.saturating_add(3) + } else { + top.saturating_sub(3) + } + .min(last_page); } - .min(last_page); if self.document.manual.is_none() { self.document.manual = Some(ManualViewport { buffer_id, @@ -255,6 +286,22 @@ impl BufferViewports { } } +fn wheel_kind(mouse: &MouseEvent) -> Option { + Some(match mouse.kind { + MouseEventKind::ScrollUp if mouse.modifiers.contains(KeyModifiers::SHIFT) => { + MouseEventKind::ScrollLeft + } + MouseEventKind::ScrollDown if mouse.modifiers.contains(KeyModifiers::SHIFT) => { + MouseEventKind::ScrollRight + } + kind @ (MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight) => kind, + _ => return None, + }) +} + fn draw_frame( terminal: &mut Terminal>>, output: &mut W, @@ -474,6 +521,7 @@ fn draw( .areas(viewport); let height = usize::from(viewport.height); let width = usize::from(viewport.width); + document.text_width = width; let cursor = editor.cursor(); let mut document_caret = None; let carets = editor.carets(); @@ -499,6 +547,13 @@ fn draw( ), )); *top = (*top).min(editor.lines().len().saturating_sub(height)); + if document.manual.is_some() + && let Some((id, revision, columns)) = document.horizontal_extent + && id == editor.buffer_id() + && revision == editor.text_revision() + { + *left = (*left).min(columns.saturating_add(1).saturating_sub(width.max(1))); + } if height > 0 && width > 0 { if document.manual.is_none() { if cursor.row < *top { @@ -724,6 +779,7 @@ fn draw( struct DiffGeometry { regions: [(Rect, Focus); 3], viewport: Option<(u64, usize, usize, usize, usize)>, + text_widths: [u16; 2], } fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect { @@ -1217,6 +1273,7 @@ fn draw_diff( (Rect::default(), Focus::After), ], viewport: None, + text_widths: [0; 2], }); if let Some(editor) = editor { draw_diff_popups(frame, editor, area, None, panels); @@ -1358,6 +1415,16 @@ fn draw_diff( comparison.map(|comparison| comparison.files.as_slice()), ); } + let mut text_widths = [0; 2]; + let previous_text_widths = panels + .previous_diff + .as_ref() + .filter(|geometry| { + geometry.viewport.is_some_and(|(id, file, _, _, _)| { + Some(id) == diff.comparison_id && file == diff.file + }) + }) + .map(|geometry| geometry.text_widths); if let Some(file) = selected && let Some(syntax) = &panels.diff_syntax { @@ -1372,8 +1439,13 @@ fn draw_diff( before_area, true, &syntax.before, - (top, before_left), + ( + top, + &mut before_left, + previous_text_widths.map(|widths| widths[0]), + ), ); + text_widths[0] = text_area.width; if let Some(editor) = editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before)) { @@ -1396,8 +1468,13 @@ fn draw_diff( after_area, false, &syntax.after, - (top, after_left), + ( + top, + &mut after_left, + previous_text_widths.map(|widths| widths[1]), + ), ); + text_widths[1] = text_area.width; if let Some(editor) = editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After)) { @@ -1439,6 +1516,7 @@ fn draw_diff( viewport: diff .comparison_id .map(|id| (id, diff.file, top, before_left, after_left)), + text_widths, }); if let Some(picker) = &diff.files && diff.files_visible @@ -1476,7 +1554,7 @@ fn draw_diff_pane( area: Rect, before: bool, syntax: &SyntaxCache, - viewport: (usize, usize), + viewport: (usize, &mut usize, Option), ) -> Rect { if area.width == 0 || area.height == 0 { return area; @@ -1509,7 +1587,7 @@ fn draw_diff_pane( return content; } let source = if before { &file.before } else { &file.after }; - let (top, left) = viewport; + let (top, left, previous_width) = viewport; let digits = source.len().max(1).ilog10() as usize + 1; let gutter = if usize::from(content.width) > digits + 2 { digits + 2 @@ -1517,6 +1595,10 @@ fn draw_diff_pane( 0 }; let width = usize::from(content.width).saturating_sub(gutter); + if previous_width.is_some_and(|previous| usize::from(previous) != width) { + *left = diff.bound_horizontal(focus, *left, width); + } + let left = *left; for (display_row, row) in diff .display_rows .iter() @@ -1738,6 +1820,7 @@ struct WheelPanels { manual_files: Option, diff: Option, diff_syntax: Option, + previous_diff: Option, } struct FileWheelOffset { @@ -1772,7 +1855,7 @@ impl WheelPanels { self.hover = None; self.completion = None; self.files = None; - self.diff = None; + self.previous_diff = self.diff.take(); } // Keyboard/paste may change the catalog without changing its length. @@ -1801,12 +1884,31 @@ impl WheelPanels { top } + // A horizontal report over a popup must not pan the underlying buffer. fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { - let down = match mouse.kind { - MouseEventKind::ScrollDown => true, - MouseEventKind::ScrollUp => false, - _ => return false, + let Some(kind) = wheel_kind(&mouse) else { + return false; }; + let horizontal = matches!( + kind, + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight + ); + let down = matches!( + kind, + MouseEventKind::ScrollDown | MouseEventKind::ScrollRight + ); + let position = Position::new(mouse.column, mouse.row); + if horizontal + && (session.editor().palette().is_some() + || session.editor().startup_diagnostic().is_some() + || self.completion.is_some_and(|area| area.contains(position)) + || self.hover.is_some_and(|(area, _)| area.contains(position)) + || self + .files + .is_some_and(|(area, _, _)| area.contains(position))) + { + return true; + } let editor = session.editor(); if editor.diff().is_some() { let position = Position::new(mouse.column, mouse.row); @@ -1817,8 +1919,16 @@ impl WheelPanels { .find_map(|(area, focus)| area.contains(position).then_some(*focus)) }); if let Some(focus) = focus { - for _ in 0..3 { - session.scroll_diff(focus, down); + if horizontal { + let width = + self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)]; + if width > 0 { + session.scroll_diff_horizontal(focus, down, usize::from(width)); + } + } else { + for _ in 0..3 { + session.scroll_diff(focus, down); + } } } return true; @@ -4016,12 +4126,18 @@ mod tests { }; git(&["init", "-q"]); for index in 0..8 { - directory.file(&format!("file-{index}.txt"), "first\nsecond\nthird\n"); + directory.file( + &format!("file-{index}.txt"), + &format!("{}\nsecond\nthird\n", "before.".repeat(30)), + ); } git(&["add", "."]); git(&["commit", "-qm", "base"]); for index in 0..8 { - directory.file(&format!("file-{index}.txt"), "changed\nsecond\nthird\n"); + directory.file( + &format!("file-{index}.txt"), + &format!("{}\nsecond\nthird\n", "after.".repeat(30)), + ); } let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let original = session.editor().lines().to_vec(); @@ -4054,6 +4170,30 @@ mod tests { let tree = regions[0].0; let before = regions[1].0; let after = regions[2].0; + let initial_focus = session.editor().diff().unwrap().focus; + let initial_cursor = session.editor().cursor(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollRight, before.x + 1, before.y + 1) + )); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(screen_text(&terminal).contains("ore.before.")); + assert_eq!(session.editor().diff().unwrap().left_before, 3); + assert_eq!(session.editor().diff().unwrap().left_after, 0); + let mut shift = document_wheel(MouseEventKind::ScrollDown, after.x + 1, after.y + 1); + shift.modifiers = KeyModifiers::SHIFT; + assert!(viewports.scroll(&mut session, shift)); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(screen_text(&terminal).contains("er.after.")); + assert_eq!(session.editor().diff().unwrap().left_before, 3); + assert_eq!(session.editor().diff().unwrap().left_after, 3); + assert_eq!(session.editor().diff().unwrap().top, 0); + assert_eq!(session.editor().diff().unwrap().focus, initial_focus); + assert_eq!(session.editor().cursor(), initial_cursor); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1), @@ -7067,6 +7207,64 @@ end } } + #[test] + fn horizontal_wheel_preserves_selection_bounds_display_cells_and_survives_redraw() { + let directory = Directory::new(); + let text = format!("abc\t界e\u{301}{}", "xyz".repeat(20)); + let path = directory.file("wide.txt", &text); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session.handle(Input::Char('v')); + session.handle(Input::Char('l')); + let cursor = session.editor().cursor(); + let selections = session.editor().selections(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollRight, 0, 0) + )); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(3, 0)].symbol(), "界"); + assert_eq!(terminal.backend().buffer()[(5, 0)].symbol(), "e\u{301}"); + let mut shift = document_wheel(MouseEventKind::ScrollDown, 0, 0); + shift.modifiers = KeyModifiers::SHIFT; + assert!(viewports.scroll(&mut session, shift)); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 6)); + for _ in 0..100 { + viewports.scroll(&mut session, shift); + } + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!( + viewports.offsets[&session.editor().buffer_id()].1, + display_column(&text, usize::MAX) + 1 - viewports.document.text_width + ); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().selections(), selections); + assert_eq!(session.editor().lines(), std::slice::from_ref(&text)); + terminal.backend_mut().resize(100, 6); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 0)); + assert!(screen_text(&terminal).contains("abc")); + session.handle(Input::Escape); + session.handle(Input::Char('l')); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + } + #[test] fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() { let directory = Directory::new();