Add visual mode, node registers, search and the jump list (stage 8c)

The rest of vim, on nodes. Visual mode takes in what lies between where
it started and the cursor: on the canvas, every node the rectangle from
one end's box to the other's touches; in the outline and the Note view,
the nodes in between in reading order. V takes whole branches, Ctrl+click
and Ctrl+drag pick with the mouse, and the operators take exactly what's
selected: d, y, > and <, M, and the pickers on every node at once.

Registers now hold text or nodes, so yy, dd and x keep what they take and
p, P and ]p paste it, layout and links included. "+ is the system
clipboard through arboard: nodes go there as Markdown in the user's
format (## headings, a --- tag list, the body), and come back whole while
the clipboard still holds them. clipboard = "unnamedplus" makes plain
yanks use it, as in vim.

. repeats the last change made with keys, with the text typed after it.
/ opens a Telescope-style search: fuzzy matches over titles, tags and
bodies, best first, with the note of the one you're on beside them; n
and N go on with it, and :find searches straight away. Each pane keeps a
jump list for Ctrl-o and Ctrl-i that crosses documents and reopens closed
ones. Capital marks lead into any document and, like the small ones, are
kept in memory.ron. gd follows a cross-link, and :marks, :jumps and
:registers list what they hold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 04:12:27 -04:00
1 parent abe3dc1d84
commit 7bec5bf3fc
45 files changed
+3922 -272

No files matched your search

+14
View File
@@ -70,6 +70,20 @@ impl ScreenRect {
(self.left()..self.right()).contains(&cell.col) && (self.top()..self.bottom()).contains(&cell.row)
}
/// The rectangle with two cells as its opposite corners.
pub fn spanning(a: ScreenPos, b: ScreenPos) -> ScreenRect {
let (left, top) = (a.col.min(b.col), a.row.min(b.row));
ScreenRect::new(left, top, a.col.max(b.col) - left + 1, a.row.max(b.row) - top + 1)
}
/// Whether the two rectangles share at least one cell.
pub fn overlaps(self, other: ScreenRect) -> bool {
self.left() < other.right()
&& other.left() < self.right()
&& self.top() < other.bottom()
&& other.top() < self.bottom()
}
/// The rectangle grown by `cols` on the left and right.
pub fn widened(self, cols: i32) -> ScreenRect {
ScreenRect::new(self.left() - cols, self.top(), self.width + 2 * cols, self.height)
+13 -4
View File
@@ -29,8 +29,11 @@ impl Widget for &Scene {
}
Shape::Label(text) => self.draw_label(&mut painter, node, text),
Shape::Dot => {
let style =
if node.selected { self.selected_style() } else { Style::new().fg(node.color) };
let style = match (node.selected, node.picked) {
(true, _) => self.selected_style(),
(false, true) => self.picked_style(),
(false, false) => Style::new().fg(node.color),
};
painter.put(node.rect.pos, self.glyphs.bullet(), style);
}
}
@@ -88,8 +91,9 @@ impl Scene {
/// Far zoom: a colored bullet and the title on one line.
fn draw_label(&self, painter: &mut Painter<'_>, node: &SceneNode, text: &str) {
if node.selected {
painter.text(node.rect.pos, text, self.selected_style(), |_| true);
if node.selected || node.picked {
let style = if node.selected { self.selected_style() } else { self.picked_style() };
painter.text(node.rect.pos, text, style, |_| true);
return;
}
let mut chars = text.chars();
@@ -105,6 +109,11 @@ impl Scene {
fn selected_style(&self) -> Style {
Style::new().fg(Color::Black).bg(self.accent)
}
/// A selected node when zoomed out: dark text on the visual color.
fn picked_style(&self) -> Style {
Style::new().fg(Color::Black).bg(self.visual)
}
}
/// Writes into the part of the buffer the canvas covers, in screen cells
+24 -8
View File
@@ -2,7 +2,7 @@
//! document, the cursor, the camera and the theme. Clicks are looked up in
//! the same scene, so what you click is what you see.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
use ratatui::layout::Size;
@@ -13,12 +13,14 @@ use super::boxes::{NodeBox, draft_box, truncate};
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
use super::lines::{LineLayer, LineOwner, Paint};
use super::route::{Bend, route};
use crate::app::mode::Mode;
use crate::app::text_edit::TextEdit;
use crate::app::visual::selection;
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
use crate::ui::glyphs::Glyphs;
use crate::ui::text_editor::layout;
use crate::ui::theme::{LineStyle, Theme};
use crate::view::ViewContext;
use crate::view::{ViewContext, ViewKind};
/// How many columns of a title show at Far zoom.
const FAR_TITLE_WIDTH: usize = 14;
@@ -48,6 +50,8 @@ pub struct SceneNode {
pub faded: bool,
/// Whether the cursor is on it.
pub selected: bool,
/// Whether it's in visual mode's selection.
pub picked: bool,
}
/// Text drawn over the lines: an arrowhead or a label.
@@ -76,6 +80,8 @@ pub struct Scene {
pub(super) glyphs: Glyphs,
pub(super) accent: Color,
pub(super) muted: Color,
/// What's selected in visual mode.
pub(super) visual: Color,
/// The border of the cursor's box: the accent, or the typing color while
/// its title is typed.
cursor_color: Color,
@@ -100,12 +106,17 @@ impl Scene {
let edit = ctx.mode.title_edit();
let typing = edit.is_some_and(|edit| edit.editor.is_typing());
let visible = Visible::new(doc, cursor, settings.focus);
let picked: BTreeSet<NodeId> = match ctx.mode {
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Canvas).into_iter().collect(),
_ => BTreeSet::new(),
};
let mut scene = Scene {
size,
zoom: camera.zoom,
glyphs: theme.glyphs,
accent: theme.accent,
muted: theme.muted,
visual: theme.visual,
cursor_color: if typing { theme.insert } else { theme.accent },
covered: vec![None; size.area() as usize],
lines: LineLayer::new(size),
@@ -120,8 +131,9 @@ impl Scene {
Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width),
None => shape(node, badge, style.border, camera, theme.glyphs, max_width),
};
let (color, faded, selected) = (style.color, style.faded, id == cursor);
scene.nodes.push(SceneNode { id, rect, shape, color, faded, selected });
let (color, faded, selected, picked) =
(style.color, style.faded, id == cursor, picked.contains(&id));
scene.nodes.push(SceneNode { id, rect, shape, color, faded, selected, picked });
}
// The cursor's node goes last, on top of any it overlaps.
if let Some(index) = scene.nodes.iter().position(|node| node.selected) {
@@ -219,13 +231,17 @@ impl Scene {
/// Adds the boxes' borders to the line layer, so lines joining them get
/// the right characters. The cursor's box is thick, in the accent color
/// (the typing color while its title is typed).
/// (the typing color while its title is typed); the other selected
/// boxes are thick in the visual color.
fn add_borders(&mut self) {
let Scene { nodes, covered, lines, size, cursor_color, .. } = self;
let Scene { nodes, covered, lines, size, cursor_color, visual, .. } = self;
for (index, node) in nodes.iter().enumerate() {
let Shape::Box { border, .. } = &node.shape else { continue };
let (border, color) =
if node.selected { (Border::Thick, *cursor_color) } else { (*border, node.color) };
let (border, color) = match (node.selected, node.picked) {
(true, _) => (Border::Thick, *cursor_color),
(false, true) => (Border::Thick, *visual),
(false, false) => (*border, node.color),
};
let paint = Paint { color, owner: LineOwner::Border(node.id) };
lines.add_border(node.rect, border, paint, |at| covered_at(covered, *size, at) == Some(index));
}
+82 -12
View File
@@ -4,6 +4,7 @@
use ratatui::Frame;
use ratatui::layout::{Position, Rect};
use ratatui::style::Style;
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
use super::navigate::nearest;
@@ -39,6 +40,9 @@ enum Drag {
Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, group: Group },
/// Panning the view: the cell the mouse was last on.
Pan { last: ScreenPos },
/// Selecting with Ctrl held: the rectangle from where the drag started
/// to where the mouse is.
Band { from: ScreenPos, to: ScreenPos },
}
/// The canvas view.
@@ -140,7 +144,7 @@ impl CanvasView {
/// Move mode: hjkl moves the cursor's node, or its whole branch, and
/// every move until the mode ends is one undo step.
fn move_command(&mut self, typed: &Typed, grab: Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
fn move_command(&mut self, typed: &Typed, grab: &Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
let step = cells(typed);
let (cols, rows) = match typed.command {
Command::Move(Motion::Left) => (-step, 0),
@@ -156,10 +160,40 @@ impl CanvasView {
_ => return None,
};
self.reveal = true;
let op = EditOp::Shift { id: ctx.cursor, offset: self.camera.cells(cols, rows), branch: grab.whole };
let ids = if grab.nodes.is_empty() { vec![ctx.cursor] } else { grab.nodes.clone() };
let op = EditOp::Shift { ids, offset: self.camera.cells(cols, rows), branch: grab.whole };
Some(vec![Action::edit_in(op, grab.group)])
}
/// The outline of a Ctrl+drag's rectangle, in the visual color.
fn draw_band(&self, frame: &mut Frame, band: ScreenRect, ctx: &ViewContext<'_>) {
let set = ctx.theme.glyphs.frame(false);
let style = Style::new().fg(ctx.theme.visual);
let area = self.area;
let buf = frame.buffer_mut();
let mut put = |col: i32, row: i32, symbol: &str| {
if let (Ok(col), Ok(row)) = (u16::try_from(col), u16::try_from(row))
&& col < area.width
&& row < area.height
{
buf[(area.x + col, area.y + row)].set_symbol(symbol).set_style(style);
}
};
let (left, top, right, bottom) = (band.left(), band.top(), band.right() - 1, band.bottom() - 1);
for col in left..=right {
put(col, top, set.horizontal_top);
put(col, bottom, set.horizontal_bottom);
}
for row in top..=bottom {
put(left, row, set.vertical_left);
put(right, row, set.vertical_right);
}
put(left, top, set.top_left);
put(right, top, set.top_right);
put(left, bottom, set.bottom_left);
put(right, bottom, set.bottom_right);
}
/// The view's cell under a terminal cell.
fn screen(&self, cell: Cell) -> ScreenPos {
ScreenPos::new(
@@ -178,13 +212,17 @@ impl CanvasView {
return Vec::new();
}
*moved = total;
vec![Action::edit_in(EditOp::Shift { id: *id, offset: step, branch: true }, *group)]
vec![Action::edit_in(EditOp::Shift { ids: vec![*id], offset: step, branch: true }, *group)]
}
Some(Drag::Pan { last }) => {
self.camera.pan(last.col - at.col, last.row - at.row);
*last = at;
Vec::new()
}
Some(Drag::Band { to, .. }) => {
*to = at;
Vec::new()
}
None => Vec::new(),
}
}
@@ -222,7 +260,11 @@ impl View for CanvasView {
}
fn contexts(&self, mode: &Mode) -> &'static [Context] {
contexts_for(mode, &[Context::Canvas, Context::Normal, Context::Motions])
contexts_for(
mode,
&[Context::Canvas, Context::Normal, Context::Motions],
&[Context::Visual, Context::Canvas, Context::Motions],
)
}
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
@@ -250,8 +292,11 @@ impl View for CanvasView {
}
}
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) {
frame.render_widget(&self.scene, self.area);
if let Some(Drag::Band { from, to }) = self.drag {
self.draw_band(frame, ScreenRect::spanning(from, to), ctx);
}
if let Some(cell) = self.scene.text_cursor()
&& let (Ok(col), Ok(row)) = (u16::try_from(cell.col), u16::try_from(cell.row))
&& col < self.area.width
@@ -263,10 +308,10 @@ impl View for CanvasView {
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode {
Mode::Move(grab) => return self.move_command(typed, *grab, ctx),
Mode::Move(grab) => return self.move_command(typed, grab, ctx),
// Keys inside a title are the edit's, not the view's.
Mode::Text(_) | Mode::Popup(_) => return None,
Mode::Normal | Mode::Command(_) => {}
Mode::Normal | Mode::Visual(_) | Mode::Command(_) => {}
}
let steps = cells(typed);
let half_page = i32::from(self.area.height / 2);
@@ -289,6 +334,18 @@ impl View for CanvasView {
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
match gesture {
// Ctrl+click: a node into the selection or out of it; on empty
// space, Ctrl+drag selects what its rectangle touches.
Gesture::Press { at, ctrl: true } if ctx.mode.edit().is_none() => {
let at = self.screen(at);
match self.scene.node_at(at) {
Some(id) => vec![Action::App(AppAction::ToggleSelected(id))],
None => {
self.drag = Some(Drag::Band { from: at, to: at });
Vec::new()
}
}
}
Gesture::Press { at, .. } => {
let at = self.screen(at);
let Mode::Text(edit) = ctx.mode else { return self.press(at) };
@@ -317,10 +374,22 @@ impl View for CanvasView {
actions
}
Gesture::Drag { at, .. } => self.drag_to(self.screen(at)),
Gesture::Release { .. } => {
self.drag = None;
Vec::new()
}
Gesture::Release { .. } => match self.drag.take() {
Some(Drag::Band { from, to }) => {
let band = ScreenRect::spanning(from, to);
let caught: Vec<NodeId> = self
.scene
.nodes()
.iter()
.filter(|node| node.rect.overlaps(band))
.map(|node| node.id)
.collect();
let ids: Vec<NodeId> =
ctx.doc.outline().into_iter().filter(|id| caught.contains(id)).collect();
if ids.is_empty() { Vec::new() } else { vec![Action::App(AppAction::SelectAlso(ids))] }
}
_ => Vec::new(),
},
Gesture::Scroll { direction, .. } => {
let (cols, rows) = match direction {
Direction::Up => (0, -SCROLL_ROWS),
@@ -418,7 +487,8 @@ mod tests {
let mut buffer = Buffer::new(doc);
buffer.apply(DocAction::MoveCursor { to: g.beds, jump: false });
for command in [Command::Move(Motion::Right), Command::FarDown] {
for action in view.command(&typed(command, Some(2)), &setup.ctx(buffer.doc(), g.beds)).unwrap() {
let ctx = setup.ctx(buffer.doc(), g.beds);
for action in view.command(&typed(command, Some(2)), &ctx).unwrap() {
let Action::Doc(action) = action else { panic!("only edits") };
buffer.apply(action);
}
+8 -2
View File
@@ -96,10 +96,16 @@ impl ViewContext<'_> {
}
/// The key tables a view reads in a mode: `normal` in normal mode (its own
/// table first), and what every view reads in the other modes.
pub fn contexts_for(mode: &Mode, normal: &'static [Context]) -> &'static [Context] {
/// table first), `visual` in visual mode, and what every view reads in the
/// other modes.
pub fn contexts_for(
mode: &Mode,
normal: &'static [Context],
visual: &'static [Context],
) -> &'static [Context] {
match mode {
Mode::Normal => normal,
Mode::Visual(_) => visual,
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
Mode::Text(_) => &[Context::Text],
Mode::Move(_) => &[Context::Move],
+16 -4
View File
@@ -80,6 +80,8 @@ pub struct NoteView {
children: Vec<NodeId>,
/// The part being edited, as drawn.
editing: Option<Editing>,
/// A preview (the search's): no bar marks a part.
preview: bool,
}
/// One row of the note, ready to draw.
@@ -107,6 +109,12 @@ impl NoteView {
NoteView::default()
}
/// A note to look at, not to move through: the search shows the note
/// of the match you're on this way.
pub fn preview() -> NoteView {
NoteView { preview: true, ..NoteView::default() }
}
/// How wide the text of the note can be.
fn text_width(&self) -> usize {
usize::from(self.area.width.saturating_sub(MARGIN)).max(8)
@@ -352,7 +360,11 @@ impl View for NoteView {
}
fn contexts(&self, mode: &Mode) -> &'static [Context] {
contexts_for(mode, &[Context::Note, Context::Normal, Context::Motions])
contexts_for(
mode,
&[Context::Note, Context::Normal, Context::Motions],
&[Context::Visual, Context::Motions],
)
}
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
@@ -394,7 +406,7 @@ impl View for NoteView {
let width = self.area.width.saturating_sub(MARGIN);
let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height));
for (row, y) in rows.zip(self.area.y..) {
if row.part == Some(self.part) {
if row.part == Some(self.part) && !self.preview {
buf.set_string(self.area.x, y, &bar, bar_style);
}
buf.set_line(self.area.x + MARGIN, y, &row.line, width);
@@ -406,8 +418,8 @@ impl View for NoteView {
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode {
Mode::Move(grab) => return commands::reorder(typed, *grab, ctx),
Mode::Normal => {}
Mode::Move(grab) => return commands::reorder(typed, grab, ctx),
Mode::Normal | Mode::Visual(_) => {}
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
}
let times = isize::try_from(typed.times()).unwrap_or(1);
+18 -3
View File
@@ -25,6 +25,8 @@
//! 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 std::collections::BTreeSet;
use ratatui::Frame;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Modifier, Style};
@@ -36,6 +38,7 @@ use crate::app::commands;
use crate::app::mode::Mode;
use crate::app::popup::MenuFor;
use crate::app::text_edit::{EditAt, StartEdit, TextEdit};
use crate::app::visual::selection;
use crate::input::bindings::Context;
use crate::input::command::{Command, Motion};
use crate::input::engine::Typed;
@@ -76,6 +79,8 @@ pub struct OutlineView {
scrolled: bool,
/// The title being edited, as drawn.
editing: Option<Editing>,
/// What's selected in visual mode.
picked: BTreeSet<NodeId>,
}
/// One row, ready to draw.
@@ -117,6 +122,10 @@ impl OutlineView {
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus);
let edit = ctx.mode.title_edit();
self.picked = match ctx.mode {
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(),
_ => BTreeSet::new(),
};
// The color of the lines from a node to its children, as on the
// canvas: the node's own, or dim when it has none.
let line_style = |id: NodeId| {
@@ -185,6 +194,8 @@ impl OutlineView {
let muted = Style::new().fg(theme.muted);
let title_style = if id == ctx.cursor {
Style::new().fg(Color::Black).bg(theme.accent).add_modifier(Modifier::BOLD)
} else if self.picked.contains(&id) {
Style::new().fg(Color::Black).bg(theme.visual)
} else if style.faded {
muted
} else {
@@ -418,7 +429,11 @@ impl View for OutlineView {
}
fn contexts(&self, mode: &Mode) -> &'static [Context] {
contexts_for(mode, &[Context::Outline, Context::Normal, Context::Motions])
contexts_for(
mode,
&[Context::Outline, Context::Normal, Context::Motions],
&[Context::Visual, Context::Outline, Context::Motions],
)
}
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
@@ -459,8 +474,8 @@ impl View for OutlineView {
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode {
Mode::Move(grab) => return commands::reorder(typed, *grab, ctx),
Mode::Normal => {}
Mode::Move(grab) => return commands::reorder(typed, grab, ctx),
Mode::Normal | Mode::Visual(_) => {}
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
}
let rows = isize::try_from(typed.times()).unwrap_or(1);