Files
cumulus/src/view/outline.rs
T
skkeyeandClaude Opus 5.5 ebe8a5988e Add connect mode, portals, backlinks and renames (stage 8d, v1)
Part 8d, the last of stage 8, and with it everything on DESIGN.md's v1
list.

Connect mode (C): the cursor goes to another node, a dashed arrow in the
visual color follows it, and Enter links the two; then the cursor goes
back to where C was typed (the user's pick). d removes the line between
them (a branch is cut, and the child comes loose), > puts the node under
the other one, and <Space> a, l, c, s and t style the line as you watch,
making it when it isn't there yet. Everything connect mode changes is one
undo group, and Esc takes it back with History::cancel, which leaves
nothing to redo. From visual mode every selected node connects, and the
line pickers go back to connect mode when they close.

Portals show their document live, from a cache of what each document
file holds (open documents from their buffers, the others from their
files, looked at every second): the root's title, its color and how many
nodes it has, or ? when the document isn't there. Enter or gf goes
through, as a jump, and the pane's breadcrumb starts with the documents
it came through. <Space>p types :portal (the user's pick): a path as :e
takes it, or the title of a new document. Backlinks are put right
whenever a document is opened or saved; the root shows how many, the
Note view lists them, gu goes up to them (the user's pick) and
:backlinks lists them.

File names follow the root's title: when it changed, the next save
renames the file (-2 when the name is taken; a folder's default document
keeps its name), and the portals pointing at it, its backlinks in the
documents it leads to, the recent documents, the capital marks and the
jump lists follow. A portal whose file was moved by hand finds its
document in that folder by uid. Portals in a register keep full paths,
so a pasted portal leads to the same document from any folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 08:24:43 -04:00

589 lines
24 KiB
Rust

//! The outline: the tree as an indented list, to reorganize and to read.
//!
//! ```text
//! ▾ Garden plans
//! ├ Soil #todo
//! ├ ▸ Raised beds ¶ +2
//! ├ ▾ Watering
//! │ ├ Rain barrel
//! │ └ ▸ Drip line +1
//! └ » Kitchen
//! ◦ ask about compost ¶
//! ```
//!
//! A row for each node on show (it folds the way every view does), with its
//! fold marker, its tags, `¶` when it has a body and `+N` for what a fold
//! hides. The tree lines take the parent's color, as branches do on the
//! canvas. `zb` shows every body under its title, to read the document like
//! a page.
//!
//! `j` and `k` go row by row, `h` and `l` to the parent and the first child.
//! 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 std::collections::BTreeSet;
use ratatui::Frame;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::app::action::{Action, AppAction, Message};
use crate::app::commands;
use crate::app::connect;
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;
use crate::input::mouse::{Cell, Gesture};
use crate::model::{Content, Direction, EditOp, Fold, NodeId, Visible};
use crate::ui::text_editor::layout;
use crate::view::{View, ViewContext, ViewKind, contexts_for};
/// How close the cursor's row may come to the top or the bottom before the
/// outline scrolls, like vim's `scrolloff`.
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;
/// The outline view.
#[derive(Debug, Default)]
pub struct OutlineView {
/// Where on screen the view is.
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.
rows: Vec<Row>,
/// The cursor at the last layout. When it moves, the view follows.
last_cursor: Option<NodeId>,
/// The view was scrolled by hand: it stays put until the cursor moves.
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.
#[derive(Debug, Clone)]
struct Row {
/// The node it's about (a line of a body is about its node).
id: NodeId,
line: Line<'static>,
/// Where the title, or the line of the body, starts.
text_col: u16,
/// On the title row of a node with children: where the fold marker is,
/// and whether the children are folded away.
marker: Option<(u16, bool)>,
/// The title row, rather than a line of the body.
title: bool,
}
/// The title being edited, as drawn: where its text starts, scrolled
/// sideways so that the text cursor stays in view.
#[derive(Debug, Clone)]
struct Editing {
row: usize,
text_col: u16,
/// How many columns of the text are scrolled off to the left.
skip: usize,
text: String,
/// The text cursor's column, counted from the start of the text.
cursor_col: usize,
}
impl OutlineView {
pub fn new() -> OutlineView {
OutlineView::default()
}
/// Lays out a row for every node on show (and the lines of their bodies,
/// when bodies show).
fn build(&mut self, ctx: &ViewContext<'_>) {
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(),
Mode::Connect(connecting) => connect::others(connecting, ctx.cursor).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| {
let color =
doc.node(id).map_or(Color::Reset, |node| theme.node_style(node, id == doc.root()).color);
Style::new().fg(if color == Color::Reset { theme.muted } else { color })
};
self.rows.clear();
self.editing = None;
for &id in &visible.order {
// Top-down: the top-level node this one is under, …, its parent.
let mut path: Vec<NodeId> = doc.ancestors(id).collect();
path.reverse();
// The lines of the levels above, going on down past this row,
// then this node's joint to its parent's line. The lines of its
// body go on below the way its children's would.
let mut lines = Lines::default();
let mut body_lines = Lines::default();
body_lines.push(" ", Style::new());
if let Some(&parent) = path.last() {
lines.push(" ", Style::new());
for pair in path.windows(2) {
let going_on = doc.next_sibling(pair[1]).is_some();
lines.push_level(going_on, glyphs.tree_line(), line_style(pair[0]));
}
let last = doc.next_sibling(id).is_none();
body_lines = lines.clone();
body_lines.push_level(!last, glyphs.tree_line(), line_style(parent));
lines.push(format!("{} ", glyphs.tree_joint(last)), line_style(parent));
}
self.add_title(ctx, id, &visible, lines, edit);
self.add_body(ctx, id, body_lines);
}
}
/// The title row of a node: its marker, title, tags, `¶` and `+N`.
fn add_title(
&mut self,
ctx: &ViewContext<'_>,
id: NodeId,
visible: &Visible,
mut lines: Lines,
edit: Option<&TextEdit>,
) {
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
let Some(node) = doc.node(id) else { return };
let top_level = lines.spans.is_empty();
let style = ctx.node_style(id, node);
let color = style.color;
let has_children = !doc.children(id).is_empty();
let folded = visible.hidden.contains_key(&id);
let marker = if node.is_portal() {
Some(glyphs.portal())
} else if has_children {
Some(glyphs.fold(folded))
} else if top_level {
Some(glyphs.loose())
} else {
None
};
let marker_at = (has_children && !node.is_portal()).then_some((lines.width, folded));
if let Some(marker) = marker {
lines.push(format!("{marker} "), Style::new().fg(color));
}
let text_col = lines.width;
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 {
Style::new()
};
match edit.filter(|edit| edit.node == id) {
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_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 };
lines.push(shown.to_string(), Style::new().fg(color).add_modifier(Modifier::BOLD));
self.editing = Some(Editing {
row: self.rows.len(),
text_col,
skip,
text: text.to_string(),
cursor_col,
});
}
None => lines.push(node.title().to_string(), title_style),
}
if !node.tags.is_empty() {
lines.push(format!(" {}", node.tags), muted);
}
let has_body = matches!(&node.content, Content::Idea(idea) if !idea.body.is_empty());
if has_body && !self.bodies {
lines.push(format!(" {}", glyphs.body_mark()), muted);
}
if let Some(badge) = ctx.badge(id, node) {
lines.push(format!(" {badge}"), muted);
}
if let Some(count) = visible.hidden.get(&id) {
lines.push(format!(" +{count}"), muted);
}
self.rows.push(Row { id, line: Line::from(lines.spans), text_col, marker: marker_at, title: true });
}
/// The lines of a node's body, under its title, when bodies show.
fn add_body(&mut self, ctx: &ViewContext<'_>, id: NodeId, lines: Lines) {
if !self.bodies {
return;
}
let Some(Content::Idea(idea)) = ctx.doc.node(id).map(|node| &node.content) else { return };
let body = idea.body.as_str();
if body.is_empty() {
return;
}
let text_col = lines.width;
let room = usize::from(self.area.width.saturating_sub(text_col)).max(8);
for range in layout::rows(body, room) {
let text = body[range].trim_end_matches('\n').to_string();
let mut spans = lines.spans.clone();
spans.push(Span::raw(text));
self.rows.push(Row { id, line: Line::from(spans), text_col, marker: None, title: false });
}
}
/// Scrolls so the cursor's row shows, a few rows from the edges.
fn show_cursor_row(&mut self, cursor: NodeId) {
let Some(index) = self.rows.iter().position(|row| row.id == cursor && row.title) else { return };
let height = usize::from(self.area.height).max(1);
let off = SCROLL_OFF.min(height.saturating_sub(1) / 2);
if index < self.scroll + off {
self.scroll = index.saturating_sub(off);
} else if index + off >= self.scroll + height {
self.scroll = index + off + 1 - height;
}
}
/// Scrolls by some rows, up when negative. The cursor stays where it is.
fn scroll_by(&mut self, rows: isize) {
self.scroll = self.scroll.saturating_add_signed(rows).min(self.rows.len().saturating_sub(1));
self.scrolled = true;
}
/// 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(|| {
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.
fn text_offset_at(&self, (col, row): (u16, usize)) -> Option<usize> {
let editing =
self.editing.as_ref().filter(|editing| editing.row == row && col >= editing.text_col)?;
let column = usize::from(col - editing.text_col) + editing.skip;
Some(layout::offset(&editing.text, &layout::rows(&editing.text, usize::MAX), 0, column))
}
/// The terminal cell of the text cursor, while a title is edited.
fn text_cursor(&self) -> Option<Position> {
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.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))
}
/// A left press: on a row, its node becomes the cursor; on a fold marker,
/// the branch folds or unfolds too.
fn press(&self, at: (u16, usize), ctx: &ViewContext<'_>) -> Vec<Action> {
let Some(row) = self.rows.get(at.1) else { return Vec::new() };
let mut actions = vec![Action::cursor(row.id)];
if row.title
&& let Some((col, folded)) = row.marker
&& col == at.0
&& ctx.doc.contains(row.id)
{
let fold = if folded { Fold::Open } else { Fold::Closed };
actions.push(Action::edit(EditOp::SetFold { ids: vec![row.id], fold }));
}
actions
}
/// Where a double-click on a title lands in it, to start editing there.
fn title_spot(&self, row: &Row, col: u16, ctx: &ViewContext<'_>) -> EditAt {
let Some(node) = ctx.doc.node(row.id) else { return EditAt::End };
if col < row.text_col {
return EditAt::End;
}
let title = node.title().as_str();
EditAt::Offset(layout::offset(
title,
&layout::rows(title, usize::MAX),
0,
usize::from(col - row.text_col),
))
}
}
/// The spans of a row as it's built, and how wide they are so far.
#[derive(Debug, Clone, Default)]
struct Lines {
spans: Vec<Span<'static>>,
width: u16,
}
impl Lines {
fn push(&mut self, text: impl Into<String>, style: Style) {
let text = text.into();
self.width = self.width.saturating_add(u16::try_from(text.width()).unwrap_or(u16::MAX));
self.spans.push(Span::styled(text, style));
}
/// One level of the tree: its line going on down, or room where it ended.
fn push_level(&mut self, going_on: bool, line: char, style: Style) {
let indent = usize::from(INDENT);
let text = if going_on { format!("{line}{}", " ".repeat(indent - 1)) } else { " ".repeat(indent) };
self.push(text, style);
}
}
/// 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) {
let mut cut = 0;
for (at, c) in text.char_indices() {
if cut >= columns {
return (&text[at..], cut);
}
cut += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
}
("", cut)
}
fn say(text: &str) -> Vec<Action> {
vec![Action::say(Message::info(text))]
}
impl View for OutlineView {
fn kind(&self) -> ViewKind {
ViewKind::Outline
}
fn contexts(&self, mode: &Mode) -> &'static [Context] {
contexts_for(
mode,
&[Context::Outline, Context::Normal, Context::Motions],
&[Context::Visual, Context::Outline, Context::Motions],
&[Context::Connect, Context::Outline, Context::Motions],
)
}
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
self.area = area;
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));
}
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
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..) {
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);
}
}
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::Visual(_) | Mode::Connect(_) => {}
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.
let tree =
|motion: Motion| commands::shared(&Typed { command: Command::Move(motion), ..*typed }, ctx);
match typed.command {
Command::Move(Motion::Down) => return tree(Motion::Next),
Command::Move(Motion::Up) => return tree(Motion::Previous),
Command::Move(Motion::Left) => return tree(Motion::Parent),
Command::Move(Motion::Right) => return tree(Motion::FirstChild),
Command::ScrollDown => self.scroll_by(rows),
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));
self.scrolled = true;
}
}
Command::ToggleBodies => {
self.bodies = !self.bodies;
let text = if self.bodies {
"every body shows under its title: zb hides them"
} else {
"bodies hidden: ¶ marks the nodes that have one"
};
return Some(say(text));
}
_ => return None,
}
Some(Vec::new())
}
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
match gesture {
Gesture::Press { at, .. } => {
let Some(at) = self.local(at) else { return Vec::new() };
let Some(edit) = ctx.mode.title_edit() else { return self.press(at, ctx) };
// While a title is edited, a click in it moves the text
// cursor; a click anywhere else keeps the edit, finishes it,
// and does what a click does.
if let Some(offset) = self.text_offset_at(at) {
return vec![Action::App(AppAction::TextCursor(offset))];
}
let mut actions = edit.finish(ctx);
actions.extend(self.press(at, ctx));
actions
}
Gesture::DoublePress { at } => {
let Some(at) = self.local(at) else { return Vec::new() };
if let Some(offset) = self.text_offset_at(at) {
return vec![Action::App(AppAction::TextCursor(offset))];
}
let Some(row) = self.rows.get(at.1).filter(|row| row.title) else { return Vec::new() };
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
let start = StartEdit::title(self.title_spot(row, at.0, ctx));
actions.extend([Action::cursor(row.id), Action::App(AppAction::StartEdit(start))]);
actions
}
Gesture::Scroll { direction, .. } => {
let rows = isize::try_from(WHEEL_ROWS).unwrap_or(1);
match direction {
Direction::Down => self.scroll_by(rows),
Direction::Up => self.scroll_by(-rows),
Direction::Left => self.scroll_side(-i32::from(SCROLL_COLS)),
Direction::Right => self.scroll_side(i32::from(SCROLL_COLS)),
}
Vec::new()
}
Gesture::RightPress { at: cell } => {
let Some(row) = self.local(cell).and_then(|at| self.rows.get(at.1)) else {
return Vec::new();
};
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
let (what, col, row_at) = (MenuFor::Node(row.id), cell.col, cell.row);
actions.extend([
Action::cursor(row.id),
Action::App(AppAction::OpenMenu { what, col, row: row_at }),
]);
actions
}
Gesture::Drag { .. } | Gesture::Release { .. } => Vec::new(),
}
}
fn cursor_area(&self, ctx: &ViewContext<'_>) -> Option<Rect> {
let index = self.rows.iter().position(|row| row.id == ctx.cursor && row.title)?;
let row =
u16::try_from(index.checked_sub(self.scroll)?).ok().filter(|&row| row < self.area.height)?;
let width = self.rows[index].line.width();
let width = u16::try_from(width).unwrap_or(u16::MAX).min(self.area.width).max(1);
Some(Rect::new(self.area.x, self.area.y + row, width, 1))
}
}
#[cfg(test)]
mod tests;