Files
cumulus/src/view/outline.rs
T
skkeyeandClaude Opus 5.5 abe3dc1d84 Read config.toml, keys.toml and themes, and open them with :config (stage 8b)
The config folder now sets everything: config.toml the settings (now
also `theme` and `editor`), keys.toml the keys, and themes/<name>.toml
the theme, laid over the built-in one, so a theme only needs what it
changes. Each file is applied again a second after it's saved; a
mistake only drops its own line, and the status bar says what's wrong,
naming the file.

:config lists the three files with their folder; Enter hands the
terminal to your editor (the editor setting, $VISUAL, $EDITOR, else
notepad or vi), writing the file first if it's missing, with every
option at its default behind a #, explained (keys.toml lists every
command by name). :theme switches themes until cumulus quits, like
:set.

The built-in theme gets the named styles the user picked, each also put
on by its tag: question (yellow, dashed), important (red, thick) and done
(gray, its title faded); and link styles blocks and supports, whose
label shows on a link without its own. Themes can also set [modes],
[node], [root], [branch] and [link].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 23:55:11 -04:00

568 lines
23 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 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::mode::Mode;
use crate::app::popup::MenuFor;
use crate::app::text_edit::{EditAt, StartEdit, TextEdit};
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>,
}
/// 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();
// 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 = theme.node_style(node, id == doc.root());
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 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(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])
}
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::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;