feat: support horizontal mouse scrolling in buffers
This commit is contained in:
+29
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+226
-28
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user