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

+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"));
}