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

+6 -2
View File
@@ -154,6 +154,8 @@ lines.
title, where its children hang, to read the document like a page. `j` and
`k` go row by row, `h` and `l` to the parent and the first child; a click
on a marker folds. Titles are edited in their row, rapid entry included.
Rows wider than the view scroll sideways (`zh` `zl`, the wheel sideways),
and the cursor's title comes back into view when the cursor moves.
- Move mode in the outline and the Note view moves the node through the
tree: `j` and `k` swap it with its siblings (`J` and `K` five places), `h`
takes it out a level, `l` puts it under the one above.
@@ -313,8 +315,10 @@ backlinks, and the backlink paths in every document it points to.
**Mouse** (on the canvas; other views get click, double-click and scroll):
click selects, double-click edits the title (from the letter clicked),
dragging a node moves it with its branch, dragging empty space pans, scrolling
pans, Ctrl+drag selects a rectangle, Ctrl+click adds or removes one node,
dragging a node moves it with its branch, dragging empty space pans, the wheel
pans up and down, and sideways with a wheel that tilts, a touchpad, or the
wheel with Shift or Alt held (Alt for terminals that keep Shift for selecting
text), Ctrl+drag selects a rectangle, Ctrl+click adds or removes one node,
right-click opens a menu built from the same actions as the keys. Zoom is on
the `+` and `-` keys.
+2 -1
View File
@@ -50,7 +50,8 @@ The plan for the everyday keys (all of them are in DESIGN.md):
After the first key of a sequence (`g`, `z`, `d`, `<Space>`…), a popup lists
what can follow. The right mouse button opens a menu of what you can do with
a node.
a node. The wheel scrolls; with Shift or Alt held (or a wheel that tilts), it
scrolls sideways.
## Development
+4 -3
View File
@@ -40,9 +40,10 @@
//! again: done. A body is edited the same way, with Enter starting a line.
//!
//! Mouse: click selects (and focuses a pane), dragging a node moves its
//! branch, dragging empty space pans, the wheel scrolls. A double-click on a
//! title edits it from that letter. Right-click: the menu. A click on a name
//! in the document bar shows that document.
//! branch, dragging empty space pans, the wheel scrolls (sideways: a wheel
//! that tilts, a touchpad, or Shift or Alt with the wheel). A double-click on
//! a title edits it from that letter. Right-click: the menu. A click on a
//! name in the document bar shows that document.
use std::io;
use std::time::{Duration, Instant};
+42
View File
@@ -7,6 +7,7 @@ use cumulus::input::keymap::DEFAULT_LEADER;
use cumulus::model::{Border, CanvasPos, Content, NodeId};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
struct Harness {
playground: Playground,
@@ -59,6 +60,16 @@ impl Harness {
self.mouse(Gesture::RightPress { at: Cell { col, row } });
}
/// A mouse event as the terminal sends it, read the way the event loop
/// reads it.
fn raw_mouse(&mut self, kind: MouseEventKind, (column, row): (u16, u16), modifiers: KeyModifiers) {
let event = MouseEvent { kind, column, row, modifiers };
if let Some(gesture) = self.playground.mouse.read(event, Instant::now()) {
self.playground.gesture(gesture);
}
self.draw();
}
/// The focused pane's document.
fn doc(&self) -> &Document {
self.playground.state.buffers[self.playground.buffer()].doc()
@@ -619,3 +630,34 @@ fn more_keys_across_views() {
assert_eq!(h.message(), "key hints off");
assert!(!h.screen().contains("? help"), "{}", h.screen());
}
#[test]
fn the_wheel_scrolls_sideways() {
let mut h = Harness::new();
let (col, row) = h.find("Watering");
// A wheel that tilts, or a touchpad: the canvas pans sideways.
h.raw_mouse(MouseEventKind::ScrollRight, (col, row), KeyModifiers::NONE);
assert_eq!(h.find("Watering"), (col - 6, row));
// Shift or Alt with the wheel: the same, for mice that don't tilt.
h.raw_mouse(MouseEventKind::ScrollUp, (col, row), KeyModifiers::SHIFT);
assert_eq!(h.find("Watering"), (col, row));
h.raw_mouse(MouseEventKind::ScrollDown, (col, row), KeyModifiers::ALT);
assert_eq!(h.find("Watering"), (col - 6, row));
// The wheel on its own still scrolls up and down.
h.raw_mouse(MouseEventKind::ScrollDown, (col, row), KeyModifiers::NONE);
assert_eq!(h.find("Watering"), (col - 6, row - 3));
// The outline, in a narrow pane: rows cut at its edge scroll sideways.
h.type_keys("<C-w>v<C-w>v<Space>vogt");
assert!(h.screen().contains("outline · Kitchen"));
let (col, row) = h.find("Mint, in");
h.raw_mouse(MouseEventKind::ScrollRight, (col, row), KeyModifiers::NONE);
assert_eq!(h.find("Mint, in"), (col - 6, row), "the wheel sideways");
h.type_keys("zh");
assert_eq!(h.find("Mint, in"), (col, row), "zh: back");
// In a popup, the wheel sideways does nothing (it used to move it up).
h.type_keys("?j");
let before = h.screen();
h.raw_mouse(MouseEventKind::ScrollLeft, (50, 15), KeyModifiers::NONE);
assert_eq!(h.screen(), before);
}
+4 -2
View File
@@ -470,7 +470,8 @@ impl Popup {
};
let outcome = match gesture {
Gesture::Press { at, .. } => list.click(position(at)),
Gesture::Scroll { direction, .. } => {
// Up and down only: a list has nothing to the side.
Gesture::Scroll { direction: direction @ (Direction::Up | Direction::Down), .. } => {
list.wheel(direction == Direction::Down);
ListOutcome::Stay
}
@@ -742,7 +743,8 @@ impl Card {
};
vec![Action::App(AppAction::StartEdit(StartEdit::Body { at, in_card: true }))]
}
Gesture::Scroll { direction, .. } => {
// Up and down only: the card wraps its text, so nothing is to the side.
Gesture::Scroll { direction: direction @ (Direction::Up | Direction::Down), .. } => {
self.view.scroll_by(if direction == Direction::Down { 3 } else { -3 });
Vec::new()
}
+33 -1
View File
@@ -32,7 +32,8 @@ pub enum Gesture {
Drag { from: Cell, at: Cell, ctrl: bool },
/// The left button came up; `dragged` tells whether it moved first.
Release { at: Cell, dragged: bool },
/// The wheel turned (sideways on a touchpad).
/// The wheel turned: up or down, or sideways (a wheel that tilts, a
/// touchpad, or the wheel with Shift or Alt held).
Scroll { at: Cell, direction: Direction },
}
@@ -76,6 +77,15 @@ impl MouseReader {
self.pressed_at.take()?;
Some(Gesture::Release { at, dragged: std::mem::take(&mut self.dragged) })
}
// The wheel with Shift held scrolls sideways, as in most apps, for
// mice without a sideways wheel. Alt does too: some terminals
// keep Shift with the mouse for themselves, to select text.
MouseEventKind::ScrollUp if sideways(event.modifiers) => {
Some(Gesture::Scroll { at, direction: Direction::Left })
}
MouseEventKind::ScrollDown if sideways(event.modifiers) => {
Some(Gesture::Scroll { at, direction: Direction::Right })
}
MouseEventKind::ScrollUp => Some(Gesture::Scroll { at, direction: Direction::Up }),
MouseEventKind::ScrollDown => Some(Gesture::Scroll { at, direction: Direction::Down }),
MouseEventKind::ScrollLeft => Some(Gesture::Scroll { at, direction: Direction::Left }),
@@ -85,6 +95,11 @@ impl MouseReader {
}
}
/// Whether the wheel turns sideways with these keys held.
fn sideways(modifiers: KeyModifiers) -> bool {
modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -93,6 +108,23 @@ mod tests {
MouseEvent { kind, column: col, row, modifiers: KeyModifiers::NONE }
}
#[test]
fn the_wheel_scrolls_sideways_on_its_own_or_with_shift_or_alt() {
let mut mouse = MouseReader::default();
let now = Instant::now();
let at = Cell { col: 2, row: 3 };
let scroll = |direction| Some(Gesture::Scroll { at, direction });
let with = |kind, modifiers| MouseEvent { kind, column: 2, row: 3, modifiers };
assert_eq!(mouse.read(event(MouseEventKind::ScrollLeft, 2, 3), now), scroll(Direction::Left));
assert_eq!(mouse.read(event(MouseEventKind::ScrollRight, 2, 3), now), scroll(Direction::Right));
let shift_up = with(MouseEventKind::ScrollUp, KeyModifiers::SHIFT);
assert_eq!(mouse.read(shift_up, now), scroll(Direction::Left), "Shift and the wheel up: left");
let alt_down = with(MouseEventKind::ScrollDown, KeyModifiers::ALT);
assert_eq!(mouse.read(alt_down, now), scroll(Direction::Right), "Alt and the wheel down: right");
let ctrl_down = with(MouseEventKind::ScrollDown, KeyModifiers::CONTROL);
assert_eq!(mouse.read(ctrl_down, now), scroll(Direction::Down), "Ctrl doesn't turn it");
}
#[test]
fn presses_doubles_and_drags() {
let mut mouse = MouseReader::default();
+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");
}