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:
1 parent
6eedbe0ce2
commit
84aefbecdb
31 files changed
+3289
-341
No files matched your search
+403
-42
@@ -1,31 +1,46 @@
|
||||
//! A playground for the canvas (stages 2 to 5 of the build): a sample
|
||||
//! document to move around, zoom, fold and rearrange, with the real vim keys,
|
||||
//! the which-key popup and the `:` command line. Text editing and the other
|
||||
//! views come in later stages.
|
||||
//! A playground for the canvas (stages 2 to 6 of the build): a sample
|
||||
//! document to move around, zoom, fold, rearrange and write in, with the real
|
||||
//! vim keys, the which-key popup and the `:` command line. The other views
|
||||
//! come in stage 7.
|
||||
//!
|
||||
//! It runs the way the app will: the key engine turns keys into commands,
|
||||
//! the canvas view (or the commands every view shares) turns those into
|
||||
//! actions, and [`Playground::update`] carries them out.
|
||||
//! actions, and [`Playground::update`] carries them out. While a title is
|
||||
//! being edited, keys go to the edit instead.
|
||||
//!
|
||||
//! Run it with `cargo run --example canvas`. A few keys to start with:
|
||||
//!
|
||||
//! | Keys | What they do |
|
||||
//! |----------------|------------------------------------------------------|
|
||||
//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) |
|
||||
//! | `g` | go to: `gh gl gj gk` the tree, `gg` the root |
|
||||
//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` |
|
||||
//! | `+` `-` | zoom in / out |
|
||||
//! | `a` `o` `O` | add a child / a sibling below / above |
|
||||
//! | `x` `dd` `dib` | delete the node / the branch / the children |
|
||||
//! | `M` | move mode: hjkl moves the branch, Enter ends |
|
||||
//! | `u` `Ctrl-r` | undo / redo |
|
||||
//! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) |
|
||||
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
|
||||
//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) |
|
||||
//! | `q`, `ZZ` | quit |
|
||||
//! | Keys | What they do |
|
||||
//! |-----------------|-----------------------------------------------------------|
|
||||
//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) |
|
||||
//! | `g` | go to: `gh gl gj gk` the tree, `gg` the root |
|
||||
//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` |
|
||||
//! | `+` `-` | zoom in / out |
|
||||
//! | `i` `A` `Enter` | edit the title, typing at its end (`I`: at its start) |
|
||||
//! | `cc` `S` | type the title anew |
|
||||
//! | `a` `o` `O` | add a child / a sibling below / above, and type its title |
|
||||
//! | `x` `dd` `dib` | delete the node / the branch / the children |
|
||||
//! | `>>` `<<` | the node goes under the one above it / out a level |
|
||||
//! | `M` | move mode: hjkl moves the branch, Enter ends |
|
||||
//! | `u` `Ctrl-r` | undo / redo |
|
||||
//! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) |
|
||||
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
|
||||
//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) |
|
||||
//! | `q`, `ZZ` | quit |
|
||||
//!
|
||||
//! Typing a title (INSERT, a bar cursor): Enter is done. After `a`, `o` or
|
||||
//! `O`, Enter starts the next idea instead, Tab puts the new idea under the
|
||||
//! one above, Shift-Tab takes it out a level, and an idea left empty goes
|
||||
//! away. Esc gives vim keys inside the title (TEXT, a block cursor):
|
||||
//! `h l w b e 0 ^ $`, `f t F T ; ,`, `d c y` with any of those or with
|
||||
//! `iw aw i" a" i( a(`, `x X s S D C Y r ~ p P`, `u Ctrl-r .`, counts
|
||||
//! (`3x`, `d2f.`), `i a I A` to type again, `o O` for a new sibling. Esc
|
||||
//! again: done.
|
||||
//!
|
||||
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
||||
//! space pans, the wheel scrolls.
|
||||
//! space pans, the wheel scrolls. A double-click on a title edits it from
|
||||
//! that letter; while editing, a click in the title moves the text cursor,
|
||||
//! and a click anywhere else finishes the edit.
|
||||
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -33,6 +48,8 @@ use std::time::{Duration, Instant};
|
||||
use cumulus::app::action::{Action, AppAction, Message, Tone};
|
||||
use cumulus::app::buffer::Buffer;
|
||||
use cumulus::app::mode::{Grab, Mode};
|
||||
use cumulus::app::registers::Registers;
|
||||
use cumulus::app::text_edit::TextEdit;
|
||||
use cumulus::app::{calls, commands};
|
||||
use cumulus::command::history::History as CommandHistory;
|
||||
use cumulus::command::parse::{SetArg, Sources, parse};
|
||||
@@ -48,6 +65,7 @@ use cumulus::ui::theme::Theme;
|
||||
use cumulus::ui::which_key::WhichKey;
|
||||
use cumulus::view::canvas::CanvasView;
|
||||
use cumulus::view::{View, ViewContext, ViewSettings};
|
||||
use ratatui::crossterm::cursor::SetCursorStyle;
|
||||
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
@@ -71,14 +89,15 @@ fn main() -> io::Result<()> {
|
||||
let doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?;
|
||||
let mut terminal = ratatui::try_init()?;
|
||||
execute!(io::stdout(), EnableMouseCapture)?;
|
||||
// On a crash, turn the mouse off too; ratatui's own hook restores the rest.
|
||||
// On a crash, turn the mouse off and give the cursor back its own shape
|
||||
// too; ratatui's own hook restores the rest.
|
||||
let restore_the_rest = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape);
|
||||
restore_the_rest(info);
|
||||
}));
|
||||
let result = Playground::new(doc).run(&mut terminal);
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape);
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
@@ -109,6 +128,8 @@ struct Playground {
|
||||
state: State,
|
||||
keymap: Keymap,
|
||||
keys: KeyEngine,
|
||||
/// Yanked and deleted text, kept from one edit to the next.
|
||||
registers: Registers,
|
||||
mouse: MouseReader,
|
||||
which_key: bool,
|
||||
command_history: CommandHistory,
|
||||
@@ -116,6 +137,8 @@ struct Playground {
|
||||
popup: PopupMode,
|
||||
/// The status bar's message, until the next key.
|
||||
message: Option<Message>,
|
||||
/// The cursor shape last sent to the terminal.
|
||||
cursor_shape: SetCursorStyle,
|
||||
quit: bool,
|
||||
}
|
||||
|
||||
@@ -132,11 +155,13 @@ impl Playground {
|
||||
},
|
||||
keymap,
|
||||
keys: KeyEngine::default(),
|
||||
registers: Registers::default(),
|
||||
mouse: MouseReader::default(),
|
||||
which_key: true,
|
||||
command_history: CommandHistory::default(),
|
||||
popup: PopupMode::default(),
|
||||
message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))),
|
||||
cursor_shape: SetCursorStyle::DefaultUserShape,
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
@@ -144,6 +169,7 @@ impl Playground {
|
||||
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
while !self.quit {
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
self.set_cursor_shape()?;
|
||||
// While a sequence is half typed, wake up now and then: its
|
||||
// which-key popup may be due.
|
||||
let wait =
|
||||
@@ -169,17 +195,35 @@ impl Playground {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gives the terminal's cursor the mode's shape, when that changed.
|
||||
fn set_cursor_shape(&mut self) -> io::Result<()> {
|
||||
let shape = cursor_shape(&self.state.mode);
|
||||
if shape != self.cursor_shape {
|
||||
execute!(io::stdout(), shape)?;
|
||||
self.cursor_shape = shape;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----- Input ----------------------------------------------------------
|
||||
|
||||
fn gesture(&mut self, gesture: Gesture) {
|
||||
// A click drops a half-typed key sequence: a `d` typed before
|
||||
// clicking somewhere else mustn't wait to delete what comes next.
|
||||
if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. })
|
||||
{
|
||||
self.keys.reset();
|
||||
}
|
||||
let actions = self.view.mouse(gesture, &self.state.ctx());
|
||||
self.update(actions);
|
||||
}
|
||||
|
||||
fn key(&mut self, key: Key) {
|
||||
self.message = None;
|
||||
if let Mode::Command(_) = self.state.mode {
|
||||
return self.command_line_key(key);
|
||||
match self.state.mode {
|
||||
Mode::Command(_) => return self.command_line_key(key),
|
||||
Mode::Text(_) => return self.text_key(key),
|
||||
Mode::Normal | Mode::Move(_) => {}
|
||||
}
|
||||
let tables = self.keymap.tables(self.view.contexts(&self.state.mode));
|
||||
match self.keys.press(key, &tables) {
|
||||
@@ -199,6 +243,29 @@ impl Playground {
|
||||
}
|
||||
}
|
||||
|
||||
/// A key while a title is edited: typed into it, or a vim key inside it.
|
||||
fn text_key(&mut self, key: Key) {
|
||||
let tables = self.keymap.tables(self.view.contexts(&self.state.mode));
|
||||
// The edit comes out of the mode while it works, so it can look at
|
||||
// the document; it goes back in before its actions run.
|
||||
let Mode::Text(mut edit) = std::mem::take(&mut self.state.mode) else { return };
|
||||
let ctx = self.state.ctx();
|
||||
let actions = if edit.editor.is_typing() {
|
||||
// Typing: a key the table doesn't bind types itself.
|
||||
edit.typing(key, tables.bound(&[key]), &ctx)
|
||||
} else {
|
||||
match self.keys.press(key, &tables) {
|
||||
Outcome::Run(typed) => edit.vim(&typed, &mut self.registers, &ctx),
|
||||
Outcome::Unknown(keys) => {
|
||||
vec![Action::say(Message::info(format!("{keys} does nothing here")))]
|
||||
}
|
||||
Outcome::Waiting | Outcome::Cancelled => Vec::new(),
|
||||
}
|
||||
};
|
||||
self.state.mode = Mode::Text(edit);
|
||||
self.update(actions);
|
||||
}
|
||||
|
||||
/// A key while the command line is open.
|
||||
fn command_line_key(&mut self, key: Key) {
|
||||
let sources = self.sources();
|
||||
@@ -268,6 +335,18 @@ impl Playground {
|
||||
|
||||
fn app(&mut self, action: AppAction) -> Option<Message> {
|
||||
match action {
|
||||
AppAction::StartEdit(start) => {
|
||||
let started = TextEdit::start(&self.state.ctx(), start);
|
||||
match started {
|
||||
Ok(edit) => self.state.mode = Mode::Text(edit),
|
||||
Err(message) => return Some(message),
|
||||
}
|
||||
}
|
||||
AppAction::TextCursor(at) => {
|
||||
if let Mode::Text(edit) = &mut self.state.mode {
|
||||
edit.editor.set_cursor(at);
|
||||
}
|
||||
}
|
||||
AppAction::EnterMoveMode => self.state.mode = Mode::Move(Grab::new()),
|
||||
AppAction::ToggleWhole => {
|
||||
if let Mode::Move(grab) = &mut self.state.mode {
|
||||
@@ -345,18 +424,32 @@ impl Playground {
|
||||
/// The bottom line: the mode, the zoom and the cursor's title, then a
|
||||
/// message or key hints, and the keys typed so far on the right.
|
||||
fn draw_status(&self, frame: &mut Frame, area: Rect) {
|
||||
let hints = match self.state.mode {
|
||||
let (mode, theme) = (&self.state.mode, &self.state.theme);
|
||||
let typing = matches!(mode, Mode::Text(edit) if edit.editor.is_typing());
|
||||
let hints = match mode {
|
||||
Mode::Move(Grab { whole: true, .. }) => {
|
||||
"hjkl moves the branch (HJKL by 5) · M just this node · Enter done"
|
||||
}
|
||||
Mode::Move(_) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done",
|
||||
Mode::Text(edit) if typing && edit.new_node => {
|
||||
"Enter the next idea · Tab under the one above · Shift-Tab out a level · Esc vim keys"
|
||||
}
|
||||
Mode::Text(_) if typing => "Enter done · Esc vim keys",
|
||||
Mode::Text(_) => {
|
||||
"w b e 0 $ f t move · d c y + a motion · x r ~ p · u undo · . repeat · i a type · o/O new idea · Esc done"
|
||||
}
|
||||
Mode::Normal | Mode::Command(_) => {
|
||||
"hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · : command · <Space> menu · q quit"
|
||||
"hjkl move · g go to · z folds · +/- zoom · i edit · a/o add · x/dd delete · >>/<< in/out · M move · u undo · : command · <Space> menu · q quit"
|
||||
}
|
||||
};
|
||||
// While a title is edited, the status bar shows it as typed.
|
||||
let title = match mode {
|
||||
Mode::Text(edit) => edit.editor.text().to_string(),
|
||||
_ => {
|
||||
let buffer = &self.state.buffer;
|
||||
buffer.doc().node(buffer.cursor()).map(|node| node.title().to_string()).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let doc = self.state.buffer.doc();
|
||||
let title =
|
||||
doc.node(self.state.buffer.cursor()).map(|node| node.title().to_string()).unwrap_or_default();
|
||||
let focus = if self.state.settings.focus { " · focus" } else { "" };
|
||||
let middle = match &self.message {
|
||||
None => hints.dark_gray(),
|
||||
@@ -364,8 +457,9 @@ impl Playground {
|
||||
Some(Message { text, tone: Tone::Done }) => text.clone().green(),
|
||||
Some(Message { text, tone: Tone::Info }) => text.clone().yellow(),
|
||||
};
|
||||
let label_color = if typing { theme.insert } else { theme.accent };
|
||||
let line = Line::from(vec![
|
||||
format!(" {} ", self.state.mode.label()).black().on_cyan().bold(),
|
||||
format!(" {} ", mode.label()).black().bg(label_color).bold(),
|
||||
format!(" {}{focus} │ {title} │ ", self.view.camera().zoom.name()).into(),
|
||||
middle,
|
||||
]);
|
||||
@@ -384,6 +478,17 @@ impl Playground {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor's shape in a mode, as in vim: a bar where typed letters go in
|
||||
/// between two others, a block on the letter vim keys act on, and the
|
||||
/// terminal's own shape where the cursor is hidden anyway.
|
||||
fn cursor_shape(mode: &Mode) -> SetCursorStyle {
|
||||
match mode {
|
||||
Mode::Text(edit) if !edit.editor.is_typing() => SetCursorStyle::SteadyBlock,
|
||||
Mode::Text(_) | Mode::Command(_) => SetCursorStyle::SteadyBar,
|
||||
Mode::Normal | Mode::Move(_) => SetCursorStyle::DefaultUserShape,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a command does, in words: its own help, or its operator's.
|
||||
fn describe(command: Command) -> &'static str {
|
||||
match (help_for(Bound::Command(command)), command) {
|
||||
@@ -406,6 +511,7 @@ mod tests {
|
||||
use cumulus::input::keymap::DEFAULT_LEADER;
|
||||
use cumulus::input::mouse::Cell;
|
||||
use cumulus::model::{CanvasPos, NodeId};
|
||||
use cumulus::view::canvas::ScreenPos;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
@@ -442,10 +548,50 @@ mod tests {
|
||||
self.draw();
|
||||
}
|
||||
|
||||
/// A click on a cell of the screen.
|
||||
fn click(&mut self, col: i32, row: i32) {
|
||||
let at = cell(col, row);
|
||||
self.mouse(Gesture::Press { at, ctrl: false });
|
||||
self.mouse(Gesture::Release { at, dragged: false });
|
||||
}
|
||||
|
||||
/// A double-click, as the mouse reader reports it: a press, then a
|
||||
/// second one on the same cell.
|
||||
fn double_click(&mut self, col: i32, row: i32) {
|
||||
let at = cell(col, row);
|
||||
self.click(col, row);
|
||||
self.mouse(Gesture::DoublePress { at });
|
||||
self.mouse(Gesture::Release { at, dragged: false });
|
||||
}
|
||||
|
||||
/// The title of the cursor's node.
|
||||
fn cursor(&self) -> String {
|
||||
let buffer = &self.playground.state.buffer;
|
||||
buffer.doc().node(buffer.cursor()).unwrap().title().to_string()
|
||||
self.title(buffer.cursor())
|
||||
}
|
||||
|
||||
fn title(&self, id: NodeId) -> String {
|
||||
self.playground.state.buffer.doc().node(id).unwrap().title().to_string()
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
self.playground.state.mode.label()
|
||||
}
|
||||
|
||||
/// The title being edited, as typed so far.
|
||||
fn draft(&self) -> &str {
|
||||
match &self.playground.state.mode {
|
||||
Mode::Text(edit) => edit.editor.text(),
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the text cursor is in the title being edited: a byte offset.
|
||||
fn draft_cursor(&self) -> Option<usize> {
|
||||
match &self.playground.state.mode {
|
||||
Mode::Text(edit) => Some(edit.editor.cursor()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn message(&self) -> &str {
|
||||
@@ -458,10 +604,33 @@ mod tests {
|
||||
doc.outline().into_iter().find(|&id| doc.node(id).unwrap().title().as_str() == title).unwrap()
|
||||
}
|
||||
|
||||
fn children(&self, title: &str) -> Vec<String> {
|
||||
let doc = self.playground.state.buffer.doc();
|
||||
doc.children(self.node(title)).iter().map(|&id| self.title(id)).collect()
|
||||
}
|
||||
|
||||
fn parent(&self, title: &str) -> String {
|
||||
self.title(self.playground.state.buffer.doc().parent(self.node(title)).unwrap())
|
||||
}
|
||||
|
||||
fn pos(&self, title: &str) -> CanvasPos {
|
||||
self.playground.state.buffer.doc().node(self.node(title)).unwrap().pos
|
||||
}
|
||||
|
||||
/// Where a node's box starts on screen: its top-left corner. Its
|
||||
/// title starts 2 cells right of that (the border, a space) and a
|
||||
/// row down.
|
||||
fn on_screen(&self, title: &str) -> ScreenPos {
|
||||
self.playground.view.camera().to_screen(self.pos(title))
|
||||
}
|
||||
|
||||
/// The terminal's cursor, when it shows.
|
||||
fn text_cursor(&self) -> Option<(i32, i32)> {
|
||||
let backend = self.terminal.backend();
|
||||
let at = backend.cursor_position();
|
||||
backend.cursor_visible().then(|| (i32::from(at.x), i32::from(at.y)))
|
||||
}
|
||||
|
||||
/// What's on screen, row by row.
|
||||
fn screen(&self) -> String {
|
||||
let buffer = self.terminal.backend().buffer();
|
||||
@@ -470,6 +639,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(col: i32, row: i32) -> Cell {
|
||||
Cell { col: u16::try_from(col).unwrap(), row: u16::try_from(row).unwrap() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_counts_and_undo() {
|
||||
let mut h = Harness::new();
|
||||
@@ -494,9 +667,9 @@ mod tests {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glgjM");
|
||||
assert_eq!(h.cursor(), "Raised beds");
|
||||
assert_eq!(h.playground.state.mode.label(), "MOVE");
|
||||
assert_eq!(h.label(), "MOVE");
|
||||
h.type_keys("3lJ<CR>");
|
||||
assert_eq!(h.playground.state.mode.label(), "NORMAL");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
assert_eq!(h.pos("Raised beds"), CanvasPos::new(25, 12), "3 right, 5 down");
|
||||
assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(46, 10), "its branch came along");
|
||||
h.type_keys("u");
|
||||
@@ -507,10 +680,8 @@ mod tests {
|
||||
#[test]
|
||||
fn dragging_a_node_moves_its_branch() {
|
||||
let mut h = Harness::new();
|
||||
let soil = h.playground.view.camera().to_screen(h.pos("Soil"));
|
||||
let at =
|
||||
|col: i32, row: i32| Cell { col: u16::try_from(col).unwrap(), row: u16::try_from(row).unwrap() };
|
||||
let (start, end) = (at(soil.col + 1, soil.row + 1), at(soil.col + 5, soil.row + 3));
|
||||
let soil = h.on_screen("Soil");
|
||||
let (start, end) = (cell(soil.col + 1, soil.row + 1), cell(soil.col + 5, soil.row + 3));
|
||||
h.mouse(Gesture::Press { at: start, ctrl: false });
|
||||
assert_eq!(h.cursor(), "Soil", "the press selects");
|
||||
h.mouse(Gesture::Drag { from: start, at: end, ctrl: false });
|
||||
@@ -528,7 +699,7 @@ mod tests {
|
||||
assert_eq!(root.look.color.as_ref().map(|color| color.as_str()), Some("teal"));
|
||||
h.type_keys(":in mulch<CR>");
|
||||
assert_eq!(h.message(), "captured \"mulch\" to the Inbox");
|
||||
assert_eq!(h.playground.state.buffer.doc().parent(h.node("mulch")), Some(h.node("Inbox")));
|
||||
assert_eq!(h.parent("mulch"), "Inbox");
|
||||
assert_eq!(h.cursor(), "Garden plans", "capturing leaves the cursor where it was");
|
||||
h.type_keys(":find drip<CR>");
|
||||
assert_eq!(h.cursor(), "Drip line");
|
||||
@@ -542,8 +713,8 @@ mod tests {
|
||||
#[test]
|
||||
fn adding_folding_zooming_and_quitting() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("a");
|
||||
assert_eq!(h.cursor(), "new idea");
|
||||
h.type_keys("aMulch<Esc><Esc>");
|
||||
assert_eq!(h.cursor(), "Mulch");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.cursor(), "Garden plans", "undo goes back to where the change was");
|
||||
h.type_keys("glgjza");
|
||||
@@ -556,12 +727,202 @@ mod tests {
|
||||
assert!(h.playground.quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_title_in_its_box() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gli mix");
|
||||
assert_eq!(h.label(), "INSERT");
|
||||
assert!(h.screen().contains("Soil mix"), "typed into the box:\n{}", h.screen());
|
||||
let soil = h.on_screen("Soil mix");
|
||||
assert_eq!(h.text_cursor(), Some((soil.col + 2 + 8, soil.row + 1)), "after the last letter");
|
||||
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBar);
|
||||
h.type_keys("<Esc>");
|
||||
assert_eq!(h.label(), "TEXT");
|
||||
let soil = h.on_screen("Soil mix");
|
||||
assert_eq!(h.text_cursor(), Some((soil.col + 2 + 7, soil.row + 1)), "on the last letter, as in vim");
|
||||
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBlock);
|
||||
h.type_keys("<Esc>");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
assert_eq!(h.text_cursor(), None, "no text cursor outside a title");
|
||||
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::DefaultUserShape);
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.cursor(), "Soil", "the whole edit was one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_entry_with_enter_tab_and_shift_tab() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glgjgjaBucket<CR>Hose<Tab>");
|
||||
assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line", "Bucket"]);
|
||||
assert_eq!(h.children("Bucket"), ["Hose"], "Tab: under the idea above");
|
||||
h.type_keys("<CR>Can<S-Tab>");
|
||||
assert_eq!(
|
||||
h.children("Watering"),
|
||||
["Rain barrel", "Drip line", "Bucket", "Can"],
|
||||
"Shift-Tab: out a level"
|
||||
);
|
||||
h.type_keys("<CR><CR>");
|
||||
assert_eq!(h.label(), "NORMAL", "Enter on an empty idea stops");
|
||||
assert_eq!(
|
||||
h.children("Watering"),
|
||||
["Rain barrel", "Drip line", "Bucket", "Can"],
|
||||
"the empty one went"
|
||||
);
|
||||
assert_eq!(h.children("Bucket"), ["Hose"]);
|
||||
assert_eq!(h.cursor(), "Can");
|
||||
assert_eq!(h.message(), "");
|
||||
assert!(h.screen().contains("Can"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retyping_and_repeating() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glccCompost<Esc><Esc>");
|
||||
assert_eq!(h.cursor(), "Compost", "cc types the title anew");
|
||||
h.type_keys("A!<Esc>..");
|
||||
assert_eq!(h.draft(), "Compost!!!", ". repeats the typing too");
|
||||
h.type_keys("u<Esc>");
|
||||
assert_eq!(h.cursor(), "Compost!!", "u inside the title undoes its last change");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.cursor(), "Compost", "u outside undoes the whole edit");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_keys_inside_a_title() {
|
||||
let mut h = Harness::new();
|
||||
// "Cedar, untreated": change the second word, then find the comma.
|
||||
h.type_keys("glgjgli<Esc>bcwtreated<Esc>0f,x");
|
||||
assert_eq!(h.draft(), "Cedar treated");
|
||||
h.type_keys("w~");
|
||||
assert_eq!(h.draft(), "Cedar Treated", "~ switches the case and steps right");
|
||||
h.type_keys("$diw");
|
||||
assert_eq!(h.draft(), "Cedar ");
|
||||
h.type_keys("<Esc>");
|
||||
assert_eq!(h.cursor(), "Cedar", "the title is trimmed");
|
||||
}
|
||||
|
||||
/// Every other vim key inside a title, one step at a time, each checked
|
||||
/// against what vim does: the text after it, and where the cursor is.
|
||||
#[test]
|
||||
fn the_rest_of_the_vim_keys_inside_a_title() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glccplant (two) \"red\" beans<Esc>");
|
||||
let steps: &[(&str, &str, usize)] = &[
|
||||
("0e", "plant (two) \"red\" beans", 4),
|
||||
("^", "plant (two) \"red\" beans", 0),
|
||||
("$F(", "plant (two) \"red\" beans", 6),
|
||||
("$T\"", "plant (two) \"red\" beans", 17),
|
||||
// ; repeats the T, skipping the quote it's already next to.
|
||||
(";", "plant (two) \"red\" beans", 13),
|
||||
// , repeats it the other way: t".
|
||||
(",", "plant (two) \"red\" beans", 15),
|
||||
("0f(ci(six<Esc>", "plant (six) \"red\" beans", 9),
|
||||
("f\"di\"", "plant (six) \"\" beans", 13),
|
||||
("da\"", "plant (six) beans", 12),
|
||||
("F(da(", "plant beans", 6),
|
||||
("X", "plant beans", 5),
|
||||
("wdaw", "plant", 4),
|
||||
("A beans<Esc>", "plant beans", 10),
|
||||
("0sP<Esc>", "Plant beans", 0),
|
||||
("$r!", "Plant bean!", 10),
|
||||
("u", "Plant beans", 10),
|
||||
("<C-r>", "Plant bean!", 10),
|
||||
("0wD", "Plant ", 5),
|
||||
("0CSeed<Esc>", "Seed", 3),
|
||||
("0yiwP", "SeedSeed", 3),
|
||||
("I><Esc>", ">SeedSeed", 0),
|
||||
("a-<Esc>", ">-SeedSeed", 1),
|
||||
("SGo<Esc>", "Go", 1),
|
||||
("0iE<Esc>", "EGo", 0),
|
||||
("3~", "egO", 2),
|
||||
("Sa.b.c.d.e.f.g<Esc>0d3f.", "d.e.f.g", 0),
|
||||
("2x", "e.f.g", 0),
|
||||
("d2f.", "g", 0),
|
||||
// A title has one line, so Y's line goes in like any text.
|
||||
("YP", "gg", 0),
|
||||
];
|
||||
for &(keys, text, cursor) in steps {
|
||||
h.type_keys(keys);
|
||||
assert_eq!((h.draft(), h.draft_cursor()), (text, Some(cursor)), "after {keys}");
|
||||
}
|
||||
h.type_keys("<Esc>u");
|
||||
assert_eq!(h.cursor(), "Soil", "all of it was one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_carry_text_from_title_to_title() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gli<Esc>\"ayiw<Esc>");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
h.type_keys("gjA <Esc>\"ap<Esc>");
|
||||
assert_eq!(h.cursor(), "Raised beds Soil", "\"a kept Soil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn o_inside_a_title_starts_a_sibling() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gli<Esc>oCompost<Esc><Esc>");
|
||||
assert_eq!(h.children("Garden plans"), ["Soil", "Compost", "Raised beds", "Watering", "Kitchen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mouse_while_editing() {
|
||||
let mut h = Harness::new();
|
||||
let watering = h.node("Watering");
|
||||
// A double-click on the first e: typing goes in right before it.
|
||||
let at = h.on_screen("Watering");
|
||||
h.double_click(at.col + 2 + 3, at.row + 1);
|
||||
assert_eq!(h.label(), "INSERT");
|
||||
h.type_keys("-");
|
||||
assert_eq!(h.draft(), "Wat-ering");
|
||||
// A click in the title moves the text cursor.
|
||||
let at = h.on_screen("Wat-ering");
|
||||
h.click(at.col + 2, at.row + 1);
|
||||
h.type_keys("~");
|
||||
assert_eq!(h.draft(), "~Wat-ering");
|
||||
// A click anywhere else keeps the edit, finishes it, and selects.
|
||||
let soil = h.on_screen("Soil");
|
||||
h.click(soil.col + 2, soil.row + 1);
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
assert_eq!(h.title(watering), "~Wat-ering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indenting_and_outdenting_nodes() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glgj>>");
|
||||
assert_eq!(h.cursor(), "Raised beds");
|
||||
assert_eq!(h.parent("Raised beds"), "Soil", ">>: under the node above");
|
||||
h.type_keys("<<");
|
||||
assert_eq!(
|
||||
h.children("Garden plans"),
|
||||
["Soil", "Raised beds", "Watering", "Kitchen"],
|
||||
"<<: back out"
|
||||
);
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.parent("Raised beds"), "Soil");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.parent("Raised beds"), "Garden plans");
|
||||
assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7), "back where it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_later_stages_say_so() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("yy");
|
||||
assert_eq!(h.message(), "\"yank (copy)…\" comes in a later stage of the build");
|
||||
h.type_keys("K");
|
||||
assert_eq!(h.message(), "\"show the body\" comes in a later stage of the build");
|
||||
// Kitchen is a portal: its title is the other document's name.
|
||||
h.type_keys("glgjgjgj");
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
h.type_keys("i");
|
||||
assert_eq!(h.message(), "\"edit the title\" comes in a later stage of the build");
|
||||
assert_eq!(h.message(), "a portal shows its document's name: rename that document instead");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
h.type_keys("<CR>");
|
||||
assert_eq!(h.message(), "going through a portal comes in stage 8");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user