Edit titles in their boxes, with vim keys inside the text

Titles are typed right in their boxes on the canvas: the box grows with
the text, the terminal's cursor sits in it (a bar while typing, a block
on the letter with vim keys), and the border turns the insert color.
`i`, `A` and Enter edit at the end, `I` at the start, `cc` and `S` type
the title anew. The document follows the text as it's typed, so the
layout stays current, and the whole edit is one undo step.

Esc gives vim's normal mode inside the text (TextEditor, in ui/): h l w
b e 0 ^ $, f t F T with ; and ,, the d c y operators with any of those
motions or the iw aw i" a" i( a( text objects, x X s S D C Y, r ~, p P,
counts, u and Ctrl-r for the edit's own changes, and `.` for the last
one, typing included. j k gg G and J work on lines, for bodies. It
follows vim where it's quirky too: cw acts like ce, dw stops at the end
of the line, Esc steps back onto the last letter. Keys have two new
tables, `text` and `insert`, and in text an operator takes motions as
targets (`dw`, `c$`, `2d3fx`), which which-key lists.

Rapid entry: after `o`, `O` or `a`, the new idea's title is typed at
once; Enter starts the next idea, Tab puts the one being typed under the
idea above, Shift-Tab takes it out a level, and Enter on an empty idea
stops. An idea left empty goes away without a trace in the undo history
(a group whose edits cancel out leaves no step); a title left empty
comes back as it was. Inside a title, `o` and `O` start a new sibling.

Registers are shared by the whole app, so text yanked in one title
pastes into another; `"a`-`"z` and `"_` work, and `"+` stays inside
cumulus until the app core connects the clipboard.

The mouse while editing: a click in the text moves the text cursor, a
click anywhere else keeps the text and finishes the edit, and a
double-click on a title starts editing at that letter.

`>>` and `<<` now move nodes: under the node above, as its last child,
or out a level, right after the old parent, with counts taking in the
next siblings.

The command line's input became TextInput, line-aware so the editor can
use it for bodies too; bodies themselves get edited in the Note view.
The example's tests type every key above into the app and check the
text and the cursor after each one against what vim does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 17:45:40 -04:00
1 parent 6eedbe0ce2
commit 84aefbecdb
31 files changed
+3289 -341

No files matched your search

+5
View File
@@ -5,6 +5,7 @@
//!
//! [`Buffer::apply`]: super::buffer::Buffer::apply
use super::text_edit::StartEdit;
use crate::command::parse::SetArg;
use crate::model::{EditOp, Group, NodeId};
@@ -77,6 +78,10 @@ pub enum DocAction {
/// What happens around the documents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppAction {
/// Start editing the cursor's title.
StartEdit(StartEdit),
/// Put the text cursor here, in the title being edited (a click).
TextCursor(usize),
/// Move mode: hjkl moves the cursor's branch.
EnterMoveMode,
/// In move mode: the whole branch, or just the node.
+5 -2
View File
@@ -62,10 +62,13 @@ impl Buffer {
};
self.keep_cursor();
match applied {
Applied::Removed(count) => {
// A grouped edit is one step of something bigger (typing,
// a drag), with nothing to say on its own: an idea left
// empty goes away quietly.
Applied::Removed(count) if group.is_none() => {
Some(Message::done(format!("deleted {} · u to undo", nodes(count))))
}
Applied::Added(_) | Applied::Changed => None,
Applied::Removed(_) | Applied::Added(_) | Applied::Changed => None,
}
}
DocAction::Undo(times) => self.undo_or_redo(times, true),
+117 -15
View File
@@ -5,12 +5,17 @@
//! own meaning (in the Note view, `dd` will delete a line of text).
//! [`shared`] answers the ones it leaves.
use std::collections::BTreeMap;
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
use super::text_edit::{EditAt, StartEdit};
use crate::input::command::{Command, Motion, Operator, Target};
use crate::input::engine::Typed;
use crate::model::{CanvasPos, EditOp, Fold, Node, NodeId, Spot, Title, Visible};
use crate::model::{
CanvasPos, CanvasRect, CanvasSize, Document, EditOp, Fold, Group, Node, NodeId, Spot, Title, Visible,
};
use crate::view::ViewContext;
use crate::view::canvas::placement::{below_spot, child_spot};
use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, spot_below};
/// The actions for a command every view shares, or `None` when it isn't
/// one.
@@ -23,12 +28,20 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::Redo => vec![Action::Doc(DocAction::Redo(times))],
Command::SetMark(name) => vec![Action::Doc(DocAction::SetMark(name))],
Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))],
Command::Activate if ctx.doc.node(cursor).is_some_and(Node::is_portal) => {
say("going through a portal comes in stage 8")
}
Command::EditTitle | Command::Activate => edit(EditAt::End),
Command::EditTitleAtStart => edit(EditAt::Start),
Command::RetypeTitle => edit(EditAt::Retype),
Command::AddChild => add(ctx, Spot::LastChildOf(cursor)),
Command::AddBelow => add(ctx, Spot::After(cursor)),
Command::AddAbove => add(ctx, Spot::Before(cursor)),
Command::DeleteNode => delete_node(ctx),
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => delete_branches(ctx, times),
Command::Operate(Operator::Delete, Target::Children) => delete_children(ctx),
Command::Operate(Operator::Indent, Target::Current) => indent(ctx, &siblings(ctx, times), None),
Command::Operate(Operator::Outdent, Target::Current) => outdent(ctx, &siblings(ctx, times), None),
Command::ToggleFold => toggle_fold(ctx),
Command::OpenFold => set_fold(ctx, Fold::Open),
Command::CloseFold => set_fold(ctx, Fold::Closed),
@@ -60,6 +73,108 @@ pub fn set_fold(ctx: &ViewContext<'_>, fold: Fold) -> Vec<Action> {
actions
}
/// Adds a node at `spot`, next to its parent or sibling, and starts typing
/// its title: rapid entry. The add and the typing are one undo step.
pub fn add(ctx: &ViewContext<'_>, spot: Spot) -> Vec<Action> {
// Until something is typed, the node holds a placeholder (a title can't
// be empty).
let Ok(title) = Title::parse("new idea") else { return Vec::new() };
let node = Node::idea(title, CanvasPos::default());
let (size, rects) = (ctx.box_size(&node), ctx.box_rects());
let pos = match spot {
Spot::LastChildOf(parent) => child_spot(ctx.doc, parent, size, &rects),
_ => below_spot(ctx.cursor, size, &rects),
};
let group = Group::new();
let op = Box::new(EditOp::Add { node: Node { pos, ..node }, spot });
vec![
Action::Doc(DocAction::Edit { op, cursor: CursorAfter::Added, group: Some(group) }),
Action::App(AppAction::StartEdit(StartEdit { at: EditAt::End, new_node: Some(group) })),
]
}
/// `>>` (and Tab on a new idea): the nodes become the last children of the
/// sibling above them, placed to its right; their branches come along.
pub fn indent(ctx: &ViewContext<'_>, ids: &[NodeId], group: Option<Group>) -> Vec<Action> {
let doc = ctx.doc;
let Some(parent) = ids.first().and_then(|&first| doc.previous_sibling(first)) else {
return say("there's no node above to go under");
};
let (mut rects, moving) = make_room(ctx, ids);
let Some(&parent_rect) = rects.get(&parent) else { return Vec::new() };
let group = group.unwrap_or_default();
let mut last = doc.children(parent).last().and_then(|child| rects.get(child)).copied();
let mut actions = Vec::new();
for (id, size) in moving {
let pos = match last {
Some(rect) => spot_below(rect, size, &rects),
None => first_child_spot(parent_rect, size, &rects),
};
actions.push(Action::edit_in(EditOp::MoveTo { id, spot: Spot::LastChildOf(parent) }, group));
actions.extend(shift_to(doc, id, pos, group));
let rect = CanvasRect::new(pos, size);
rects.insert(id, rect);
last = Some(rect);
}
actions
}
/// `<<` (and Shift-Tab on a new idea): the nodes come out from under their
/// parent, to just after it, placed below it; their branches come along.
pub fn outdent(ctx: &ViewContext<'_>, ids: &[NodeId], group: Option<Group>) -> Vec<Action> {
let doc = ctx.doc;
let Some(parent) = ids.first().and_then(|&first| doc.parent(first)) else {
return say("this node isn't under another one");
};
if parent == doc.root() {
return say("this node is right under the root already");
}
let (mut rects, moving) = make_room(ctx, ids);
let Some(&parent_rect) = rects.get(&parent) else { return Vec::new() };
let group = group.unwrap_or_default();
let (mut after, mut last) = (parent, parent_rect);
let mut actions = Vec::new();
for (id, size) in moving {
let pos = spot_below(last, size, &rects);
actions.push(Action::edit_in(EditOp::MoveTo { id, spot: Spot::After(after) }, group));
actions.extend(shift_to(doc, id, pos, group));
last = CanvasRect::new(pos, size);
rects.insert(id, last);
after = id;
}
actions
}
/// The boxes of every node except the moving branches (so they don't get in
/// their own way), and the size of each moving node's box.
fn make_room(
ctx: &ViewContext<'_>,
ids: &[NodeId],
) -> (BTreeMap<NodeId, CanvasRect>, Vec<(NodeId, CanvasSize)>) {
let mut rects = ctx.box_rects();
let moving = ids.iter().filter_map(|&id| Some((id, rects.get(&id)?.size))).collect();
for branch in ids.iter().flat_map(|&id| ctx.doc.branch(id)) {
rects.remove(&branch);
}
(rects, moving)
}
/// Moves a node's branch so the node lands at `pos`.
fn shift_to(doc: &Document, id: NodeId, pos: CanvasPos, group: Group) -> Option<Action> {
let offset = pos - doc.node(id)?.pos;
let moved = offset != Default::default();
moved.then(|| Action::edit_in(EditOp::Shift { id, offset, branch: true }, group))
}
/// The cursor's node and the next siblings, `count` in all.
fn siblings(ctx: &ViewContext<'_>, count: u32) -> Vec<NodeId> {
ctx.doc.siblings_from(ctx.cursor, usize::try_from(count).unwrap_or(1))
}
fn edit(at: EditAt) -> Vec<Action> {
app(AppAction::StartEdit(StartEdit { at, new_node: None }))
}
fn app(action: AppAction) -> Vec<Action> {
vec![Action::App(action)]
}
@@ -126,19 +241,6 @@ fn tree_step(ctx: &ViewContext<'_>, from: NodeId, motion: Motion) -> Option<Node
}
}
/// Adds a "new idea" node at `spot`, placed next to its parent or sibling,
/// and moves the cursor to it.
fn add(ctx: &ViewContext<'_>, spot: Spot) -> Vec<Action> {
let Ok(title) = Title::parse("new idea") else { return Vec::new() };
let node = Node::idea(title, CanvasPos::default());
let (size, rects) = (ctx.box_size(&node), ctx.box_rects());
let pos = match spot {
Spot::LastChildOf(parent) => child_spot(ctx.doc, parent, size, &rects),
_ => below_spot(ctx.cursor, size, &rects),
};
vec![Action::edit_then(EditOp::Add { node: Node { pos, ..node }, spot }, CursorAfter::Added)]
}
/// `x`: deletes just the cursor's node; its children take its place.
fn delete_node(ctx: &ViewContext<'_>) -> Vec<Action> {
let doc = ctx.doc;
+5 -2
View File
@@ -4,11 +4,12 @@
//! 1. A key goes through the key engine and comes out as a command; the
//! focused view answers it with [`action`]s, and [`commands::shared`]
//! answers what the view leaves. A `:` command line is answered by
//! [`calls::actions_for`] instead.
//! [`calls::actions_for`] instead, and keys while a title is edited by
//! its [`text_edit`].
//! 2. Document actions go to the [`buffer`] holding the document: the one
//! place a document changes. The rest change the [`mode`] or the app.
//!
//! So far this is what stages 2 to 4 needed; stage 8 adds the main loop,
//! So far this is what stages 2 to 6 needed; stage 8 adds the main loop,
//! panes, files and settings.
pub mod action;
@@ -16,3 +17,5 @@ pub mod buffer;
pub mod calls;
pub mod commands;
pub mod mode;
pub mod registers;
pub mod text_edit;
+6
View File
@@ -2,6 +2,7 @@
//! what it needs, so a mode's state can't outlive it (there's no "move mode
//! flag" to forget to clear).
use super::text_edit::TextEdit;
use crate::model::Group;
use crate::ui::command_line::CommandLine;
@@ -11,6 +12,9 @@ pub enum Mode {
/// Moving around and acting on nodes.
#[default]
Normal,
/// Editing a title: typing, or vim keys inside the text (the editor
/// knows which, so the two can't disagree).
Text(TextEdit),
/// Moving the cursor's node (or its branch) with hjkl.
Move(Grab),
/// Typing a `:` command.
@@ -44,6 +48,8 @@ impl Mode {
pub fn label(&self) -> &'static str {
match self {
Mode::Normal => "NORMAL",
Mode::Text(edit) if edit.editor.is_typing() => "INSERT",
Mode::Text(_) => "TEXT",
Mode::Move(_) => "MOVE",
Mode::Command(_) => "COMMAND",
}
+63
View File
@@ -0,0 +1,63 @@
//! Registers, as in vim: where yanked and deleted text goes, and where `p`
//! takes it from. There's one set for the whole app, so text yanked in one
//! title can be pasted into another.
//!
//! Everything yanked or deleted goes in the unnamed register, which `p`
//! uses when no register is named, and also in the one named with `"a`–`"z`.
//! `"_` keeps nothing. `"+` is kept inside cumulus for now: stage 8
//! connects it to the system clipboard.
use std::collections::BTreeMap;
use crate::input::command::Register;
use crate::ui::text_editor::Yank;
/// The registers' contents.
#[derive(Debug, Clone, Default)]
pub struct Registers {
unnamed: Option<Yank>,
named: BTreeMap<char, Yank>,
clipboard: Option<Yank>,
}
impl Registers {
/// Keeps text that was yanked or deleted into `register` (or none).
pub fn store(&mut self, register: Option<Register>, yank: Yank) {
match register {
Some(Register::BlackHole) => return,
Some(Register::Named(name)) => {
self.named.insert(name, yank.clone());
}
Some(Register::Clipboard) => self.clipboard = Some(yank.clone()),
Some(Register::Unnamed) | None => {}
}
self.unnamed = Some(yank);
}
/// What a register holds; the unnamed one when none is named.
pub fn get(&self, register: Option<Register>) -> Option<&Yank> {
match register {
None | Some(Register::Unnamed) => self.unnamed.as_ref(),
Some(Register::Named(name)) => self.named.get(&name),
Some(Register::Clipboard) => self.clipboard.as_ref(),
Some(Register::BlackHole) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn named_registers_and_the_black_hole() {
let yank = |text: &str| Yank { text: text.into(), linewise: false };
let mut registers = Registers::default();
registers.store(Some(Register::Named('a')), yank("cedar"));
registers.store(None, yank("soil"));
registers.store(Some(Register::BlackHole), yank("gone"));
assert_eq!(registers.get(Some(Register::Named('a'))), Some(&yank("cedar")));
assert_eq!(registers.get(None), Some(&yank("soil")), "the black hole keeps nothing");
assert_eq!(registers.get(Some(Register::Named('b'))), None);
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Editing a node's title: the [`TextEditor`] does the typing and the vim
//! keys, and this is what the edit means for the document.
//!
//! The document follows the text as you type, so the layout and every view
//! stay current, and all of it joins one undo step. Rapid entry lives here
//! too: after `o`, `O` or `a`, Enter starts the next idea, Tab and
//! Shift-Tab indent and outdent the one being typed, and an idea left empty
//! goes away.
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
use super::commands;
use super::registers::Registers;
use crate::input::command::{Bound, Command, Operator, Target};
use crate::input::engine::Typed;
use crate::input::key::Key;
use crate::model::{Content, EditOp, Group, NodeId, Spot, Title};
use crate::ui::text_editor::{Reply, TextEditor, Typing};
use crate::view::ViewContext;
/// Where the text cursor starts when an edit begins.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditAt {
/// After the last character (`i`, `A`, Enter).
End,
/// Before the first one (`I`).
Start,
/// With the title cleared, to type it again (`cc`, `S`).
Retype,
/// At this byte offset (a double-click on a letter).
Offset(usize),
}
/// How an edit of the cursor's title starts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StartEdit {
pub at: EditAt,
/// For a node `o`, `O` or `a` just added: the add's undo step, which
/// the edit joins. The node goes away if it's left empty.
pub new_node: Option<Group>,
}
/// A title being edited.
#[derive(Debug, Clone)]
pub struct TextEdit {
/// The node whose title it is.
pub node: NodeId,
pub editor: TextEditor,
/// The node was just added by `o`, `O` or `a`: rapid entry.
pub new_node: bool,
/// Every change the edit makes to the document joins this undo step.
group: Group,
/// The title before the edit, to put back if it's left empty.
original: String,
}
impl TextEdit {
/// Starts editing the cursor's title. A portal's title is its
/// document's name, so it isn't edited here.
pub fn start(ctx: &ViewContext<'_>, start: StartEdit) -> Result<TextEdit, Message> {
let node = ctx.doc.node(ctx.cursor).ok_or_else(|| Message::problem("that node is gone"))?;
let Content::Idea(idea) = &node.content else {
return Err(Message::info("a portal shows its document's name: rename that document instead"));
};
// A new node's title is a placeholder until something is typed.
let original = if start.new_node.is_some() { String::new() } else { idea.title.to_string() };
let mut editor = TextEditor::new(&original, false);
// Typing starts the way vim's commands start it.
let command = match start.at {
EditAt::End => Command::InsertAtEnd,
EditAt::Start => Command::InsertAtStart,
EditAt::Retype => Command::Operate(Operator::Change, Target::Current),
EditAt::Offset(at) if at >= original.len() => Command::InsertAtEnd,
EditAt::Offset(at) => {
editor.set_cursor(at);
Command::InsertBefore
}
};
editor.command(&Typed { command, count: None, register: None }, None);
let new_node = start.new_node.is_some();
Ok(TextEdit {
node: ctx.cursor,
editor,
new_node,
group: start.new_node.unwrap_or_default(),
original,
})
}
/// A key while typing, with what the `insert` table binds it to (keys it
/// doesn't bind type themselves).
pub fn typing(&mut self, key: Key, bound: Option<Bound>, ctx: &ViewContext<'_>) -> Vec<Action> {
let typing = match bound {
Some(Bound::Command(Command::Escape)) => {
self.editor.stop_typing();
return Vec::new();
}
Some(Bound::Command(Command::Confirm)) => return self.enter(ctx),
Some(Bound::Command(Command::Operate(operator @ (Operator::Indent | Operator::Outdent), _))) => {
return self.change_level(ctx, operator == Operator::Indent);
}
Some(Bound::Command(command)) => Typing::Command(command),
Some(_) => return Vec::new(),
None => match key.as_char() {
Some(c) => Typing::Char(c),
None => return Vec::new(),
},
};
self.editor.type_in(typing);
self.follow(ctx)
}
/// A command typed with vim keys inside the title.
pub fn vim(&mut self, typed: &Typed, registers: &mut Registers, ctx: &ViewContext<'_>) -> Vec<Action> {
match typed.command {
Command::Escape => return self.finish(ctx),
// In a title, o and O start a new sibling, as they do on nodes.
Command::AddBelow | Command::AddAbove => {
let spot = if typed.command == Command::AddBelow {
Spot::After(self.node)
} else {
Spot::Before(self.node)
};
let kept = Title::parse(self.editor.text()).is_ok();
let mut actions = self.finish(ctx);
if kept {
actions.extend(commands::add(ctx, spot));
}
return actions;
}
_ => {}
}
let paste = registers.get(typed.register).cloned();
let reply = self.editor.command(typed, paste.as_ref());
let mut actions = self.follow(ctx);
match reply {
Reply::Yanked(yank) => registers.store(typed.register, yank),
Reply::Refused(why) => actions.push(Action::say(Message::info(why))),
Reply::Done | Reply::NotMine => {}
}
actions
}
/// The edit is done. The title stays as typed; left empty, the old one
/// comes back (and a new node goes away instead).
pub fn finish(&self, ctx: &ViewContext<'_>) -> Vec<Action> {
let mut actions = Vec::new();
if Title::parse(self.editor.text()).is_err() {
if self.new_node {
let doc = ctx.doc;
let back = doc.previous_sibling(self.node).or_else(|| doc.parent(self.node));
let op = Box::new(EditOp::RemoveBranches(vec![self.node]));
let cursor = back.map_or(CursorAfter::Stay, CursorAfter::To);
actions.push(Action::Doc(DocAction::Edit { op, cursor, group: Some(self.group) }));
} else {
if let Ok(title) = Title::parse(&self.original) {
actions.push(self.set_title(title));
}
actions.push(Action::say(Message::info("a title can't be empty: it's back as it was")));
}
}
actions.push(Action::App(AppAction::LeaveMode));
actions
}
/// Enter: done with an existing title. In rapid entry, the next idea
/// starts below this one, or, on an empty idea, rapid entry stops.
fn enter(&mut self, ctx: &ViewContext<'_>) -> Vec<Action> {
let kept = Title::parse(self.editor.text()).is_ok();
let mut actions = self.finish(ctx);
if self.new_node && kept {
actions.extend(commands::add(ctx, Spot::After(self.node)));
}
actions
}
/// Tab and Shift-Tab: the new idea goes under the one above, or out a
/// level.
fn change_level(&self, ctx: &ViewContext<'_>, indent: bool) -> Vec<Action> {
if !self.new_node {
return vec![Action::say(Message::info(
"Tab and Shift-Tab move a new idea; >> and << move others",
))];
}
if indent {
commands::indent(ctx, &[self.node], Some(self.group))
} else {
commands::outdent(ctx, &[self.node], Some(self.group))
}
}
/// The document follows the text: the title becomes what's typed (once
/// it's a title at all: not empty).
fn follow(&self, ctx: &ViewContext<'_>) -> Vec<Action> {
let Ok(title) = Title::parse(self.editor.text()) else { return Vec::new() };
if ctx.doc.node(self.node).is_some_and(|node| *node.title() == title) {
return Vec::new();
}
vec![self.set_title(title)]
}
fn set_title(&self, title: Title) -> Action {
Action::edit_in(EditOp::SetTitle { id: self.node, title }, self.group)
}
}
#[cfg(test)]
mod tests;
+206
View File
@@ -0,0 +1,206 @@
//! Editing titles: rapid entry, empty titles, `o` inside a title, and the
//! document following the text.
use super::*;
use crate::app::buffer::Buffer;
use crate::app::mode::Mode;
use crate::input::bindings::Context;
use crate::input::engine::{KeyEngine, Outcome};
use crate::input::key::parse_keys;
use crate::input::keymap::{DEFAULT_LEADER, Keymap};
use crate::model::Document;
use crate::model::test_support::garden;
use crate::ui::theme::Theme;
use crate::view::ViewSettings;
/// The app around an edit, as small as it gets: a document, the mode, the
/// keys and the registers. Normal-mode keys go to the shared commands.
struct App {
buffer: Buffer,
mode: Mode,
keymap: Keymap,
keys: KeyEngine,
registers: Registers,
theme: Theme,
settings: ViewSettings,
said: Vec<String>,
}
impl App {
fn new(doc: Document, cursor: NodeId) -> App {
let mut buffer = Buffer::new(doc);
buffer.apply(DocAction::MoveCursor { to: cursor, jump: false });
App {
buffer,
mode: Mode::Normal,
keymap: Keymap::new(DEFAULT_LEADER),
keys: KeyEngine::default(),
registers: Registers::default(),
theme: Theme::default(),
settings: ViewSettings::default(),
said: Vec::new(),
}
}
/// Presses keys written in vim notation.
fn press(&mut self, keys: &str) {
for key in parse_keys(keys, DEFAULT_LEADER).unwrap() {
let normal = Mode::Normal;
let ctx = ViewContext {
doc: self.buffer.doc(),
cursor: self.buffer.cursor(),
mode: &normal,
theme: &self.theme,
settings: &self.settings,
};
let actions = match &mut self.mode {
Mode::Text(edit) if edit.editor.is_typing() => {
edit.typing(key, self.keymap.tables(&[Context::Insert]).bound(&[key]), &ctx)
}
Mode::Text(edit) => match self.keys.press(key, &self.keymap.tables(&[Context::Text])) {
Outcome::Run(typed) => edit.vim(&typed, &mut self.registers, &ctx),
_ => Vec::new(),
},
_ => match self.keys.press(key, &self.keymap.tables(&[Context::Normal, Context::Motions])) {
Outcome::Run(typed) => commands::shared(&typed, &ctx).unwrap_or_default(),
_ => Vec::new(),
},
};
self.run(actions);
}
}
/// Carries out actions in order, as the app's update does.
fn run(&mut self, actions: Vec<Action>) {
for action in actions {
match action {
Action::Doc(action) => {
self.said.extend(self.buffer.apply(action).map(|message| message.text))
}
Action::App(AppAction::StartEdit(start)) => {
let normal = Mode::Normal;
let ctx = ViewContext {
doc: self.buffer.doc(),
cursor: self.buffer.cursor(),
mode: &normal,
theme: &self.theme,
settings: &self.settings,
};
match TextEdit::start(&ctx, start) {
Ok(edit) => self.mode = Mode::Text(edit),
Err(message) => self.said.push(message.text),
}
}
Action::App(AppAction::LeaveMode) => self.mode = Mode::Normal,
Action::App(AppAction::Say(message)) => self.said.push(message.text),
Action::App(other) => panic!("unexpected here: {other:?}"),
}
}
}
fn doc(&self) -> &Document {
self.buffer.doc()
}
fn title(&self, id: NodeId) -> &str {
self.doc().node(id).unwrap().title().as_str()
}
fn cursor_title(&self) -> &str {
self.title(self.buffer.cursor())
}
fn titles(&self, ids: &[NodeId]) -> Vec<&str> {
ids.iter().map(|&id| self.title(id)).collect()
}
}
#[test]
fn rapid_entry_with_tab_and_enter() {
let (doc, g) = garden();
let mut app = App::new(doc, g.watering);
app.press("aRain barrel<CR>Drip line<Tab>");
assert_eq!(app.mode.label(), "INSERT");
assert_eq!(app.titles(app.doc().children(g.watering)), ["Rain barrel"]);
let barrel = app.doc().children(g.watering)[0];
assert_eq!(app.titles(app.doc().children(barrel)), ["Drip line"], "Tab: under the idea above");
let drip = app.doc().children(barrel)[0];
assert!(
app.doc().node(drip).unwrap().pos.x > app.doc().node(barrel).unwrap().pos.x,
"placed to its right"
);
// Enter starts the next idea next to Drip line; Shift-Tab takes it back
// out a level, after Rain barrel.
app.press("<CR>Timer<S-Tab><CR><CR>");
assert_eq!(app.mode.label(), "NORMAL", "Enter on an empty idea stops");
assert_eq!(app.titles(app.doc().children(g.watering)), ["Rain barrel", "Timer"]);
assert_eq!(app.titles(app.doc().children(barrel)), ["Drip line"]);
assert_eq!(app.cursor_title(), "Timer", "back on the last idea typed");
assert!(app.said.is_empty(), "the empty idea went away quietly: {:?}", app.said);
// One undo step per idea.
app.buffer.apply(DocAction::Undo(1));
assert_eq!(app.titles(app.doc().children(g.watering)), ["Rain barrel"]);
}
#[test]
fn editing_a_title_with_vim_keys() {
let (doc, g) = garden();
let mut app = App::new(doc, g.soil);
app.press("i mix");
assert_eq!(app.title(g.soil), "Soil mix", "the document follows the text as it's typed");
app.press("<Esc>bcwblend<Esc>");
assert_eq!(app.mode.label(), "TEXT");
app.press("<Esc>");
assert_eq!(app.mode.label(), "NORMAL");
assert_eq!(app.title(g.soil), "Soil blend");
app.buffer.apply(DocAction::Undo(1));
assert_eq!(app.title(g.soil), "Soil", "the whole edit is one undo step");
}
#[test]
fn empty_titles_come_back_and_empty_ideas_go_away() {
let (doc, g) = garden();
let mut app = App::new(doc, g.soil);
app.press("cc<Esc><Esc>");
assert_eq!(app.title(g.soil), "Soil");
assert_eq!(app.said, ["a title can't be empty: it's back as it was"]);
assert!(!app.buffer.history().can_undo(), "nothing changed, nothing to undo");
app.press("o<Esc><Esc>");
assert_eq!(app.doc().node_count(), 6, "the new idea left empty went away");
assert_eq!(app.cursor_title(), "Soil");
assert!(!app.buffer.history().can_undo());
}
#[test]
fn o_inside_a_title_starts_a_sibling() {
let (doc, g) = garden();
let mut app = App::new(doc, g.soil);
app.press("A!<Esc>oCompost<Esc><Esc>");
assert_eq!(app.title(g.soil), "Soil!");
assert_eq!(app.titles(app.doc().children(g.root)), ["Soil!", "Compost", "Raised beds", "Watering"]);
}
#[test]
fn registers_last_from_one_edit_to_the_next() {
let (doc, g) = garden();
let mut app = App::new(doc, g.soil);
app.press("i<Esc>yiw<Esc>");
app.buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false });
app.press("A <Esc>p<Esc>");
assert_eq!(app.title(g.cedar), "Cedar Soil");
}
#[test]
fn shift_tab_takes_a_new_idea_out_a_level() {
let (doc, g) = garden();
let mut app = App::new(doc, g.beds);
app.press("aPaths<S-Tab><Esc><Esc>");
let paths = app.buffer.cursor();
assert_eq!(app.title(paths), "Paths");
assert_eq!(app.doc().parent(paths), Some(g.root));
assert_eq!(app.doc().next_sibling(g.beds), Some(paths), "right after its old parent");
// Under the root already: Shift-Tab can't take it further out.
app.buffer.apply(DocAction::MoveCursor { to: g.soil, jump: false });
app.press("oMore<S-Tab>");
assert_eq!(app.said.last().map(String::as_str), Some("this node is right under the root already"));
}