Add the outline and Note views, splits, popups and the bars

The outline shows the tree as an indented list: a row per node with its
fold marker, tree lines in the parent's color, tags, a ¶ for a body and
+N for what a fold hides. zb shows every body under its title, to read a
document like a page. j/k go row by row, h/l to the parent and the first
child, a click on a marker folds, and titles are edited in their row,
rapid entry included. In move mode, j/k swap the node with its siblings,
h takes it out a level and l puts it under the one above.

The Note view shows one node at full size, as parts: the title, the
tags, the body, each link and each child. j/k step through them, Enter
or i edits the part (or goes to that link or child), and h/l turn the
page to the sibling before or after. Bodies and tags are edited with the
same editor as titles: Enter starts a line in a body, Tab types spaces,
and tags that don't parse come back as they were. cc, C and s now start
typing on an empty line too, as in vim.

Panes split the screen the way vim's windows do (Ctrl-w v s h j k l w c
o =, :split, :vsplit, :only), each in a box with its view and document,
and any pane can show any open document (gt, gT, :b, the documents
picker, a click on the document bar). The status bar has two lines: the
mode, the view, the breadcrumb or a message, the keys typed; then key
hints for what you're doing, which <Space>w hides with which-key.

Popups are a mode of their own, built on one list: ? lists every key and
command, / searches it and Enter runs what you pick; the right-click menu
lists what you can do with a node (with the keys that do the same) or
adds a new idea on the empty canvas; the pickers set a node's color,
border, style or tags. K opens the body in a card beside the node, where
i edits it in place.

The example now opens two documents (the garden, and the kitchen its
portal leads to) and its tests drive all of the above end to end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 19:10:09 -04:00
1 parent 84aefbecdb
commit c587c42115
37 files changed
+5579 -730

No files matched your search

+480
View File
@@ -0,0 +1,480 @@
//! 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.
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 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,
/// 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 color = theme.node_style(node, id == doc.root()).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 {
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_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;
}
/// The row and column under a terminal cell, if it's in the view.
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)))
}
/// 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).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);
}
}
/// `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;
if self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor) {
self.scrolled = false;
}
self.build(ctx);
if !self.scrolled || self.editing.is_some() {
self.show_cursor_row(ctx.cursor);
}
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..) {
buf.set_line(self.area.x, y, &row.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 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::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 | Direction::Right => {}
}
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;