diff --git a/DESIGN.md b/DESIGN.md index eb3bd45..7e1be06 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/README.md b/README.md index a54d4ad..a5a17c7 100644 --- a/README.md +++ b/README.md @@ -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`, ``…), 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 diff --git a/examples/canvas.rs b/examples/canvas.rs index 4b4e02e..f51e84b 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -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}; diff --git a/examples/canvas/tests.rs b/examples/canvas/tests.rs index 69bf4ce..379668a 100644 --- a/examples/canvas/tests.rs +++ b/examples/canvas/tests.rs @@ -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("vvvogt"); + 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); +} diff --git a/src/app/popup.rs b/src/app/popup.rs index dcc6cc4..99d127d 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -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() } diff --git a/src/input/mouse.rs b/src/input/mouse.rs index f55b653..a7c9769 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -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(); diff --git a/src/view/outline.rs b/src/view/outline.rs index faabe25..2600e13 100644 --- a/src/view/outline.rs +++ b/src/view/outline.rs @@ -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() } diff --git a/src/view/outline/tests.rs b/src/view/outline/tests.rs index 52c7512..17c4526 100644 --- a/src/view/outline/tests.rs +++ b/src/view/outline/tests.rs @@ -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"); +}