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 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 `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. 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 - 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` 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. 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): **Mouse** (on the canvas; other views get click, double-click and scroll):
click selects, double-click edits the title (from the letter clicked), click selects, double-click edits the title (from the letter clicked),
dragging a node moves it with its branch, dragging empty space pans, scrolling dragging a node moves it with its branch, dragging empty space pans, the wheel
pans, Ctrl+drag selects a rectangle, Ctrl+click adds or removes one node, 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 right-click opens a menu built from the same actions as the keys. Zoom is on
the `+` and `-` keys. 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 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 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 ## Development
+4 -3
View File
@@ -40,9 +40,10 @@
//! again: done. A body is edited the same way, with Enter starting a line. //! 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 //! 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 //! branch, dragging empty space pans, the wheel scrolls (sideways: a wheel
//! title edits it from that letter. Right-click: the menu. A click on a name //! that tilts, a touchpad, or Shift or Alt with the wheel). A double-click on
//! in the document bar shows that document. //! 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::io;
use std::time::{Duration, Instant}; 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 cumulus::model::{Border, CanvasPos, Content, NodeId};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
struct Harness { struct Harness {
playground: Playground, playground: Playground,
@@ -59,6 +60,16 @@ impl Harness {
self.mouse(Gesture::RightPress { at: Cell { col, row } }); 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. /// The focused pane's document.
fn doc(&self) -> &Document { fn doc(&self) -> &Document {
self.playground.state.buffers[self.playground.buffer()].doc() 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_eq!(h.message(), "key hints off");
assert!(!h.screen().contains("? help"), "{}", h.screen()); 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 { let outcome = match gesture {
Gesture::Press { at, .. } => list.click(position(at)), 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); list.wheel(direction == Direction::Down);
ListOutcome::Stay ListOutcome::Stay
} }
@@ -742,7 +743,8 @@ impl Card {
}; };
vec![Action::App(AppAction::StartEdit(StartEdit::Body { at, in_card: true }))] 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 }); self.view.scroll_by(if direction == Direction::Down { 3 } else { -3 });
Vec::new() Vec::new()
} }
+33 -1
View File
@@ -32,7 +32,8 @@ pub enum Gesture {
Drag { from: Cell, at: Cell, ctrl: bool }, Drag { from: Cell, at: Cell, ctrl: bool },
/// The left button came up; `dragged` tells whether it moved first. /// The left button came up; `dragged` tells whether it moved first.
Release { at: Cell, dragged: bool }, 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 }, Scroll { at: Cell, direction: Direction },
} }
@@ -76,6 +77,15 @@ impl MouseReader {
self.pressed_at.take()?; self.pressed_at.take()?;
Some(Gesture::Release { at, dragged: std::mem::take(&mut self.dragged) }) 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::ScrollUp => Some(Gesture::Scroll { at, direction: Direction::Up }),
MouseEventKind::ScrollDown => Some(Gesture::Scroll { at, direction: Direction::Down }), MouseEventKind::ScrollDown => Some(Gesture::Scroll { at, direction: Direction::Down }),
MouseEventKind::ScrollLeft => Some(Gesture::Scroll { at, direction: Direction::Left }), 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -93,6 +108,23 @@ mod tests {
MouseEvent { kind, column: col, row, modifiers: KeyModifiers::NONE } 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] #[test]
fn presses_doubles_and_drags() { fn presses_doubles_and_drags() {
let mut mouse = MouseReader::default(); 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: //! 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` //! `j` and `k` swap it with its siblings, `h` takes it out a level, and `l`
//! puts it under the one above. //! 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::Frame;
use ratatui::layout::{Position, Rect}; use ratatui::layout::{Position, Rect};
@@ -46,6 +49,11 @@ use crate::view::{View, ViewContext, ViewKind, contexts_for};
const SCROLL_OFF: usize = 3; const SCROLL_OFF: usize = 3;
/// How many rows the mouse wheel scrolls. /// How many rows the mouse wheel scrolls.
const WHEEL_ROWS: usize = 3; 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. /// How many columns each level of the tree is indented.
const INDENT: u16 = 4; const INDENT: u16 = 4;
@@ -56,6 +64,8 @@ pub struct OutlineView {
area: Rect, area: Rect,
/// The first row on show. /// The first row on show.
scroll: usize, scroll: usize,
/// How many columns the rows are scrolled to the left.
side: u16,
/// Every body shows under its title (`zb`). /// Every body shows under its title (`zb`).
bodies: bool, bodies: bool,
/// The rows laid out last: what's drawn, and what a click lands on. /// The rows laid out last: what's drawn, and what a click lands on.
@@ -181,7 +191,8 @@ impl OutlineView {
Some(edit) => { Some(edit) => {
// The title as typed, scrolled so the text cursor shows. // The title as typed, scrolled so the text cursor shows.
let text = edit.editor.text(); 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 cursor_col = text[..edit.editor.cursor()].width();
let (shown, skip) = cut_left(text, (cursor_col + 1).saturating_sub(room)); let (shown, skip) = cut_left(text, (cursor_col + 1).saturating_sub(room));
let color = if edit.editor.is_typing() { theme.insert } else { theme.accent }; let color = if edit.editor.is_typing() { theme.insert } else { theme.accent };
@@ -247,10 +258,41 @@ impl OutlineView {
self.scrolled = true; 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)> { fn local(&self, cell: Cell) -> Option<(u16, usize)> {
let at = Position::new(cell.col, cell.row); 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. /// 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 editing = self.editing.as_ref()?;
let row = u16::try_from(editing.row.checked_sub(self.scroll)?).ok()?; 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 = 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) (row < self.area.height && col < self.area.width)
.then(|| Position::new(self.area.x + col, self.area.y + row)) .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 /// `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). /// 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) { 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<'_>) { fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
self.area = area; 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.scrolled = false;
} }
self.build(ctx); self.build(ctx);
if !self.scrolled || self.editing.is_some() { if !self.scrolled || self.editing.is_some() {
self.show_cursor_row(ctx.cursor); 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)); 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 buf = frame.buffer_mut();
let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height)); let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height));
for (row, y) in rows.zip(self.area.y..) { 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() { if let Some(cell) = self.text_cursor() {
frame.set_cursor_position(cell); frame.set_cursor_position(cell);
@@ -381,6 +461,7 @@ impl View for OutlineView {
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None, Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
} }
let rows = isize::try_from(typed.times()).unwrap_or(1); 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); 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 // hjkl follow the rows: the next and previous in reading order, the
// parent and the first child. // parent and the first child.
@@ -395,6 +476,8 @@ impl View for OutlineView {
Command::ScrollUp => self.scroll_by(-rows), Command::ScrollUp => self.scroll_by(-rows),
Command::HalfPageDown => self.scroll_by(half_page * rows), Command::HalfPageDown => self.scroll_by(half_page * rows),
Command::HalfPageUp => 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 => { Command::Center => {
if let Some(index) = self.rows.iter().position(|row| row.id == ctx.cursor && row.title) { 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)); self.scroll = index.saturating_sub(usize::from(self.area.height / 2));
@@ -446,7 +529,8 @@ impl View for OutlineView {
match direction { match direction {
Direction::Down => self.scroll_by(rows), Direction::Down => self.scroll_by(rows),
Direction::Up => 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() 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); let rows = draw(&mut view, &setup.ctx(&doc, g.compost), 40, 4);
assert_eq!(rows[2], "◦ ask about compost", "a row from the bottom"); 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");
}