feat: support horizontal mouse scrolling in buffers

This commit is contained in:
2026-10-11 14:10:20 +02:00
parent 0ca0d89b9e
commit 0d4601b612
6 changed files with 272 additions and 31 deletions
+226 -28
View File
@@ -135,6 +135,8 @@ impl Drop for TerminalSession {
struct DocumentViewport {
geometry: Option<(usize, Rect)>,
manual: Option<ManualViewport>,
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<MouseEventKind> {
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<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
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<u16>),
) -> 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<FileWheelOffset>,
diff: Option<DiffGeometry>,
diff_syntax: Option<DiffSyntaxCache>,
previous_diff: Option<DiffGeometry>,
}
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();