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

+148
View File
@@ -0,0 +1,148 @@
//! The popups driven by their key tables' commands, as the app drives them.
use super::*;
use crate::input::key::KeyName;
use crate::input::keymap::DEFAULT_LEADER;
use crate::model::test_support::garden;
use crate::model::{Spot, Tags};
use crate::view::test_support::Setup;
const CANVAS: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions];
fn typed(command: Command) -> Typed {
Typed { command, count: None, register: None }
}
/// Types a search into a list popup, the way `/` and the keys after it do.
fn search(popup: &mut Popup, text: &str, ctx: &ViewContext<'_>) {
let keymap = Keymap::new(DEFAULT_LEADER);
popup.command(&typed(Command::Search), &mut Registers::default(), ctx);
for c in text.chars() {
popup.typing(Key::char(c), &keymap, ctx);
}
}
/// Enter, while a search is typed.
fn enter(popup: &mut Popup, ctx: &ViewContext<'_>) -> Vec<Action> {
popup.typing(Key::plain(KeyName::Enter), &Keymap::new(DEFAULT_LEADER), ctx)
}
fn app(action: AppAction) -> Action {
Action::App(action)
}
#[test]
fn help_finds_keys_and_commands_and_runs_them() {
let ((doc, g), setup) = (garden(), Setup::default());
let ctx = setup.ctx(&doc, g.root);
let keymap = Keymap::new(DEFAULT_LEADER);
let help = || Popup::help(&keymap, CANVAS, "canvas", "", &setup.theme);
let mut popup = help();
search(&mut popup, "dd", &ctx);
let dd = Command::Operate(Operator::Delete, Target::Current);
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Run(dd))]);
let mut popup = help();
search(&mut popup, "zoom in", &ctx);
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Run(Command::ZoomIn))]);
// A key that waits for more is typed, so the rest can follow.
let mut popup = help();
search(&mut popup, "mark this", &ctx);
let m = vec![Key::char('m')];
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Type(m))]);
// A command that needs more opens the command line with it typed.
let mut popup = Popup::help(&keymap, CANVAS, "canvas", ":color", &setup.theme);
let confirm = popup.command(&typed(Command::Confirm), &mut Registers::default(), &ctx);
assert_eq!(confirm, [app(AppAction::LeaveMode), app(AppAction::CommandLineWith("color ".into()))]);
assert_eq!(popup.label(), "HELP");
}
#[test]
fn the_menu_shows_the_keys_that_do_the_same() {
let keymap = Keymap::new(DEFAULT_LEADER);
let tables = keymap.tables(CANVAS);
assert_eq!(keys_for(&tables, Command::AddChild).as_deref(), Some("a"));
assert_eq!(keys_for(&tables, Command::PickColor).as_deref(), Some("<Space>c"));
assert_eq!(keys_for(&tables, Command::Operate(Operator::Delete, Target::Current)).as_deref(), Some("dd"));
// On the empty canvas, the first thing is a new idea right there.
let ((doc, g), setup) = (garden(), Setup::default());
let ctx = setup.ctx(&doc, g.root);
let spot = CanvasPos::new(60, 30);
let mut menu =
Popup::menu(MenuFor::Canvas(spot), "canvas", Position::new(5, 5), &keymap, CANVAS, &setup.theme);
let actions = menu.command(&typed(Command::Confirm), &mut Registers::default(), &ctx);
assert_eq!(actions[0], app(AppAction::LeaveMode));
let Action::Doc(crate::app::action::DocAction::Edit { op, .. }) = &actions[1] else { panic!("an add") };
let EditOp::Add { node, spot: Spot::Loose } = op.as_ref() else { panic!("a loose node") };
assert_eq!(node.pos, spot, "where the click was");
}
#[test]
fn pickers_set_what_is_picked() {
let ((mut doc, g), setup) = (garden(), Setup::default());
doc.apply(&EditOp::SetTags { id: g.beds, tags: Tags::parse("#wood").unwrap() }).unwrap();
let ctx = setup.ctx(&doc, g.soil);
let pick = |kind| Popup::picker(kind, &ctx, &[], 0).unwrap();
let mut colors = pick(PickKind::Color);
search(&mut colors, "teal", &ctx);
let teal = ColorName::parse("teal").ok();
assert_eq!(
enter(&mut colors, &ctx),
[app(AppAction::LeaveMode), Action::edit(EditOp::SetColor { id: g.soil, color: teal })]
);
let mut borders = pick(PickKind::Border);
search(&mut borders, "dashed", &ctx);
let dashed = Some(Border::Dashed);
assert_eq!(
enter(&mut borders, &ctx),
[app(AppAction::LeaveMode), Action::edit(EditOp::SetBorder { id: g.soil, border: dashed })]
);
let no_styles = Popup::picker(PickKind::Style, &ctx, &[], 0).unwrap_err();
assert_eq!(no_styles.text, "this theme has no named styles yet: theme files come in stage 8");
// Tags go on and off, and the picker stays open; a new one is added.
let mut tags = pick(PickKind::Tags);
let wood = Tag::parse("wood").unwrap();
let on = Action::edit(EditOp::Tag { id: g.soil, add: vec![wood.clone()], remove: Vec::new() });
let off = Action::edit(EditOp::Tag { id: g.soil, add: Vec::new(), remove: vec![wood] });
let mut registers = Registers::default();
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [on]);
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [off]);
search(&mut tags, "spring", &ctx);
let spring = Tag::parse("spring").unwrap();
assert_eq!(
enter(&mut tags, &ctx),
[Action::edit(EditOp::Tag { id: g.soil, add: vec![spring], remove: Vec::new() })]
);
// Documents: it starts on the one shown; the next one is a j away.
let documents = ["Garden plans".to_string(), "Kitchen".to_string()];
let mut picker = Popup::picker(PickKind::Document, &ctx, &documents, 0).unwrap();
picker.command(&typed(Command::Move(crate::input::command::Motion::Down)), &mut registers, &ctx);
assert_eq!(
picker.command(&typed(Command::Confirm), &mut registers, &ctx),
[app(AppAction::LeaveMode), app(AppAction::SwitchDocument(DocumentChoice::At(1)))]
);
}
#[test]
fn the_card_edits_the_body_and_closes() {
let ((doc, g), setup) = (garden(), Setup::default());
let ctx = setup.ctx(&doc, g.beds);
let mut card = Popup::card(g.beds);
assert_eq!((card.label(), card.contexts()), ("CARD", &[Context::Card][..]));
let mut registers = Registers::default();
let start = StartEdit::Body { at: EditAt::End, in_card: true };
assert_eq!(
card.command(&typed(Command::EditBody), &mut registers, &ctx),
[app(AppAction::StartEdit(start))]
);
assert_eq!(card.command(&typed(Command::Escape), &mut registers, &ctx), [app(AppAction::LeaveMode)]);
}