Scroll sideways with Shift or Alt and the wheel, and in the outline

A wheel that tilts (or a touchpad) already panned the canvas sideways,
but only when the terminal passes those events on, and many don't:
Windows Terminal only does since 1.24, and Windows' own console layer
can drop them. The wheel with Shift held now scrolls sideways too, as in
most apps, and so does Alt, since Alacritty and Windows Terminal keep
Shift with the mouse for selecting text.

The outline scrolls sideways as well (the wheel, zh and zl), for rows
wider than its pane: a deep tree or a long title. Clicks land where the
rows are, however far they're scrolled, and when the cursor moves its
title comes back into view.

Fixed on the way: the wheel turned sideways over the help, the menu, a
picker or the K card moved it up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 19:48:52 -04:00
1 parent c587c42115
commit e6f3c06ecc
8 files changed
+203 -16

No files matched your search

+91 -7
View File
@@ -21,6 +21,9 @@
//! In move mode a node moves through the tree rather than across the canvas:
//! `j` and `k` swap it with its siblings, `h` takes it out a level, and `l`
//! puts it under the one above.
//!
//! Rows wider than the view (a deep tree, a long title) are cut at its right
//! edge; `zh` and `zl` or the wheel sideways scroll them.
use ratatui::Frame;
use ratatui::layout::{Position, Rect};
@@ -46,6 +49,11 @@ use crate::view::{View, ViewContext, ViewKind, contexts_for};
const SCROLL_OFF: usize = 3;
/// How many rows the mouse wheel scrolls.
const WHEEL_ROWS: usize = 3;
/// How many columns `zh`, `zl` and the wheel sideways scroll.
const SCROLL_COLS: u16 = 6;
/// How many columns of a title stay in view, at least, when the outline
/// scrolls sideways to show the cursor's row.
const TITLE_ROOM: u16 = 12;
/// How many columns each level of the tree is indented.
const INDENT: u16 = 4;
@@ -56,6 +64,8 @@ pub struct OutlineView {
area: Rect,
/// The first row on show.
scroll: usize,
/// How many columns the rows are scrolled to the left.
side: u16,
/// Every body shows under its title (`zb`).
bodies: bool,
/// The rows laid out last: what's drawn, and what a click lands on.
@@ -181,7 +191,8 @@ impl OutlineView {
Some(edit) => {
// The title as typed, scrolled so the text cursor shows.
let text = edit.editor.text();
let room = usize::from(self.area.width.saturating_sub(text_col)).max(1);
let room =
usize::from(self.area.width.saturating_add(self.side).saturating_sub(text_col)).max(1);
let cursor_col = text[..edit.editor.cursor()].width();
let (shown, skip) = cut_left(text, (cursor_col + 1).saturating_sub(room));
let color = if edit.editor.is_typing() { theme.insert } else { theme.accent };
@@ -247,10 +258,41 @@ impl OutlineView {
self.scrolled = true;
}
/// The row and column under a terminal cell, if it's in the view.
/// Scrolls sideways by some columns, left when negative: as far as it
/// takes for the widest row to end at the right edge.
fn scroll_side(&mut self, cols: i32) {
let side = i32::from(self.side).saturating_add(cols).clamp(0, i32::from(self.widest_overhang()));
self.side = u16::try_from(side).unwrap_or(0);
}
/// How far the widest row goes past the view's right edge.
fn widest_overhang(&self) -> u16 {
let widest = self.rows.iter().map(|row| row.line.width()).max().unwrap_or(0);
u16::try_from(widest).unwrap_or(u16::MAX).saturating_sub(self.area.width)
}
/// Scrolls sideways, if it has to, so the start of the cursor's title is
/// in view with some of the title after it. Returns whether it scrolled.
fn show_cursor_title(&mut self, cursor: NodeId) -> bool {
let Some(row) = self.rows.iter().find(|row| row.id == cursor && row.title) else { return false };
let before = self.side;
let room = TITLE_ROOM.min(self.area.width / 2);
if row.text_col < self.side {
self.side = row.text_col.saturating_sub(INDENT);
} else if row.text_col + room > self.side + self.area.width {
self.side = row.text_col + room - self.area.width;
}
self.side != before
}
/// The row and column under a terminal cell, if it's in the view: the
/// column counted from the rows' start, however far they're scrolled.
fn local(&self, cell: Cell) -> Option<(u16, usize)> {
let at = Position::new(cell.col, cell.row);
self.area.contains(at).then(|| (at.x - self.area.x, self.scroll + usize::from(at.y - self.area.y)))
self.area.contains(at).then(|| {
let col = (at.x - self.area.x).saturating_add(self.side);
(col, self.scroll + usize::from(at.y - self.area.y))
})
}
/// Where a click lands in the title being edited, if it's on its text.
@@ -266,7 +308,7 @@ impl OutlineView {
let editing = self.editing.as_ref()?;
let row = u16::try_from(editing.row.checked_sub(self.scroll)?).ok()?;
let col = usize::from(editing.text_col) + editing.cursor_col - editing.skip;
let col = u16::try_from(col).ok()?;
let col = u16::try_from(col.checked_sub(usize::from(self.side))?).ok()?;
(row < self.area.height && col < self.area.width)
.then(|| Position::new(self.area.x + col, self.area.y + row))
}
@@ -325,6 +367,31 @@ impl Lines {
}
}
/// A row without its first `columns` columns: what shows when the outline
/// is scrolled sideways.
fn skip_columns(line: &Line<'static>, columns: usize) -> Line<'static> {
if columns == 0 {
return line.clone();
}
let mut left = columns;
let mut spans = Vec::new();
for span in &line.spans {
let width = span.content.width();
if left == 0 {
spans.push(span.clone());
} else if width <= left {
left -= width;
} else {
let (rest, cut) = cut_left(&span.content, left);
// A wide character cut in half leaves its other half blank.
let blank = " ".repeat(cut - left);
spans.push(Span::styled(format!("{blank}{rest}"), span.style));
left = 0;
}
}
Line::from(spans)
}
/// `text` without its first `columns` columns, and how many columns that
/// took off (a wide character isn't cut in half, so it can be one more).
fn cut_left(text: &str, columns: usize) -> (&str, usize) {
@@ -353,13 +420,25 @@ impl View for OutlineView {
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
self.area = area;
if self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor) {
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
if moved {
self.scrolled = false;
}
self.build(ctx);
if !self.scrolled || self.editing.is_some() {
self.show_cursor_row(ctx.cursor);
}
// Sideways, the cursor's title comes into view when the cursor
// moves, and stays in view while it's edited (and the title being
// typed is laid out for where the rows are, so it's laid out again).
let before = self.side;
if moved || self.editing.is_some() {
self.show_cursor_title(ctx.cursor);
}
self.side = self.side.min(self.widest_overhang());
if self.side != before && self.editing.is_some() {
self.build(ctx);
}
self.scroll = self.scroll.min(self.rows.len().saturating_sub(1));
}
@@ -367,7 +446,8 @@ impl View for OutlineView {
let buf = frame.buffer_mut();
let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height));
for (row, y) in rows.zip(self.area.y..) {
buf.set_line(self.area.x, y, &row.line, self.area.width);
let line = skip_columns(&row.line, usize::from(self.side));
buf.set_line(self.area.x, y, &line, self.area.width);
}
if let Some(cell) = self.text_cursor() {
frame.set_cursor_position(cell);
@@ -381,6 +461,7 @@ impl View for OutlineView {
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
}
let rows = isize::try_from(typed.times()).unwrap_or(1);
let cols = i32::from(SCROLL_COLS) * i32::try_from(typed.times()).unwrap_or(1);
let half_page = isize::try_from(self.area.height / 2).unwrap_or(1).max(1);
// hjkl follow the rows: the next and previous in reading order, the
// parent and the first child.
@@ -395,6 +476,8 @@ impl View for OutlineView {
Command::ScrollUp => self.scroll_by(-rows),
Command::HalfPageDown => self.scroll_by(half_page * rows),
Command::HalfPageUp => self.scroll_by(-half_page * rows),
Command::ScrollLeft => self.scroll_side(-cols),
Command::ScrollRight => self.scroll_side(cols),
Command::Center => {
if let Some(index) = self.rows.iter().position(|row| row.id == ctx.cursor && row.title) {
self.scroll = index.saturating_sub(usize::from(self.area.height / 2));
@@ -446,7 +529,8 @@ impl View for OutlineView {
match direction {
Direction::Down => self.scroll_by(rows),
Direction::Up => self.scroll_by(-rows),
Direction::Left | Direction::Right => {}
Direction::Left => self.scroll_side(-i32::from(SCROLL_COLS)),
Direction::Right => self.scroll_side(i32::from(SCROLL_COLS)),
}
Vec::new()
}
+21
View File
@@ -153,3 +153,24 @@ fn the_view_follows_the_cursor() {
let rows = draw(&mut view, &setup.ctx(&doc, g.compost), 40, 4);
assert_eq!(rows[2], "◦ ask about compost", "a row from the bottom");
}
#[test]
fn wide_rows_scroll_sideways() {
let ((doc, g), setup) = (garden(), Setup::default());
let mut view = OutlineView::new();
let ctx = setup.ctx(&doc, g.root);
assert_eq!(draw(&mut view, &ctx, 12, 7)[5], "◦ ask about", "cut at the edge");
view.command(&typed(Command::ScrollRight), &ctx);
assert_eq!(draw(&mut view, &ctx, 12, 7)[5], "about compos", "zl: six columns");
view.command(&typed(Command::ScrollRight), &ctx);
assert_eq!(draw(&mut view, &ctx, 12, 7)[5], "bout compost", "as far as the widest row goes");
view.mouse(Gesture::Scroll { at: Cell { col: 1, row: 1 }, direction: Direction::Left }, &ctx);
assert_eq!(draw(&mut view, &ctx, 12, 7)[5], " ask about c", "the wheel sideways");
// Clicks land where the rows are, scrolled: Raised beds' marker is a
// column further left.
let fold = Action::edit(EditOp::SetFold { ids: vec![g.beds], fold: Fold::Closed });
let press = Gesture::Press { at: Cell { col: 3, row: 2 }, ctrl: false };
assert_eq!(view.mouse(press, &ctx), [Action::cursor(g.beds), fold]);
// When the cursor moves, its title comes into view.
assert_eq!(draw(&mut view, &setup.ctx(&doc, g.cedar), 12, 7)[3], "│ └ Cedar");
}