feat: support horizontal mouse scrolling in buffers
This commit is contained in:
@@ -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).
|
- [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).
|
- [ ] buffer ux improvement: add toggleable word wrap (no keybinding by default).
|
||||||
- [ ] diff view: add character-wise change highlight (enabled 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 view: change diff view modes; choose between split and unified diff view (split by default).
|
||||||
|
|||||||
+4
-2
@@ -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.
|
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
|
## 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+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 {
|
pub(crate) fn first_text_focus(&self) -> Focus {
|
||||||
if self.pane_layout() == PaneLayout::After {
|
if self.pane_layout() == PaneLayout::After {
|
||||||
Focus::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(
|
pub(crate) fn set_diff_viewport(
|
||||||
&mut self,
|
&mut self,
|
||||||
comparison_id: u64,
|
comparison_id: u64,
|
||||||
|
|||||||
@@ -274,6 +274,10 @@ impl EditorSession {
|
|||||||
self.editor.scroll_diff(focus, down);
|
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) {
|
pub fn handle(&mut self, input: Input) {
|
||||||
self.cancel_pending_popup_request();
|
self.cancel_pending_popup_request();
|
||||||
let invocation = self.editor.handle(input);
|
let invocation = self.editor.handle(input);
|
||||||
|
|||||||
+226
-28
@@ -135,6 +135,8 @@ impl Drop for TerminalSession {
|
|||||||
struct DocumentViewport {
|
struct DocumentViewport {
|
||||||
geometry: Option<(usize, Rect)>,
|
geometry: Option<(usize, Rect)>,
|
||||||
manual: Option<ManualViewport>,
|
manual: Option<ManualViewport>,
|
||||||
|
text_width: usize,
|
||||||
|
horizontal_extent: Option<(usize, u64, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ManualViewport {
|
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 {
|
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
|
||||||
if !matches!(
|
let Some(kind) = wheel_kind(&mouse) else {
|
||||||
mouse.kind,
|
|
||||||
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
if self.panels.scroll(session, mouse) {
|
if self.panels.scroll(session, mouse) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -208,17 +209,47 @@ impl BufferViewports {
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let (top, _) = self.offsets.entry(buffer_id).or_default();
|
let (top, left) = self.offsets.entry(buffer_id).or_default();
|
||||||
let last_page = editor
|
if matches!(
|
||||||
.lines()
|
kind,
|
||||||
.len()
|
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
|
||||||
.saturating_sub(usize::from(viewport.height));
|
) {
|
||||||
*top = match mouse.kind {
|
let key = (buffer_id, editor.text_revision());
|
||||||
MouseEventKind::ScrollUp => top.saturating_sub(3),
|
let columns = match self.document.horizontal_extent {
|
||||||
MouseEventKind::ScrollDown => top.saturating_add(3),
|
Some((id, revision, columns)) if (id, revision) == key => columns,
|
||||||
_ => unreachable!(),
|
_ => {
|
||||||
|
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() {
|
if self.document.manual.is_none() {
|
||||||
self.document.manual = Some(ManualViewport {
|
self.document.manual = Some(ManualViewport {
|
||||||
buffer_id,
|
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>(
|
fn draw_frame<W: io::Write>(
|
||||||
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
|
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
|
||||||
output: &mut W,
|
output: &mut W,
|
||||||
@@ -474,6 +521,7 @@ fn draw(
|
|||||||
.areas(viewport);
|
.areas(viewport);
|
||||||
let height = usize::from(viewport.height);
|
let height = usize::from(viewport.height);
|
||||||
let width = usize::from(viewport.width);
|
let width = usize::from(viewport.width);
|
||||||
|
document.text_width = width;
|
||||||
let cursor = editor.cursor();
|
let cursor = editor.cursor();
|
||||||
let mut document_caret = None;
|
let mut document_caret = None;
|
||||||
let carets = editor.carets();
|
let carets = editor.carets();
|
||||||
@@ -499,6 +547,13 @@ fn draw(
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
*top = (*top).min(editor.lines().len().saturating_sub(height));
|
*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 height > 0 && width > 0 {
|
||||||
if document.manual.is_none() {
|
if document.manual.is_none() {
|
||||||
if cursor.row < *top {
|
if cursor.row < *top {
|
||||||
@@ -724,6 +779,7 @@ fn draw(
|
|||||||
struct DiffGeometry {
|
struct DiffGeometry {
|
||||||
regions: [(Rect, Focus); 3],
|
regions: [(Rect, Focus); 3],
|
||||||
viewport: Option<(u64, usize, usize, usize, usize)>,
|
viewport: Option<(u64, usize, usize, usize, usize)>,
|
||||||
|
text_widths: [u16; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
|
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
|
||||||
@@ -1217,6 +1273,7 @@ fn draw_diff(
|
|||||||
(Rect::default(), Focus::After),
|
(Rect::default(), Focus::After),
|
||||||
],
|
],
|
||||||
viewport: None,
|
viewport: None,
|
||||||
|
text_widths: [0; 2],
|
||||||
});
|
});
|
||||||
if let Some(editor) = editor {
|
if let Some(editor) = editor {
|
||||||
draw_diff_popups(frame, editor, area, None, panels);
|
draw_diff_popups(frame, editor, area, None, panels);
|
||||||
@@ -1358,6 +1415,16 @@ fn draw_diff(
|
|||||||
comparison.map(|comparison| comparison.files.as_slice()),
|
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
|
if let Some(file) = selected
|
||||||
&& let Some(syntax) = &panels.diff_syntax
|
&& let Some(syntax) = &panels.diff_syntax
|
||||||
{
|
{
|
||||||
@@ -1372,8 +1439,13 @@ fn draw_diff(
|
|||||||
before_area,
|
before_area,
|
||||||
true,
|
true,
|
||||||
&syntax.before,
|
&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) =
|
if let Some(editor) =
|
||||||
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before))
|
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before))
|
||||||
{
|
{
|
||||||
@@ -1396,8 +1468,13 @@ fn draw_diff(
|
|||||||
after_area,
|
after_area,
|
||||||
false,
|
false,
|
||||||
&syntax.after,
|
&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) =
|
if let Some(editor) =
|
||||||
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After))
|
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After))
|
||||||
{
|
{
|
||||||
@@ -1439,6 +1516,7 @@ fn draw_diff(
|
|||||||
viewport: diff
|
viewport: diff
|
||||||
.comparison_id
|
.comparison_id
|
||||||
.map(|id| (id, diff.file, top, before_left, after_left)),
|
.map(|id| (id, diff.file, top, before_left, after_left)),
|
||||||
|
text_widths,
|
||||||
});
|
});
|
||||||
if let Some(picker) = &diff.files
|
if let Some(picker) = &diff.files
|
||||||
&& diff.files_visible
|
&& diff.files_visible
|
||||||
@@ -1476,7 +1554,7 @@ fn draw_diff_pane(
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
before: bool,
|
before: bool,
|
||||||
syntax: &SyntaxCache,
|
syntax: &SyntaxCache,
|
||||||
viewport: (usize, usize),
|
viewport: (usize, &mut usize, Option<u16>),
|
||||||
) -> Rect {
|
) -> Rect {
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return area;
|
return area;
|
||||||
@@ -1509,7 +1587,7 @@ fn draw_diff_pane(
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
let source = if before { &file.before } else { &file.after };
|
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 digits = source.len().max(1).ilog10() as usize + 1;
|
||||||
let gutter = if usize::from(content.width) > digits + 2 {
|
let gutter = if usize::from(content.width) > digits + 2 {
|
||||||
digits + 2
|
digits + 2
|
||||||
@@ -1517,6 +1595,10 @@ fn draw_diff_pane(
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
let width = usize::from(content.width).saturating_sub(gutter);
|
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
|
for (display_row, row) in diff
|
||||||
.display_rows
|
.display_rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1738,6 +1820,7 @@ struct WheelPanels {
|
|||||||
manual_files: Option<FileWheelOffset>,
|
manual_files: Option<FileWheelOffset>,
|
||||||
diff: Option<DiffGeometry>,
|
diff: Option<DiffGeometry>,
|
||||||
diff_syntax: Option<DiffSyntaxCache>,
|
diff_syntax: Option<DiffSyntaxCache>,
|
||||||
|
previous_diff: Option<DiffGeometry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FileWheelOffset {
|
struct FileWheelOffset {
|
||||||
@@ -1772,7 +1855,7 @@ impl WheelPanels {
|
|||||||
self.hover = None;
|
self.hover = None;
|
||||||
self.completion = None;
|
self.completion = None;
|
||||||
self.files = None;
|
self.files = None;
|
||||||
self.diff = None;
|
self.previous_diff = self.diff.take();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard/paste may change the catalog without changing its length.
|
// Keyboard/paste may change the catalog without changing its length.
|
||||||
@@ -1801,12 +1884,31 @@ impl WheelPanels {
|
|||||||
top
|
top
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A horizontal report over a popup must not pan the underlying buffer.
|
||||||
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
|
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
|
||||||
let down = match mouse.kind {
|
let Some(kind) = wheel_kind(&mouse) else {
|
||||||
MouseEventKind::ScrollDown => true,
|
return false;
|
||||||
MouseEventKind::ScrollUp => false,
|
|
||||||
_ => 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();
|
let editor = session.editor();
|
||||||
if editor.diff().is_some() {
|
if editor.diff().is_some() {
|
||||||
let position = Position::new(mouse.column, mouse.row);
|
let position = Position::new(mouse.column, mouse.row);
|
||||||
@@ -1817,8 +1919,16 @@ impl WheelPanels {
|
|||||||
.find_map(|(area, focus)| area.contains(position).then_some(*focus))
|
.find_map(|(area, focus)| area.contains(position).then_some(*focus))
|
||||||
});
|
});
|
||||||
if let Some(focus) = focus {
|
if let Some(focus) = focus {
|
||||||
for _ in 0..3 {
|
if horizontal {
|
||||||
session.scroll_diff(focus, down);
|
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;
|
return true;
|
||||||
@@ -4016,12 +4126,18 @@ mod tests {
|
|||||||
};
|
};
|
||||||
git(&["init", "-q"]);
|
git(&["init", "-q"]);
|
||||||
for index in 0..8 {
|
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(&["add", "."]);
|
||||||
git(&["commit", "-qm", "base"]);
|
git(&["commit", "-qm", "base"]);
|
||||||
for index in 0..8 {
|
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 mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||||
let original = session.editor().lines().to_vec();
|
let original = session.editor().lines().to_vec();
|
||||||
@@ -4054,6 +4170,30 @@ mod tests {
|
|||||||
let tree = regions[0].0;
|
let tree = regions[0].0;
|
||||||
let before = regions[1].0;
|
let before = regions[1].0;
|
||||||
let after = regions[2].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(
|
assert!(viewports.scroll(
|
||||||
&mut session,
|
&mut session,
|
||||||
document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1),
|
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]
|
#[test]
|
||||||
fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() {
|
fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user