From 84aefbecdb8c5a58eea0b424d902c20f4b0e10ca Mon Sep 17 00:00:00 2001 From: Skkeye Date: Thu, 1 Oct 2026 17:42:59 -0400 Subject: [PATCH] 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 --- DESIGN.md | 88 ++++- README.md | 7 +- examples/canvas.rs | 445 ++++++++++++++++++++--- src/app/action.rs | 5 + src/app/buffer.rs | 7 +- src/app/commands.rs | 132 ++++++- src/app/mod.rs | 7 +- src/app/mode.rs | 6 + src/app/registers.rs | 63 ++++ src/app/text_edit.rs | 207 +++++++++++ src/app/text_edit/tests.rs | 206 +++++++++++ src/input/bindings.rs | 119 +++++- src/input/command.rs | 88 +++++ src/input/engine.rs | 47 ++- src/input/keymap.rs | 81 ++++- src/model/history.rs | 19 + src/ui/command_line.rs | 4 +- src/ui/line_input.rs | 159 --------- src/ui/mod.rs | 11 +- src/ui/text_editor/layout.rs | 135 +++++++ src/ui/text_editor/mod.rs | 675 +++++++++++++++++++++++++++++++++++ src/ui/text_editor/motion.rs | 268 ++++++++++++++ src/ui/text_editor/object.rs | 152 ++++++++ src/ui/text_editor/tests.rs | 178 +++++++++ src/ui/text_input.rs | 231 ++++++++++++ src/ui/theme.rs | 3 + src/view/canvas/boxes.rs | 29 ++ src/view/canvas/placement.rs | 10 +- src/view/canvas/render.rs | 43 +-- src/view/canvas/scene.rs | 94 ++++- src/view/canvas/view.rs | 111 ++++-- 31 files changed, 3289 insertions(+), 341 deletions(-) create mode 100644 src/app/registers.rs create mode 100644 src/app/text_edit.rs create mode 100644 src/app/text_edit/tests.rs delete mode 100644 src/ui/line_input.rs create mode 100644 src/ui/text_editor/layout.rs create mode 100644 src/ui/text_editor/mod.rs create mode 100644 src/ui/text_editor/motion.rs create mode 100644 src/ui/text_editor/object.rs create mode 100644 src/ui/text_editor/tests.rs create mode 100644 src/ui/text_input.rs diff --git a/DESIGN.md b/DESIGN.md index ae80bf0..900190b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -91,8 +91,13 @@ lines. deletes this node and the next two, with their branches, as vim's `3dd` takes three lines. Which-key says how many as you type (`2d3`: 6 nodes). - Rapid entry is what happens after `o`, `O` or `a`: Enter starts the next - sibling, Tab / Shift-Tab indent and outdent it, Enter on an empty line stops. - Esc switches to vim motions inside the text; a second Esc leaves. + sibling, Tab / Shift-Tab indent and outdent the idea being typed, and Enter + on an empty idea stops (the empty idea goes away). Esc switches to vim keys + inside the text; a second Esc leaves. Inside a title, `o` and `O` start a + new sibling too. +- `>>` puts the node under the one above it, as its last child; `<<` takes it + out a level, right after its old parent. Both place the node below its new + neighbors, and keep its branch with it. - `C` (connect mode) edits the connection to another node. Aimed at your own parent, it styles or cuts the branch line; cutting makes the node loose. - `M` (move mode) moves the branch; `M` again switches to just the node. @@ -104,6 +109,36 @@ lines. bar shows key hints, and `?` opens searchable help. - Keys can be changed in `keys.toml`, in vim notation. +**Editing text** (settled in stage 6) +- A title is typed right in its box on the canvas (zoomed to Normal while it's + edited). The box grows with the text, the terminal's cursor sits in it, and + the border takes the insert color while you type. +- The cursor's shape follows the mode, as in vim: a bar while typing (and in + the command line), a block on the letter with vim keys, and the terminal's + own shape elsewhere. +- The document follows the text as you type, so every view stays current. The + whole edit is one undo step (in rapid entry, one per idea); inside the text, + `u` and `Ctrl-r` undo and redo the edit's own changes. +- A title left empty comes back as it was, with a note saying so; a new idea + left empty goes away. +- Registers are shared by the whole app, as in vim: text yanked in one title + pastes into another. `"+` stays inside cumulus until stage 8 connects it to + the system clipboard. +- `.` inside the text repeats the last change there, typing included. `.` on + nodes comes with node yank and paste, in stage 8. +- As in vim: `cw` changes to the end of the word, `dw` stops at the end of the + line, `j` and `k` move by lines of text rather than wrapped rows, Esc steps + back onto the last letter typed. +- Tab and Shift-Tab only move a new idea; on an existing title they say that + `>>` and `<<` do it. +- The mouse while editing: a click in the text moves the text cursor; a click + anywhere else keeps the text, finishes the edit and does what a click does; a + double-click on a title starts editing at that letter. +- A portal's title is its document's name, so it isn't edited in place: rename + that document instead. +- The editor handles bodies (several lines) too, but they're edited in the Note + view, in stage 7. + **Files and saving** - Documents are RON files ending in `.cum`. Configs are TOML: `config.toml`, `keys.toml` and `themes/.toml`. @@ -228,18 +263,20 @@ backlinks, and the backlink paths in every document it points to. breadcrumb (or a message), and the keys typed so far (`2g` above). **Mouse** (on the canvas; other views get click, double-click and scroll): -click selects, double-click edits the title, dragging a node moves it with its -branch, dragging empty space pans, scrolling pans, Ctrl+drag selects a -rectangle, Ctrl+click adds or removes one node, right-click opens a menu built -from the same actions as the keys. Zoom is on the `+` and `-` keys. +click selects, double-click edits the title (from the letter clicked), +dragging a node moves it with its branch, dragging empty space pans, scrolling +pans, Ctrl+drag selects a rectangle, Ctrl+click adds or removes one node, +right-click opens a menu built from the same actions as the keys. Zoom is on +the `+` and `-` keys. ## Modes and keys ```rust enum Mode { Normal, - Insert(TextEdit), // typing a title or body; rapid entry lives here - TextNormal(TextEdit), // vim motions inside that text + Text(TextEdit), // a title or body being edited: typing (INSERT) or vim + // keys inside it (TEXT); the editor knows which, so the + // two can't disagree. Rapid entry lives here too. Visual(Visual), // anchor + Rectangle or Branches Move(Grab), // a node or a branch being moved Connect(Connect), // the source node and the link being styled @@ -261,8 +298,8 @@ text). | **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal | | **Leader** `` | `c` color · `s` style · `B` border · `t` tags · `p` new portal · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help | | **Panes, documents** | `Ctrl-w v` `Ctrl-w s` split · `Ctrl-w hjkl` focus · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane | -| **Typing** | `Ctrl-w` delete word · `Ctrl-u` delete line · arrows, Home, End · Enter (title: done or next sibling; body: new line) · Tab Shift-Tab (new node: child / back out) · Esc to vim motions | -| **Inside text** | `h j k l w b e 0 ^ $ f t x r J o O p P u Ctrl-r .` · `d` `c` `y` with motions and `iw aw i" a" i( a(` · `i a I A` back to typing | +| **Typing** | `Backspace` `Delete` · `Ctrl-w` delete word · `Ctrl-u` delete line · arrows, Home, End · Enter (title: done, or after `o O a` the next idea; body: new line) · Tab Shift-Tab (a new idea: under the one above / out a level) · Esc to vim keys | +| **Inside text** | `h j k l w b e 0 ^ $ gg G` (and arrows, Home, End) · `f t F T ; ,` · `d` `c` `y` with any motion and `iw aw i" a" i( a(` · `x X s S D C Y` · `r ~ J p P` · `u Ctrl-r .` · `i a I A` back to typing · `o O` (title: a new sibling; body: a new line) · Esc done | | **Move mode** | `hjkl` 1 cell · `HJKL` 5 cells · `M` branch ↔ node · Enter or Esc done | | **Connect mode** | move to the target as usual · Enter link · `d` remove · `>` attach as child · `` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel | | **Visual** | `d y > < M C` and `c` `s` `t` act on the whole selection · `o` swaps ends | @@ -340,7 +377,9 @@ glyphs = "unicode" # "ascii" for fonts without box-drawing characters completion_popup = "as-you-type" # "on-tab": only after Tab, like vim's wildmenu # keys.toml: sections motions, normal, canvas, outline, note, visual, move, -# connect, list; commands by name (src/input/command.rs); "nothing" unbinds +# connect, list, text (vim keys inside text), insert (typing: one key each, +# the others type themselves); commands by name (src/input/command.rs); +# "nothing" unbinds leader = "" [normal] "x" = "delete-branch" @@ -399,13 +438,15 @@ cumulus/ ├─ command/ registry, parse, complete, history ├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render, │ navigate, placement, view), outline, note, board - ├─ ui/ text_editor (+ vim normal), which_key, command_line, line_input, status_bar, - │ document_bar, card, menu, picker, help, theme, glyphs + ├─ ui/ text_input (a line of text and its cursor), text_editor/ (vim inside text: + │ motion, object, layout), which_key, command_line, theme, glyphs; later + │ status_bar, document_bar, card, menu, picker, help ├─ config/ paths, settings, keys_file, theme_file (TOML) ├─ storage/ document_file (RON), atomic save, backups, lock, state, watch (change polling) - └─ app/ action, mode, buffer, commands (keys every view shares), calls (`:` commands); - later App, panes (split tree), jumps, registers, portals (cache, backlinks, - renames), update, render, terminal (setup, teardown, panic hook) + └─ app/ action, mode, buffer, commands (keys every view shares), calls (`:` commands), + text_edit (a title being edited, rapid entry), registers; later App, panes + (split tree), jumps, portals (cache, backlinks, renames), update, render, + terminal (setup, teardown, panic hook) ``` **Flow:** a terminal event comes in → the key engine turns keys into a typed @@ -418,8 +459,16 @@ document actions go to the document's `Buffer` (the only place a document changes: the edit, its undo step, the cursor and the marks), the rest change the mode or the app → the screen is drawn from the state. +While a title is edited, keys go to the edit (`app::text_edit`) instead: when +typing, a key the `insert` table binds is a command (Enter, Tab, Backspace…) +and any other key types itself; with vim keys, the key engine reads the `text` +table, where operators take motions as targets too (`dw`, `c$`, `dfx`). The +edit answers with actions like everything else. + Edits travel as data (`EditOp`), so views never touch the document. A `Group` -makes several edits one undo step: a whole move-mode session, a mouse drag. +makes several edits one undo step: a whole move-mode session, a mouse drag, a +text edit, an idea in rapid entry. A group whose edits cancel out (an idea +added, then left empty) leaves no step at all. ```rust trait View { // settled in stage 5 @@ -488,5 +537,10 @@ one commit. - **Model:** tree rules, undo/redo round trips, folding. - **Canvas:** camera conversions, routing and junctions (small text snapshots), hit-testing, nearest-node moves. +- **Text editing:** vim keys inside text driven key by key (motions, operators, + text objects, `.`, undo), rapid entry, empty titles, registers. +- **The example:** its tests drive the whole loop on a test terminal, keys and + mouse in and the screen out, so what a stage says works is checked end to + end. - **Files:** RON round trip, load errors, config parsing, and backlink and rename fix-ups, in temporary folders. diff --git a/README.md b/README.md index d980677..4126c40 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,10 @@ The plan for the everyday keys (all of them are in DESIGN.md): |-------------------|----------------------------------------------------| | `hjkl` | move to the nearest node that way | | `gh gj gk gl` | parent, next sibling, previous sibling, first child | -| `o` `O` `a` | new sibling below / above, new child | -| `i` or Enter | edit the title | +| `o` `O` `a` | new sibling below / above, new child: type its title, Enter for the next one | +| `i` or Enter | edit the title (Esc: vim keys inside it, Esc again: done) | | `dd` `x` | delete the branch / just the node | +| `>>` `<<` | under the node above / out a level | | `u` `Ctrl-r` | undo / redo | | `za` `zf` `zi` | fold, auto-fold, focus | | `+` `-` | zoom in / out | @@ -58,7 +59,7 @@ cargo fmt # format the code While the app is being built, the examples try out each part: ```sh -cargo run --example canvas # the canvas, vim keys, which-key, :commands +cargo run --example canvas # the canvas, vim keys, which-key, :commands, editing titles cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text cargo test --example canvas # type key sequences into it, and check what they do ``` diff --git a/examples/canvas.rs b/examples/canvas.rs index 9691432..78b3cb1 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -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) | -//! | `` | the action menu (`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) | +//! | `` | the action menu (`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, + /// 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 { 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 · 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 · 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 { + 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 { + 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"); - 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"); 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"); 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"); + 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(""); + 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(""); + 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("glgjgjaBucketHose"); + assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line", "Bucket"]); + assert_eq!(h.children("Bucket"), ["Hose"], "Tab: under the idea above"); + h.type_keys("Can"); + assert_eq!( + h.children("Watering"), + ["Rain barrel", "Drip line", "Bucket", "Can"], + "Shift-Tab: out a level" + ); + h.type_keys(""); + 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"); + assert_eq!(h.cursor(), "Compost", "cc types the title anew"); + h.type_keys("A!.."); + assert_eq!(h.draft(), "Compost!!!", ". repeats the typing too"); + h.type_keys("u"); + 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("glgjglibcwtreated0f,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(""); + 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"); + 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", "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", "plant beans", 10), + ("0sP", "Plant beans", 0), + ("$r!", "Plant bean!", 10), + ("u", "Plant beans", 10), + ("", "Plant bean!", 10), + ("0wD", "Plant ", 5), + ("0CSeed", "Seed", 3), + ("0yiwP", "SeedSeed", 3), + ("I>", ">SeedSeed", 0), + ("a-", ">-SeedSeed", 1), + ("SGo", "Go", 1), + ("0iE", "EGo", 0), + ("3~", "egO", 2), + ("Sa.b.c.d.e.f.g0d3f.", "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("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\"ayiw"); + assert_eq!(h.label(), "NORMAL"); + h.type_keys("gjA \"ap"); + 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("glioCompost"); + 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(""); + assert_eq!(h.message(), "going through a portal comes in stage 8"); } } diff --git a/src/app/action.rs b/src/app/action.rs index e926568..607e4f7 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -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. diff --git a/src/app/buffer.rs b/src/app/buffer.rs index d7e50bc..e57d7fa 100644 --- a/src/app/buffer.rs +++ b/src/app/buffer.rs @@ -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), diff --git a/src/app/commands.rs b/src/app/commands.rs index b30a7fa..8ce45e6 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -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> { 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 { 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 { + // 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) -> Vec { + 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) -> Vec { + 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, 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 { + 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 { + ctx.doc.siblings_from(ctx.cursor, usize::try_from(count).unwrap_or(1)) +} + +fn edit(at: EditAt) -> Vec { + app(AppAction::StartEdit(StartEdit { at, new_node: None })) +} + fn app(action: AppAction) -> Vec { vec![Action::App(action)] } @@ -126,19 +241,6 @@ fn tree_step(ctx: &ViewContext<'_>, from: NodeId, motion: Motion) -> Option, spot: Spot) -> Vec { - 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 { let doc = ctx.doc; diff --git a/src/app/mod.rs b/src/app/mod.rs index 8390699..d6e9b75 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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; diff --git a/src/app/mode.rs b/src/app/mode.rs index 4c6c9f1..f09fa20 100644 --- a/src/app/mode.rs +++ b/src/app/mode.rs @@ -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", } diff --git a/src/app/registers.rs b/src/app/registers.rs new file mode 100644 index 0000000..4b5a0b0 --- /dev/null +++ b/src/app/registers.rs @@ -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, + named: BTreeMap, + clipboard: Option, +} + +impl Registers { + /// Keeps text that was yanked or deleted into `register` (or none). + pub fn store(&mut self, register: Option, 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) -> 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); + } +} diff --git a/src/app/text_edit.rs b/src/app/text_edit.rs new file mode 100644 index 0000000..0024602 --- /dev/null +++ b/src/app/text_edit.rs @@ -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, +} + +/// 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 { + 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, ctx: &ViewContext<'_>) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/app/text_edit/tests.rs b/src/app/text_edit/tests.rs new file mode 100644 index 0000000..94632ab --- /dev/null +++ b/src/app/text_edit/tests.rs @@ -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, +} + +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) { + 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 barrelDrip line"); + 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("Timer"); + 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("bcwblend"); + assert_eq!(app.mode.label(), "TEXT"); + app.press(""); + 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"); + 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"); + 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!oCompost"); + 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("iyiw"); + app.buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false }); + app.press("A p"); + 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"); + 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"); + assert_eq!(app.said.last().map(String::as_str), Some("this node is right under the root already")); +} diff --git a/src/input/bindings.rs b/src/input/bindings.rs index 9bd6306..329ac48 100644 --- a/src/input/bindings.rs +++ b/src/input/bindings.rs @@ -5,7 +5,9 @@ //! Keys are written in vim notation (see [`key`](super::key)); `` is //! the leader key, `` unless `keys.toml` says otherwise. -use super::command::{Bound, CharKey, Command as C, Motion as M, Operator as Op, Target, TargetBound}; +use super::command::{ + Bound, CharKey, Command as C, Motion as M, Operator as Op, Target, TargetBound, TextObject, +}; /// Where a table's keys work. Each is a section in `keys.toml`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -25,10 +27,15 @@ pub enum Context { Connect, /// Lists in popups: pickers, menus, the help. List, + /// Vim keys inside a title or body being edited (after Esc). + Text, + /// Typing a title or body. Keys these tables don't name type + /// themselves. + Insert, } impl Context { - pub const ALL: [Context; 9] = [ + pub const ALL: [Context; 11] = [ Context::Motions, Context::Normal, Context::Canvas, @@ -38,6 +45,8 @@ impl Context { Context::Move, Context::Connect, Context::List, + Context::Text, + Context::Insert, ]; /// The section's name in `keys.toml`. @@ -52,6 +61,8 @@ impl Context { Context::Move => "move", Context::Connect => "connect", Context::List => "list", + Context::Text => "text", + Context::Insert => "insert", } } @@ -71,8 +82,25 @@ impl Context { Context::Move => MOVE, Context::Connect => CONNECT, Context::List => LIST, + Context::Text => TEXT, + Context::Insert => INSERT, } } + + /// What an operator can act on here, besides its own key again. + pub fn targets(self) -> &'static [(&'static str, TargetBound, &'static str)] { + match self { + Context::Normal => NODE_TARGETS, + Context::Text => TEXT_TARGETS, + _ => &[], + } + } + + /// Whether this context's motions can be an operator's target too, as + /// in vim: inside text, `dw` deletes to the next word. + pub fn motions_are_targets(self) -> bool { + self == Context::Text + } } /// The tables that work together in each mode and view, most specific first: @@ -85,6 +113,8 @@ pub const SITUATIONS: &[(&str, &[Context])] = &[ ("move mode", &[Context::Move]), ("connect mode", &[Context::Connect, Context::Canvas, Context::Motions]), ("lists", &[Context::List]), + ("vim keys inside text", &[Context::Text]), + ("typing", &[Context::Insert]), ]; /// A default binding: keys in notation, what they do, and how to say it @@ -296,6 +326,80 @@ const LIST: &[DefaultBinding] = &[ bind_help("q", C::Escape, "close"), ]; +/// Vim keys inside a title or body being edited. Operators take these +/// motions as targets too: `dw`, `c$`, `dfx`. +const TEXT: &[DefaultBinding] = &[ + go("h", M::Left), + go("", M::Left), + go("j", M::Down), + go("", M::Down), + go("k", M::Up), + go("", M::Up), + go("l", M::Right), + go("", M::Right), + bind_help("w", C::Move(M::Next), "the next word"), + bind_help("b", C::Move(M::Previous), "the previous word"), + go("e", M::WordEnd), + go("0", M::LineStart), + go("", M::LineStart), + go("^", M::FirstNonBlank), + go("$", M::LineEnd), + go("", M::LineEnd), + bind_help("gg", C::Move(M::First), "the first line"), + bind_help("G", C::Move(M::Last), "the last line"), + with_char("f", CharKey::FindForward), + with_char("F", CharKey::FindBackward), + with_char("t", CharKey::TillForward), + with_char("T", CharKey::TillBackward), + go(";", M::RepeatFind), + go(",", M::RepeatFindBackward), + operator("d", Op::Delete), + operator("c", Op::Change), + operator("y", Op::Yank), + bind_help("x", C::Operate(Op::Delete, Target::Motion(M::Right)), "delete the character"), + bind_help("X", C::Operate(Op::Delete, Target::Motion(M::Left)), "delete the character before"), + bind_help("s", C::Operate(Op::Change, Target::Motion(M::Right)), "type over the character"), + bind_help("S", C::Operate(Op::Change, Target::Current), "retype the line"), + bind_help("D", C::Operate(Op::Delete, Target::Motion(M::LineEnd)), "delete to the end of the line"), + bind_help("C", C::Operate(Op::Change, Target::Motion(M::LineEnd)), "change to the end of the line"), + bind_help("Y", C::Operate(Op::Yank, Target::Current), "yank the line"), + with_char("r", CharKey::Replace), + bind("~", C::SwitchCase), + bind("J", C::JoinLines), + bind_help("p", C::PasteAfter, "paste after the cursor"), + bind_help("P", C::PasteBefore, "paste before the cursor"), + bind_help("o", C::AddBelow, "a new line below (in a title: a new sibling)"), + bind_help("O", C::AddAbove, "a new line above (in a title: a new sibling)"), + bind("i", C::InsertBefore), + bind("a", C::InsertAfter), + bind("I", C::InsertAtStart), + bind("A", C::InsertAtEnd), + bind("u", C::Undo), + bind("", C::Redo), + bind(".", C::Repeat), + bind_help("", C::Escape, "done"), + bind_help("", C::Escape, "done"), +]; + +/// Typing a title or body. A key that isn't here types itself. +const INSERT: &[DefaultBinding] = &[ + bind_help("", C::Confirm, "done (after o, O or a: the next idea; in a body: a new line)"), + bind_help("", C::Operate(Op::Indent, Target::Current), "a new idea goes under the one above"), + bind_help("", C::Operate(Op::Outdent, Target::Current), "a new idea moves out a level"), + bind_help("", C::Escape, "vim keys inside the text"), + bind_help("", C::Escape, "vim keys inside the text"), + bind("", C::DeleteBefore), + bind("", C::DeleteAfter), + bind("", C::DeleteWordBefore), + bind("", C::DeleteLineBefore), + go("", M::Left), + go("", M::Right), + go("", M::Up), + go("", M::Down), + go("", M::LineStart), + go("", M::LineEnd), +]; + /// What an operator can act on, besides its own key again (`dd`: this node /// and its branch). pub const NODE_TARGETS: &[(&str, TargetBound, &str)] = &[ @@ -303,6 +407,17 @@ pub const NODE_TARGETS: &[(&str, TargetBound, &str)] = &[ ("ib", TargetBound::Target(Target::Children), "inside the branch: just what's below"), ]; +/// What an operator can act on inside text, besides motions and its own key +/// again (`dd`: the line). +pub const TEXT_TARGETS: &[(&str, TargetBound, &str)] = &[ + ("iw", TargetBound::Target(Target::Inside(TextObject::Word)), "the word"), + ("aw", TargetBound::Target(Target::Around(TextObject::Word)), "the word and a space"), + ("i\"", TargetBound::Target(Target::Inside(TextObject::Quotes)), "inside the quotes"), + ("a\"", TargetBound::Target(Target::Around(TextObject::Quotes)), "the quotes and what's in them"), + ("i(", TargetBound::Target(Target::Inside(TextObject::Parens)), "inside the brackets"), + ("a(", TargetBound::Target(Target::Around(TextObject::Parens)), "the brackets and what's in them"), +]; + /// Names for key groups: the title of which-key's popup, and its `+group` /// entries. pub const GROUPS: &[(&str, &str)] = &[ diff --git a/src/input/command.rs b/src/input/command.rs index e35a8a9..0e4611d 100644 --- a/src/input/command.rs +++ b/src/input/command.rs @@ -26,6 +26,14 @@ pub enum Motion { First, /// The last node (in text: the end). Last, + /// The end of the word (`e`, in text). + WordEnd, + /// The start of the line (`0`, in text). + LineStart, + /// The line's first character that isn't a space (`^`, in text). + FirstNonBlank, + /// The end of the line (`$`, in text). + LineEnd, SearchNext, SearchPrevious, /// To the next time a character shows up on the line (`f`, in text). @@ -36,6 +44,10 @@ pub enum Motion { TillForward(char), /// To just after the previous time a character shows up (`T`). TillBackward(char), + /// The last `f`, `F`, `t` or `T` again (`;`). + RepeatFind, + /// The last `f`, `F`, `t` or `T` again, the other way (`,`). + RepeatFindBackward, } /// Keys that wait for a target, like vim's `d`: `dd`, `dab`. @@ -75,6 +87,22 @@ pub enum Target { Selection, /// From the cursor to where a motion goes (`dw`, `dfx`, in text). Motion(Motion), + /// What's inside a piece of text (`iw`, `i"`, `i(`). + Inside(TextObject), + /// A piece of text with its edges (`aw`: the word and a space, `a"`: + /// the quotes too). + Around(TextObject), +} + +/// Pieces of text an operator can act on wherever the cursor is in them, as +/// in vim: `ciw` changes the word under the cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TextObject { + Word, + /// Text in double quotes. + Quotes, + /// Text in round brackets. + Parens, } /// Keys that need one more character, typed right after them: `m` then a @@ -87,6 +115,8 @@ pub enum CharKey { FindBackward, TillForward, TillBackward, + /// `r`, in text: the character to put in place of the cursor's. + Replace, } impl CharKey { @@ -99,9 +129,19 @@ impl CharKey { CharKey::FindBackward => Command::Move(Motion::FindBackward(c)), CharKey::TillForward => Command::Move(Motion::TillForward(c)), CharKey::TillBackward => Command::Move(Motion::TillBackward(c)), + CharKey::Replace => Command::ReplaceChar(c), } } + /// Whether the key moves the cursor (`f`, `t`…), so it can be an + /// operator's target too: `dfx`. + pub fn moves(self) -> bool { + matches!( + self, + CharKey::FindForward | CharKey::FindBackward | CharKey::TillForward | CharKey::TillBackward + ) + } + /// As the target of an operator, once the character is known (`dfx`). /// Only motions can be targets. pub fn target(self, c: char) -> Option { @@ -118,6 +158,7 @@ impl CharKey { CharKey::JumpToMark => { &[("a–z A–Z", "go to that mark"), ("'", "back to where you were before the last jump")] } + CharKey::Replace => &[("any key", "the character to put in its place")], _ => &[("any key", "the character to go to")], } } @@ -214,6 +255,32 @@ pub enum Command { Redo, Repeat, + // Vim keys inside text. + /// `i`: type before the cursor. + InsertBefore, + /// `a`: type after the cursor. + InsertAfter, + /// `I`: type at the start of the line. + InsertAtStart, + /// `A`: type at the end of the line. + InsertAtEnd, + /// `r`: this character in place of the cursor's. + ReplaceChar(char), + /// `~`: upper case to lower case and back. + SwitchCase, + /// `J`: the next line joins this one. + JoinLines, + + // Typing. + /// Backspace. + DeleteBefore, + /// Delete. + DeleteAfter, + /// `Ctrl-w`. + DeleteWordBefore, + /// `Ctrl-u`. + DeleteLineBefore, + // Looks, tags, portals. PickColor, PickStyle, @@ -331,6 +398,12 @@ pub const COMMANDS: &[CommandInfo] = &[ motion("previous", M::Previous, "the previous node, in reading order"), motion("first", M::First, "the root"), motion("last", M::Last, "the last node"), + motion("word-end", M::WordEnd, "the end of the word"), + motion("line-start", M::LineStart, "the start of the line"), + motion("first-non-blank", M::FirstNonBlank, "the first character of the line that isn't a space"), + motion("line-end", M::LineEnd, "the end of the line"), + motion("repeat-find", M::RepeatFind, "the last f / t again"), + motion("repeat-find-backward", M::RepeatFindBackward, "the last f / t again, the other way"), motion("search-next", M::SearchNext, "the next match"), motion("search-previous", M::SearchPrevious, "the previous match"), with_char("find-forward", CharKey::FindForward, "to a character"), @@ -390,6 +463,21 @@ pub const COMMANDS: &[CommandInfo] = &[ info("undo", C::Undo, "undo"), info("redo", C::Redo, "redo"), info("repeat", C::Repeat, "repeat the last change"), + info("indent-node", C::Operate(Op::Indent, Target::Current), "under the node above"), + info("outdent-node", C::Operate(Op::Outdent, Target::Current), "out from under its parent"), + // Vim keys inside text. + info("insert", C::InsertBefore, "type before the cursor"), + info("append", C::InsertAfter, "type after the cursor"), + info("insert-at-start", C::InsertAtStart, "type at the start of the line"), + info("append-at-end", C::InsertAtEnd, "type at the end of the line"), + with_char("replace-char", CharKey::Replace, "replace the character"), + info("switch-case", C::SwitchCase, "switch upper and lower case"), + info("join-lines", C::JoinLines, "join the next line to this one"), + // Typing. + info("delete-before", C::DeleteBefore, "delete the character before the cursor"), + info("delete-after", C::DeleteAfter, "delete the character after the cursor"), + info("delete-word-before", C::DeleteWordBefore, "delete the word before the cursor"), + info("delete-line-before", C::DeleteLineBefore, "delete back to the start of the line"), // Looks, tags, portals. info("pick-color", C::PickColor, "color…"), info("pick-style", C::PickStyle, "style…"), diff --git a/src/input/engine.rs b/src/input/engine.rs index 4b096a7..483317d 100644 --- a/src/input/engine.rs +++ b/src/input/engine.rs @@ -191,10 +191,14 @@ impl KeyEngine { if self.keys.is_empty() { // A count takes in the next siblings, as `3dd` takes three // lines in vim: say exactly how many. - let help = match multiply(*count, self.count).unwrap_or(1) { - 1 => "this node, with its branch".to_string(), - 2 => "2 nodes: this one and the next sibling, with their branches".to_string(), - many => format!( + let help = match (tables.in_text(), multiply(*count, self.count).unwrap_or(1)) { + (true, 1) => "the line".to_string(), + (true, many) => format!("{many} lines"), + (false, 1) => "this node, with its branch".to_string(), + (false, 2) => { + "2 nodes: this one and the next sibling, with their branches".to_string() + } + (false, many) => format!( "{many} nodes: this one and the next {} siblings, with their branches", many - 1 ), @@ -356,7 +360,7 @@ fn next_keys(typed: &[Key], candidates: &[&Binding], tables: &Tables<'_>) -> Vec mod tests { use super::*; use crate::input::bindings::Context; - use crate::input::command::Motion; + use crate::input::command::{Motion, TextObject}; use crate::input::key::parse_keys; use crate::input::keymap::{DEFAULT_LEADER, Keymap}; @@ -417,6 +421,39 @@ mod tests { assert_eq!(type_keys(&mut engine, &keymap, "'A"), run(Command::JumpToMark('A'), None, None)); } + #[test] + fn inside_text_motions_are_targets_too() { + let keymap = Keymap::new(DEFAULT_LEADER); + let tables = keymap.tables(&[Context::Text]); + let mut engine = KeyEngine::default(); + let mut type_text = |text: &str| { + let keys = parse_keys(text, keymap.leader()).unwrap(); + keys.into_iter().map(|key| engine.press(key, &tables)).last().unwrap() + }; + let operate = |operator, target, count| run(Command::Operate(operator, target), count, None); + assert_eq!(type_text("dw"), operate(Operator::Delete, Target::Motion(Motion::Next), None)); + assert_eq!(type_text("d0"), operate(Operator::Delete, Target::Motion(Motion::LineStart), None)); + assert_eq!(type_text("ci\""), operate(Operator::Change, Target::Inside(TextObject::Quotes), None)); + assert_eq!(type_text("ya("), operate(Operator::Yank, Target::Around(TextObject::Parens), None)); + assert_eq!( + type_text("2d3fx"), + operate(Operator::Delete, Target::Motion(Motion::FindForward('x')), Some(6)) + ); + assert_eq!(type_text("cc"), operate(Operator::Change, Target::Current, None)); + assert_eq!(type_text("x"), operate(Operator::Delete, Target::Motion(Motion::Right), None)); + assert_eq!(type_text("rq"), run(Command::ReplaceChar('q'), None, None)); + + type_text("d"); + let hints = engine.hints(&tables).unwrap(); + assert_eq!(hints.entries[0], Hint { keys: "d".into(), help: "the line".into(), group: false }); + assert!(hints.entries.contains(&Hint { keys: "i".into(), help: "+inside".into(), group: true })); + assert!(hints.entries.contains(&Hint { + keys: "w".into(), + help: "the next word".into(), + group: false + })); + } + #[test] fn which_key_lists_what_can_follow() { let keymap = Keymap::new(DEFAULT_LEADER); diff --git a/src/input/keymap.rs b/src/input/keymap.rs index dc8da49..d287ee8 100644 --- a/src/input/keymap.rs +++ b/src/input/keymap.rs @@ -5,8 +5,8 @@ //! leader = "" # the key stands for //! //! [normal] # a section per place: motions, normal, canvas, -//! "x" = "delete-branch" # outline, note, visual, move, connect, list -//! "Q" = "close" +//! "x" = "delete-branch" # outline, note, visual, move, connect, list, +//! "Q" = "close" # text (vim keys in text), insert (typing) //! "x" = "nothing" # "nothing" removes a default key //! ``` //! @@ -15,8 +15,8 @@ use std::collections::BTreeMap; -use super::bindings::{Context, GROUPS, NODE_TARGETS, SITUATIONS, TARGET_GROUPS}; -use super::command::{self, Bound, COMMANDS, TargetBound}; +use super::bindings::{Context, GROUPS, SITUATIONS, TARGET_GROUPS}; +use super::command::{self, Bound, COMMANDS, Command, Target, TargetBound}; use super::key::{Key, notation, parse_keys}; /// The leader key unless `keys.toml` names another. @@ -43,7 +43,8 @@ pub struct TargetBinding { pub struct Keymap { leader: Key, tables: BTreeMap>, - targets: Vec, + /// Each context's operator targets (motions aside). + targets: BTreeMap>, groups: Vec<(Vec, &'static str)>, target_groups: Vec<(Vec, &'static str)>, } @@ -78,10 +79,17 @@ impl Keymap { (context, bindings) }) .collect(); - let targets = NODE_TARGETS - .iter() - .filter_map(|(keys, bound, help)| { - Some(TargetBinding { keys: parse(keys)?, bound: *bound, help: help.to_string() }) + let targets = Context::ALL + .into_iter() + .map(|context| { + let targets = context + .targets() + .iter() + .filter_map(|(keys, bound, help)| { + Some(TargetBinding { keys: parse(keys)?, bound: *bound, help: help.to_string() }) + }) + .collect(); + (context, targets) }) .collect(); let named = |groups: &[(&str, &'static str)]| -> Vec<(Vec, &'static str)> { @@ -148,6 +156,9 @@ impl Keymap { /// named by `name`, or unbind it for "nothing". fn apply(&mut self, context: Context, keys_text: &str, name: &toml::Value) -> Result<(), String> { let keys = parse_keys(keys_text, self.leader).map_err(|error| error.to_string())?; + if context == Context::Insert && keys.len() > 1 { + return Err("keys for typing are one key each: the others type themselves".into()); + } if let Some(first) = keys[0].as_char() { if first.is_ascii_digit() && first != '0' { return Err("keys can't start with 1–9: those type a count".into()); @@ -224,14 +235,54 @@ impl<'a> Tables<'a> { found } + /// What exactly these keys are bound to (for typing, where each key + /// stands alone). + pub fn bound(&self, keys: &[Key]) -> Option { + self.bindings().into_iter().find(|binding| binding.keys == keys).map(|binding| binding.bound) + } + /// The bindings whose keys start with `keys`. pub fn commands_starting_with(&self, keys: &[Key]) -> Vec<&'a Binding> { self.bindings().into_iter().filter(|binding| binding.keys.starts_with(keys)).collect() } - /// The targets whose keys start with `keys`. - pub fn targets_starting_with(&self, keys: &[Key]) -> Vec<&'a TargetBinding> { - self.keymap.targets.iter().filter(|target| target.keys.starts_with(keys)).collect() + /// The targets whose keys start with `keys`: each context's own, and + /// its motions where motions can be targets (inside text). + pub fn targets_starting_with(&self, keys: &[Key]) -> Vec { + let mut found: Vec = Vec::new(); + for &context in self.contexts { + let own = self.keymap.targets.get(&context).into_iter().flatten().cloned(); + let motions = self.motion_targets(context); + for target in own.chain(motions) { + if target.keys.starts_with(keys) && found.iter().all(|other| other.keys != target.keys) { + found.push(target); + } + } + } + found + } + + /// Whether these are the tables for vim keys inside text. + pub fn in_text(&self) -> bool { + self.contexts.contains(&Context::Text) + } + + /// A context's motions, as operator targets, where they can be. + fn motion_targets(&self, context: Context) -> Vec { + if !context.motions_are_targets() { + return Vec::new(); + } + let bindings = self.keymap.tables.get(&context).into_iter().flatten(); + bindings + .filter_map(|binding| { + let bound = match binding.bound { + Bound::Command(Command::Move(motion)) => TargetBound::Target(Target::Motion(motion)), + Bound::WithChar(kind) if kind.moves() => TargetBound::WithChar(kind), + _ => return None, + }; + Some(TargetBinding { keys: binding.keys.clone(), bound, help: binding.help.clone() }) + }) + .collect() } /// The name of a group of keys, like "go to" for `g`. @@ -275,7 +326,9 @@ mod tests { for context in Context::ALL { assert_eq!(keymap.tables[&context].len(), context.defaults().len(), "{context:?}"); } - assert_eq!(keymap.targets.len(), NODE_TARGETS.len()); + for context in Context::ALL { + assert_eq!(keymap.targets[&context].len(), context.targets().len(), "{context:?}"); + } assert_eq!(keymap.hidden_bindings(), Vec::::new()); } @@ -320,7 +373,7 @@ mod tests { "[normal] \"\": there's no key called ", "[normal] \"X\": there's no command \"split-vert\"; did you mean \"split-vertical\"?", "[normal] \"Y\": the command should be a name in quotes, like \"delete-branch\"", - "[nromal] isn't a section: use one of motions, normal, canvas, outline, note, visual, move, connect, list", + "[nromal] isn't a section: use one of motions, normal, canvas, outline, note, visual, move, connect, list, text, insert", ] ); let hidden = "in normal mode on the canvas, g hides gt: typing g runs \"help: every key\" first"; diff --git a/src/model/history.rs b/src/model/history.rs index 4941323..259ffa1 100644 --- a/src/model/history.rs +++ b/src/model/history.rs @@ -73,6 +73,11 @@ impl History { Some(last) if group.is_some() && last.group == group => { last.change = std::mem::take(&mut last.change).then(change); last.state = state; + // The group undid itself (a node added, then removed when it + // was left empty): there's nothing left to undo. + if last.change.is_empty() { + self.done.pop(); + } } _ => self.done.push(Step { state, change, group }), } @@ -216,4 +221,18 @@ mod tests { assert_eq!(doc, start, "three moves, one step"); assert!(!history.can_undo()); } + + #[test] + fn a_group_that_undoes_itself_leaves_no_step() { + let (mut doc, g) = crate::model::test_support::garden(); + let mut history = History::default(); + let typing = Group::new(); + let node = crate::model::test_support::idea("new idea"); + let add = crate::model::EditOp::Add { node, spot: crate::model::Spot::After(g.soil) }; + let (crate::model::Applied::Added(added), change) = doc.apply(&add).unwrap() else { panic!("added") }; + history.record_in(change, typing); + let (_, change) = doc.apply(&crate::model::EditOp::RemoveBranches(vec![added])).unwrap(); + history.record_in(change, typing); + assert!(!history.can_undo(), "added and removed: nothing to undo"); + } } diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 41cd34d..d1232fe 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -21,7 +21,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Clear, Paragraph}; use unicode_width::UnicodeWidthStr; -use super::line_input::LineInput; +use super::text_input::TextInput; use super::theme::Theme; use crate::command::complete::{Completion, complete}; use crate::command::history::History; @@ -56,7 +56,7 @@ impl PopupMode { #[derive(Debug, Clone, Default)] pub struct CommandLine { popup: PopupMode, - input: LineInput, + input: TextInput, completion: Completion, /// The candidate Tab put in the line, while Tab cycles through them. selected: Option, diff --git a/src/ui/line_input.rs b/src/ui/line_input.rs deleted file mode 100644 index 5a97592..0000000 --- a/src/ui/line_input.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! One line of text being typed, with its cursor: the command line now, and -//! titles later. The cursor moves a whole character at a time, so an -//! accented letter or an emoji made of several code points is one step. - -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -use crate::input::key::{Key, KeyName}; - -/// A line of text and a cursor in it. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct LineInput { - text: String, - /// A byte offset in `text`, always between two characters. - cursor: usize, -} - -impl LineInput { - /// A line holding `text`, with the cursor at its end. - pub fn new(text: &str) -> LineInput { - LineInput { text: text.to_string(), cursor: text.len() } - } - - pub fn text(&self) -> &str { - &self.text - } - - /// Where the cursor is: a byte offset in the text. - pub fn cursor(&self) -> usize { - self.cursor - } - - /// Whether the cursor is after the last character. - pub fn at_end(&self) -> bool { - self.cursor == self.text.len() - } - - /// The screen column of the cursor: how wide the text before it is. - pub fn cursor_column(&self) -> usize { - self.text[..self.cursor].width() - } - - /// Replaces the text, and puts the cursor at its end. - pub fn set(&mut self, text: &str) { - *self = LineInput::new(text); - } - - /// Replaces the bytes `from..to` with `text`, leaving the cursor after it. - pub fn replace(&mut self, from: usize, to: usize, text: &str) { - self.text.replace_range(from..to, text); - self.cursor = from + text.len(); - } - - /// Types text at the cursor. - pub fn insert(&mut self, text: &str) { - self.text.insert_str(self.cursor, text); - self.cursor += text.len(); - } - - /// Edits the line with one of the keys every text field shares. Returns - /// false for keys it doesn't use, for the caller to handle. - /// - /// | Key | What it does | - /// |----------------------|--------------------------------------| - /// | a character | types it | - /// | Backspace, Del | deletes before / after the cursor | - /// | ← →, Home, End | move the cursor | - /// | `Ctrl-b`, `Ctrl-e` | to the start / the end (as in vim's command line) | - /// | `Ctrl-w` | deletes the word before the cursor | - /// | `Ctrl-u` | deletes everything before the cursor | - pub fn edit(&mut self, key: Key) -> bool { - match (key.name, key.ctrl) { - (KeyName::Char(c), false) if !key.alt => self.insert(c.encode_utf8(&mut [0; 4])), - (KeyName::Backspace, _) => { - let start = self.previous_boundary(); - self.text.replace_range(start..self.cursor, ""); - self.cursor = start; - } - (KeyName::Delete, _) => { - let end = self.next_boundary(); - self.text.replace_range(self.cursor..end, ""); - } - (KeyName::Left, _) => self.cursor = self.previous_boundary(), - (KeyName::Right, _) => self.cursor = self.next_boundary(), - (KeyName::Home, _) | (KeyName::Char('b'), true) => self.cursor = 0, - (KeyName::End, _) | (KeyName::Char('e'), true) => self.cursor = self.text.len(), - (KeyName::Char('w'), true) => { - let start = self.word_start_before(); - self.text.replace_range(start..self.cursor, ""); - self.cursor = start; - } - (KeyName::Char('u'), true) => { - self.text.replace_range(..self.cursor, ""); - self.cursor = 0; - } - _ => return false, - } - true - } - - /// Where the character before the cursor starts. - fn previous_boundary(&self) -> usize { - self.text[..self.cursor].grapheme_indices(true).next_back().map_or(0, |(start, _)| start) - } - - /// Where the character after the cursor ends. - fn next_boundary(&self) -> usize { - self.text[self.cursor..].graphemes(true).next().map_or(self.cursor, |next| self.cursor + next.len()) - } - - /// Where `Ctrl-w` deletes back to, as in vim: past any spaces, then a run - /// of letters and digits, or else a run of other signs (so `notes/` goes - /// in two steps). - fn word_start_before(&self) -> usize { - let before = &self.text[..self.cursor]; - let end = before.trim_end().len(); - let is_word = |c: char| c.is_alphanumeric() || c == '_'; - let last_is_word = before[..end].chars().next_back().is_some_and(is_word); - before[..end] - .char_indices() - .rev() - .take_while(|&(_, c)| !c.is_whitespace() && is_word(c) == last_is_word) - .last() - .map_or(end, |(start, _)| start) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::input::key::parse_keys; - - fn typed(start: &str, keys: &str) -> LineInput { - let mut line = LineInput::new(start); - for key in parse_keys(keys, Key::char(' ')).unwrap() { - line.edit(key); - } - line - } - - #[test] - fn typing_and_moving() { - let line = typed("", "e gardenx!"); - assert_eq!(line.text(), "exgarden!"); - assert_eq!(line.cursor(), line.text().len()); - assert_eq!(typed("café", "e").text(), "cafe", "é is one character"); - let flag = typed("ok 👍🏽", ""); - assert_eq!(flag.cursor(), 2, "an emoji with a skin tone is one step"); - assert_eq!(flag.cursor_column(), 2); - } - - #[test] - fn deleting_words_and_lines() { - assert_eq!(typed("e notes/garden", "").text(), "e notes/"); - assert_eq!(typed("e notes/", "").text(), "e notes"); - assert_eq!(typed("fold open ", "").text(), "fold "); - assert_eq!(typed("fold open", "").text(), "en"); - } -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7c08222..4449783 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,11 +1,12 @@ //! What the views share: the characters they draw with ([`glyphs`]), the -//! colors and looks ([`theme`]), the which-key popup ([`which_key`]), and the -//! command line ([`command_line`]) with the line input it types into -//! ([`line_input`]). The bars and the other shared widgets come in later -//! stages. +//! colors and looks ([`theme`]), the which-key popup ([`which_key`]), the +//! command line ([`command_line`]), and text being typed ([`text_input`]) +//! or edited with vim keys ([`text_editor`]). The bars and the other shared +//! widgets come in later stages. pub mod command_line; pub mod glyphs; -pub mod line_input; +pub mod text_editor; +pub mod text_input; pub mod theme; pub mod which_key; diff --git a/src/ui/text_editor/layout.rs b/src/ui/text_editor/layout.rs new file mode 100644 index 0000000..644eafb --- /dev/null +++ b/src/ui/text_editor/layout.rs @@ -0,0 +1,135 @@ +//! Where edited text goes on screen: the rows it wraps into, the cell the +//! cursor is on, and the other way round, where a click lands in the text. + +use std::ops::Range; + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// The rows `text` wraps into at `width` columns: byte ranges that cover the +/// whole text, in order. A row breaks after a space when it can (the space +/// stays at the end of the row it ends), or inside a word longer than a +/// row. A line break ends its row and belongs to it, and text ending in a +/// line break has an empty last row, for the cursor. +/// +/// For text without double spaces, the rows break where the node boxes +/// wrap their titles, so a title doesn't jump when you start editing it. +pub fn rows(text: &str, width: usize) -> Vec> { + let width = width.max(1); + let mut rows = Vec::new(); + let mut start = 0; + let mut used = 0; + // Where the row could break: just after its last space. + let mut break_at = None; + for (at, grapheme) in text.grapheme_indices(true) { + if grapheme == "\n" { + rows.push(start..at + 1); + (start, used, break_at) = (at + 1, 0, None); + continue; + } + let cells = grapheme.width(); + if grapheme == " " { + if used + cells > width { + // A space that doesn't fit hangs at the end of the row. + rows.push(start..at + 1); + (start, used, break_at) = (at + 1, 0, None); + } else { + used += cells; + break_at = Some(at + 1); + } + continue; + } + if used + cells > width { + match break_at { + Some(after_space) if after_space > start => { + rows.push(start..after_space); + used = text[after_space..at].width(); + start = after_space; + } + _ => { + rows.push(start..at); + (start, used) = (at, 0); + } + } + break_at = None; + } + used += cells; + } + rows.push(start..text.len()); + rows +} + +/// How wide a row shows: its text without the line break, and without the +/// spaces it breaks at (unless it's the last row of its line, where spaces +/// are still being typed). +pub fn row_width(text: &str, rows: &[Range], index: usize) -> usize { + let row = &text[rows[index].clone()]; + let row = row.strip_suffix('\n').unwrap_or(row); + let soft_break = + rows.get(index + 1).is_some() && !rows[index].is_empty() && !text[..rows[index].end].ends_with('\n'); + if soft_break { row.trim_end_matches(' ').width() } else { row.width() } +} + +/// The row and column the cursor is on. +pub fn cell(text: &str, rows: &[Range], cursor: usize) -> (usize, usize) { + let row = rows.iter().rposition(|row| row.start <= cursor).unwrap_or(0); + (row, text[rows[row].start..cursor.max(rows[row].start)].width()) +} + +/// Where in the text a cell of the rows is: the character there, or the +/// nearest one on that row. +pub fn offset(text: &str, rows: &[Range], row: usize, column: usize) -> usize { + let Some(range) = rows.get(row.min(rows.len().saturating_sub(1))) else { return 0 }; + let row_text = &text[range.clone()]; + let mut width = 0; + let mut last = range.start; + for (at, grapheme) in row_text.grapheme_indices(true) { + if grapheme == "\n" { + return range.start + at; + } + width += grapheme.width(); + last = range.start + at; + if width > column { + return last; + } + } + // Past the end of the row: at the end of the text or line, or on the + // row's last character when the next row goes on from there. + if range.end == text.len() { range.end } else { last } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::canvas::boxes::wrap; + + fn shown(text: &str, width: usize) -> Vec<&str> { + rows(text, width).into_iter().map(|row| &text[row]).collect() + } + + #[test] + fn rows_break_like_the_boxes() { + assert_eq!(shown("aaaa bbbb", 6), ["aaaa ", "bbbb"]); + assert_eq!(shown("aaaaaaaa", 6), ["aaaaaa", "aa"]); + assert_eq!(shown("ab cdefghij", 6), ["ab ", "cdefgh", "ij"]); + assert_eq!(shown("one\ntwo\n", 10), ["one\n", "two\n", ""]); + assert_eq!(shown("", 10), [""]); + let title = "Raised beds for the cedar, untreated, about forty cm high"; + let wrapped: Vec = shown(title, 30).iter().map(|row| row.trim_end().to_string()).collect(); + assert_eq!(wrapped, wrap(title, 30)); + } + + #[test] + fn the_cursor_and_clicks() { + let text = "aaaa bbbb"; + let rows = rows(text, 6); + assert_eq!(cell(text, &rows, 4), (0, 4), "on the space"); + assert_eq!(cell(text, &rows, 5), (1, 0), "after it: the next row"); + assert_eq!(cell(text, &rows, 9), (1, 4), "at the end"); + assert_eq!(offset(text, &rows, 1, 2), 7); + assert_eq!(offset(text, &rows, 1, 40), 9, "past the end of the text"); + assert_eq!(offset(text, &rows, 0, 40), 4, "past the end of a row: its last character"); + assert_eq!(row_width(text, &rows, 0), 4, "the space it breaks at doesn't count"); + assert_eq!(row_width("ab ", &super::rows("ab ", 6), 0), 3, "a space being typed does"); + } +} diff --git a/src/ui/text_editor/mod.rs b/src/ui/text_editor/mod.rs new file mode 100644 index 0000000..89b6270 --- /dev/null +++ b/src/ui/text_editor/mod.rs @@ -0,0 +1,675 @@ +//! A title or body being edited the vim way: typing, and vim's normal mode +//! inside the text after Esc. +//! +//! [`TextEditor`] holds the text and its cursor, whether you're typing, and +//! what the edit remembers: its own undo, the last `f` for `;`, and the last +//! change for `.`. It knows nothing of nodes or registers: commands that +//! yank or delete hand the text back ([`Reply::Yanked`]), and a paste is +//! handed the text to put. +//! +//! - [`motion`] works out where motions go, +//! - [`object`] finds text objects (`iw`, `a"`, `i(`), +//! - [`layout`] wraps text into rows on screen and finds the cursor's cell. + +pub mod layout; +mod motion; +mod object; + +use std::ops::Range; + +use unicode_segmentation::UnicodeSegmentation; + +use super::text_input::TextInput; +use crate::input::command::{Command, Motion, Operator, Target}; +use crate::input::engine::Typed; +use motion::{first_non_blank, last_char, line_end, line_start, next, previous}; + +/// Text that was yanked or deleted, for a register. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Yank { + pub text: String, + /// Whole lines (from `yy` or `dd`): they're pasted as lines of their own. + pub linewise: bool, +} + +/// One thing done while typing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Typing { + /// A character typed (in a body, `\n` starts a new line). + Char(char), + /// A typing command: Backspace, `Ctrl-w`, an arrow… + Command(Command), +} + +/// How a command went. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reply { + Done, + /// Done, and this text was yanked or deleted: it's for a register. + Yanked(Yank), + /// Not a command for the text itself (Esc, or `o` in a title): the + /// caller handles it. + NotMine, + /// It didn't work, and why. + Refused(&'static str), +} + +/// The text and the cursor at one moment, for undo. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Snapshot { + text: String, + cursor: usize, +} + +/// A change that `.` repeats: the command, what was typed after it, and +/// what it pasted. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Change { + typed: Typed, + typing: Vec, + pasted: Option, +} + +/// What an operator acts on. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Span { + range: Range, + /// For whole lines: the lines themselves, without the line break the + /// range takes with them. + lines: Option>, +} + +impl Span { + fn chars(range: Range) -> Span { + Span { range, lines: None } + } +} + +/// How much of the text a motion covers as an operator's target, as in vim: +/// `dw` stops before the next word, `de` takes the word's last letter, and +/// `dj` takes whole lines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Reach { + Exclusive, + Inclusive, + Linewise, +} + +/// A title or a body being edited. +#[derive(Debug, Clone)] +pub struct TextEditor { + input: TextInput, + /// A body, rather than a one-line title. + multiline: bool, + /// Typing, rather than using vim keys. + typing: bool, + undo: Vec, + redo: Vec, + /// The column `j` and `k` aim for, kept while they pass shorter lines. + column: Option, + /// The last `f`, `F`, `t` or `T`, for `;` and `,`. + last_find: Option, + /// The change being made, while it's typed in. + recording: Option, + /// The last change, for `.`. + last_change: Option, +} + +impl TextEditor { + /// `text` to edit, with vim keys and the cursor at its start: a body when + /// `multiline`, or else a one-line title. Typing starts with a command, + /// as in vim: `i`, `A`, `cc`… + pub fn new(text: &str, multiline: bool) -> TextEditor { + let mut input = TextInput::new(text); + input.set_cursor(0); + TextEditor { + input, + multiline, + typing: false, + undo: Vec::new(), + redo: Vec::new(), + column: None, + last_find: None, + recording: None, + last_change: None, + } + } + + pub fn text(&self) -> &str { + self.input.text() + } + + /// Where the cursor is: a byte offset in the text. + pub fn cursor(&self) -> usize { + self.input.cursor() + } + + /// Whether you're typing, rather than using vim keys. + pub fn is_typing(&self) -> bool { + self.typing + } + + /// Puts the cursor at `at` (for a click), as close as the mode allows. + pub fn set_cursor(&mut self, at: usize) { + self.input.set_cursor(at); + self.column = None; + self.clamp(); + } + + /// While typing: a character, or a typing command. Returns false for + /// what isn't for the text (Enter in a title, Tab…), or when not typing. + pub fn type_in(&mut self, typing: Typing) -> bool { + if !self.typing { + return false; + } + match typing { + Typing::Char('\n') if !self.multiline => self.input.insert(" "), + Typing::Char(c) => self.input.insert(c.encode_utf8(&mut [0; 4])), + Typing::Command(command) => match command { + Command::DeleteBefore => self.input.delete_before(), + Command::DeleteAfter => self.input.delete_after(), + Command::DeleteWordBefore => self.input.delete_word_before(), + Command::DeleteLineBefore => self.input.delete_line_before(), + Command::Move(Motion::Left) => self.input.left(), + Command::Move(Motion::Right) => self.input.right(), + Command::Move(Motion::LineStart) => self.input.line_start(), + Command::Move(Motion::LineEnd) => self.input.line_end(), + Command::Move(motion @ (Motion::Up | Motion::Down)) => { + if let Some(to) = self.destination(motion, None, false) { + self.input.set_cursor(to); + } + } + _ => return false, + }, + } + if !matches!(typing, Typing::Command(Command::Move(Motion::Up | Motion::Down))) { + self.column = None; + } + if let Some(change) = &mut self.recording { + change.typing.push(typing); + } + true + } + + /// Esc while typing: back to vim keys, the cursor on the last character + /// typed, as in vim. + pub fn stop_typing(&mut self) { + if !self.typing { + return; + } + self.typing = false; + let at = self.cursor(); + if at > line_start(self.text(), at) { + self.input.set_cursor(previous(self.text(), at)); + } + self.column = None; + self.end_change(); + } + + /// A command typed with vim keys. `paste` is the register's text, for + /// `p` and `P`. + pub fn command(&mut self, typed: &Typed, paste: Option<&Yank>) -> Reply { + let count = usize::try_from(typed.times()).unwrap_or(1); + let (text, at) = (self.text(), self.cursor()); + let reply = match typed.command { + Command::Move(motion) => { + self.go(motion, typed.count); + return Reply::Done; + } + Command::Undo => return self.undo(count), + Command::Redo => return self.redo(count), + Command::Repeat => return self.repeat(typed.count), + Command::Operate(Operator::Yank, target) => return self.yank(target, typed.count), + Command::Operate(operator @ (Operator::Delete | Operator::Change), target) => { + self.begin_change(typed, None); + self.cut(operator, target, typed.count) + } + Command::InsertBefore => self.start_typing(typed, at), + Command::InsertAfter => { + let after = if at < line_end(text, at) { next(text, at) } else { at }; + self.start_typing(typed, after) + } + Command::InsertAtStart => self.start_typing(typed, first_non_blank(text, at)), + Command::InsertAtEnd => self.start_typing(typed, line_end(text, at)), + Command::AddBelow | Command::AddAbove if self.multiline => { + self.begin_change(typed, None); + self.open_line(typed.command == Command::AddBelow); + Reply::Done + } + Command::ReplaceChar(c) => { + self.begin_change(typed, None); + self.replace_chars(c, count); + Reply::Done + } + Command::SwitchCase => { + self.begin_change(typed, None); + self.switch_case(count); + Reply::Done + } + Command::JoinLines => { + self.begin_change(typed, None); + self.join_lines(count.max(2) - 1); + Reply::Done + } + Command::PasteAfter | Command::PasteBefore => { + let Some(yank) = paste else { return Reply::Refused("nothing to paste yet") }; + self.begin_change(typed, Some(yank.clone())); + self.put(yank, typed.command == Command::PasteAfter, count); + Reply::Done + } + _ => return Reply::NotMine, + }; + self.end_change(); + reply + } + + // ----- Changes, undo and `.` ------------------------------------------ + + /// Before a change: a snapshot for undo, and the start of a recording + /// for `.`. + fn begin_change(&mut self, typed: &Typed, pasted: Option) { + self.undo.push(Snapshot { text: self.text().to_string(), cursor: self.cursor() }); + self.redo.clear(); + self.recording = Some(Change { typed: *typed, typing: Vec::new(), pasted }); + } + + /// After a change, unless typing goes on: a change that changed nothing + /// isn't worth an undo step, and `.` can now repeat it. + fn end_change(&mut self) { + if self.typing { + return; + } + if self.undo.last().is_some_and(|before| before.text == self.text()) { + self.undo.pop(); + } + if let Some(change) = self.recording.take() { + self.last_change = Some(change); + } + } + + fn start_typing(&mut self, typed: &Typed, at: usize) -> Reply { + self.begin_change(typed, None); + self.input.set_cursor(at); + self.typing = true; + self.column = None; + Reply::Done + } + + fn undo(&mut self, count: usize) -> Reply { + if self.undo.is_empty() { + return Reply::Refused("nothing to undo in this text"); + } + for _ in 0..count { + let Some(before) = self.undo.pop() else { break }; + self.redo.push(Snapshot { text: self.text().to_string(), cursor: self.cursor() }); + self.restore(before); + } + Reply::Done + } + + fn redo(&mut self, count: usize) -> Reply { + if self.redo.is_empty() { + return Reply::Refused("nothing to redo in this text"); + } + for _ in 0..count { + let Some(after) = self.redo.pop() else { break }; + self.undo.push(Snapshot { text: self.text().to_string(), cursor: self.cursor() }); + self.restore(after); + } + Reply::Done + } + + fn restore(&mut self, snapshot: Snapshot) { + self.input.set(&snapshot.text); + self.input.set_cursor(snapshot.cursor); + self.clamp(); + } + + /// `.`: the last change again, with a new count if one is typed. + fn repeat(&mut self, count: Option) -> Reply { + let Some(change) = self.last_change.clone() else { return Reply::Refused("no change to repeat yet") }; + let typed = Typed { count: count.or(change.typed.count), ..change.typed }; + let reply = self.command(&typed, change.pasted.as_ref()); + if self.typing { + for typing in change.typing { + self.type_in(typing); + } + self.stop_typing(); + } + reply + } + + // ----- Motions -------------------------------------------------------- + + /// Moves the cursor, in normal mode. + fn go(&mut self, motion: Motion, count: Option) { + if let Some(to) = self.destination(motion, count, false) { + self.input.set_cursor(to); + } + if !matches!(motion, Motion::Up | Motion::Down) { + self.column = None; + } + self.clamp(); + } + + /// Keeps the cursor on a character in normal mode: never past a line's + /// last one. + fn clamp(&mut self) { + if !self.typing { + let at = self.cursor(); + let furthest = last_char(self.text(), at); + if at > furthest { + self.input.set_cursor(furthest); + } + } + } + + /// Where a motion goes from the cursor, `None` when it can't. As an + /// operator's target (`operator`), `l` can reach the end of the line. + fn destination(&mut self, motion: Motion, count: Option, operator: bool) -> Option { + let times = count.map_or(1, |count| usize::try_from(count).unwrap_or(1)); + let text = self.input.text(); + let at = self.input.cursor(); + let repeat = |step: fn(&str, usize) -> usize| (0..times).fold(at, |pos, _| step(text, pos)); + let to = match motion { + Motion::Left => { + let start = line_start(text, at); + (0..times).fold(at, |pos, _| if pos > start { previous(text, pos) } else { pos }) + } + Motion::Right => { + let limit = if operator || self.typing { line_end(text, at) } else { last_char(text, at) }; + (0..times).fold(at, |pos, _| if pos < limit { next(text, pos).min(limit) } else { pos }) + } + Motion::Up | Motion::Down => { + let column = *self.column.get_or_insert_with(|| motion::column(text, at)); + let lines = isize::try_from(times).unwrap_or(1); + motion::vertical(text, at, if motion == Motion::Down { lines } else { -lines }, column)? + } + Motion::Next => repeat(motion::word_forward), + Motion::Previous => repeat(motion::word_backward), + Motion::WordEnd => repeat(motion::word_end), + Motion::LineStart => line_start(text, at), + Motion::FirstNonBlank => first_non_blank(text, at), + Motion::LineEnd => { + let below = isize::try_from(times - 1).unwrap_or(0); + line_end(text, motion::vertical(text, at, below, 0)?) + } + Motion::First => first_non_blank(text, motion::nth_line(text, times)), + Motion::Last => { + let line = count.map_or(motion::line_count(text), |_| times); + first_non_blank(text, motion::nth_line(text, line)) + } + Motion::FindForward(_) + | Motion::FindBackward(_) + | Motion::TillForward(_) + | Motion::TillBackward(_) => { + self.last_find = Some(motion); + find(text, at, motion, times, false)? + } + Motion::RepeatFind | Motion::RepeatFindBackward => { + let last = self.last_find?; + let find_motion = if motion == Motion::RepeatFind { last } else { reversed(last) }; + find(text, at, find_motion, times, true)? + } + _ => return None, + }; + Some(to) + } + + // ----- Operators ------------------------------------------------------ + + /// What an operator acts on, from the cursor. + fn span(&mut self, operator: Operator, target: Target, count: Option) -> Option { + let times = count.map_or(1, |count| usize::try_from(count).unwrap_or(1)); + let at = self.cursor(); + let span = match target { + // `dd`, `cc`, `yy`: the line, and the next ones with a count. + Target::Current => { + let text = self.text(); + let below = isize::try_from(times - 1).unwrap_or(0); + let last = motion::vertical(text, at, below, 0) + .unwrap_or_else(|| motion::nth_line(text, motion::line_count(text))); + lines(text, line_start(text, at), line_end(text, last)) + } + Target::Inside(object) | Target::Around(object) => { + let around = matches!(target, Target::Around(_)); + Span::chars(object::range(self.text(), at, object, around)?) + } + Target::Motion(motion) => return self.motion_span(operator, motion, count), + Target::Branch | Target::Children | Target::Selection => return None, + }; + (!span.range.is_empty()).then_some(span) + } + + /// What an operator acts on when its target is a motion. + fn motion_span(&mut self, operator: Operator, motion: Motion, count: Option) -> Option { + let at = self.cursor(); + // `cw` on a word changes to its end, like `ce`: the space after stays. + if operator == Operator::Change + && motion == Motion::Next + && let Some(class @ (motion::Class::Word | motion::Class::Sign)) = + motion::class_at(self.text(), at) + { + let text = self.text(); + let first_end = motion::run_end(text, at, class); + let times = count.map_or(1, |count| usize::try_from(count).unwrap_or(1)); + let end = (1..times).fold(first_end, |pos, _| motion::word_end(text, pos)); + return Some(Span::chars(at..next(text, end))); + } + let to = self.destination(motion, count, true)?; + let text = self.text(); + let (first, last) = if to < at { (to, at) } else { (at, to) }; + let span = match reach(motion, self.last_find) { + Reach::Linewise => lines(text, line_start(text, first), line_end(text, last)), + Reach::Inclusive => Span::chars(first..next(text, last)), + Reach::Exclusive => { + let mut end = last; + // `dw` on a line's last word stops at the end of that line. + if motion == Motion::Next && line_start(text, end) > line_end(text, first) { + end = line_start(text, end) - 1; + } + Span::chars(first..end) + } + }; + (!span.range.is_empty()).then_some(span) + } + + /// `y` and a target: the text, for a register. The cursor goes to its + /// start. + fn yank(&mut self, target: Target, count: Option) -> Reply { + let Some(span) = self.span(Operator::Yank, target, count) else { return Reply::Done }; + let yank = yank_of(self.text(), &span); + if span.lines.is_none() { + self.input.set_cursor(span.range.start); + self.clamp(); + } + Reply::Yanked(yank) + } + + /// `d` or `c` and a target: the text goes, for a register; `c` starts + /// typing where it was. + fn cut(&mut self, operator: Operator, target: Target, count: Option) -> Reply { + let Some(span) = self.span(operator, target, count) else { return Reply::Done }; + let yank = yank_of(self.text(), &span); + let change = operator == Operator::Change; + match &span.lines { + // `cc`: the lines become one empty line, to type on. + Some(lines) if change => self.input.replace(lines.start, lines.end, ""), + Some(_) => { + self.input.replace(span.range.start, span.range.end, ""); + self.input.set_cursor(first_non_blank(self.text(), self.cursor())); + } + None => self.input.replace(span.range.start, span.range.end, ""), + } + if change { + self.typing = true; + } else { + self.clamp(); + } + Reply::Yanked(yank) + } + + // ----- Other changes -------------------------------------------------- + + /// `p` and `P`: whole lines go on lines of their own, below or above + /// (in a title, where there's one line, they go in like any text); + /// other text goes after or before the cursor. + fn put(&mut self, yank: &Yank, after: bool, count: usize) { + let at = self.cursor(); + if yank.linewise && self.multiline { + let block = vec![yank.text.as_str(); count].join("\n"); + let start = if after { + let end = line_end(self.text(), at); + self.input.replace(end, end, &format!("\n{block}")); + end + 1 + } else { + let start = line_start(self.text(), at); + self.input.replace(start, start, &format!("{block}\n")); + start + }; + self.input.set_cursor(first_non_blank(self.text(), start)); + return; + } + let piece = + if self.multiline { yank.text.clone() } else { yank.text.replace('\n', " ") }.repeat(count); + if piece.is_empty() { + return; + } + let at = if after && at < line_end(self.text(), at) { next(self.text(), at) } else { at }; + self.input.replace(at, at, &piece); + // The cursor ends on the last character pasted. + let end = self.cursor(); + self.input.set_cursor(previous(self.text(), end)); + } + + /// `o` and `O` in a body: a new line below or above, to type on. + fn open_line(&mut self, below: bool) { + let at = self.cursor(); + if below { + let end = line_end(self.text(), at); + self.input.replace(end, end, "\n"); + } else { + let start = line_start(self.text(), at); + self.input.replace(start, start, "\n"); + self.input.set_cursor(start); + } + self.typing = true; + } + + /// `r`: `count` characters from the cursor become `c` (nothing happens + /// when the line doesn't have that many). + fn replace_chars(&mut self, c: char, count: usize) { + let (text, at) = (self.text(), self.cursor()); + let chars: Vec<(usize, &str)> = + text[at..line_end(text, at)].grapheme_indices(true).take(count).collect(); + let Some(&(offset, last)) = chars.last().filter(|_| chars.len() == count) else { return }; + let end = at + offset + last.len(); + let with = c.to_string().repeat(count); + self.input.replace(at, end, &with); + self.input.set_cursor(at + with.len() - c.len_utf8()); + } + + /// `~`: switches upper and lower case for `count` characters, moving on. + fn switch_case(&mut self, count: usize) { + let (text, at) = (self.text(), self.cursor()); + let end = text[at..line_end(text, at)] + .grapheme_indices(true) + .take(count) + .last() + .map_or(at, |(offset, grapheme)| at + offset + grapheme.len()); + let switched: String = text[at..end] + .chars() + .flat_map(|c| -> Box> { + if c.is_lowercase() { + Box::new(c.to_uppercase()) + } else if c.is_uppercase() { + Box::new(c.to_lowercase()) + } else { + Box::new(std::iter::once(c)) + } + }) + .collect(); + self.input.replace(at, end, &switched); + self.clamp(); + } + + /// `J`: the next lines join this one, `joins` times, with a space where + /// they meet and their indent dropped. + fn join_lines(&mut self, joins: usize) { + for _ in 0..joins { + let text = self.text(); + let end = line_end(text, self.cursor()); + if end == text.len() { + break; + } + let next_start = end + 1; + let next_text = first_non_blank(text, next_start); + let needs_space = end > line_start(text, end) + && !text[..end].ends_with(' ') + && next_text < line_end(text, next_start); + self.input.replace(end, next_text, if needs_space { " " } else { "" }); + self.input.set_cursor(end); + } + self.clamp(); + } +} + +/// The text a span covers, for a register: whole lines without the line +/// break that goes with them. +fn yank_of(text: &str, span: &Span) -> Yank { + let covered = span.lines.clone().unwrap_or(span.range.clone()); + Yank { text: text[covered].to_string(), linewise: span.lines.is_some() } +} + +/// Whole lines, from the line starting at `first` to the one ending at +/// `last_end`, with one line break: the one after them, or the one before +/// when they're the last lines. +fn lines(text: &str, first: usize, last_end: usize) -> Span { + let range = if last_end < text.len() { + first..last_end + 1 + } else if first > 0 { + first - 1..last_end + } else { + first..last_end + }; + Span { range, lines: Some(first..last_end) } +} + +/// How much a motion covers as an operator's target. +fn reach(motion: Motion, last_find: Option) -> Reach { + match motion { + Motion::Up | Motion::Down | Motion::First | Motion::Last => Reach::Linewise, + Motion::WordEnd | Motion::LineEnd | Motion::FindForward(_) | Motion::TillForward(_) => { + Reach::Inclusive + } + Motion::RepeatFind => last_find.map_or(Reach::Exclusive, |last| reach(last, None)), + Motion::RepeatFindBackward => last_find.map_or(Reach::Exclusive, |last| reach(reversed(last), None)), + _ => Reach::Exclusive, + } +} + +/// A find the other way round, for `,`. +fn reversed(motion: Motion) -> Motion { + match motion { + Motion::FindForward(c) => Motion::FindBackward(c), + Motion::FindBackward(c) => Motion::FindForward(c), + Motion::TillForward(c) => Motion::TillBackward(c), + Motion::TillBackward(c) => Motion::TillForward(c), + other => other, + } +} + +/// Where a find motion goes. +fn find(text: &str, at: usize, motion: Motion, count: usize, again: bool) -> Option { + let (c, forward, till) = match motion { + Motion::FindForward(c) => (c, true, false), + Motion::FindBackward(c) => (c, false, false), + Motion::TillForward(c) => (c, true, true), + Motion::TillBackward(c) => (c, false, true), + _ => return None, + }; + motion::find(text, at, c, forward, till, count, again) +} + +#[cfg(test)] +mod tests; diff --git a/src/ui/text_editor/motion.rs b/src/ui/text_editor/motion.rs new file mode 100644 index 0000000..2c6b435 --- /dev/null +++ b/src/ui/text_editor/motion.rs @@ -0,0 +1,268 @@ +//! Where the cursor's motions go inside text, as in vim. Positions are byte +//! offsets that sit between characters (graphemes: an emoji or an accented +//! letter is one character), and lines end at `\n`. + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// The start of the line `at` is on. +pub fn line_start(text: &str, at: usize) -> usize { + text[..at].rfind('\n').map_or(0, |found| found + 1) +} + +/// The end of the line `at` is on, before its line break. +pub fn line_end(text: &str, at: usize) -> usize { + text[at..].find('\n').map_or(text.len(), |found| at + found) +} + +/// The start of the character after the one at `at` (at most the end). +pub fn next(text: &str, at: usize) -> usize { + text[at..].graphemes(true).next().map_or(at, |grapheme| at + grapheme.len()) +} + +/// The start of the character before `at` (at least the start). +pub fn previous(text: &str, at: usize) -> usize { + text[..at].grapheme_indices(true).next_back().map_or(0, |(start, _)| start) +} + +/// The furthest the cursor goes on `at`'s line in normal mode: the line's +/// last character, or its start when it's empty. +pub fn last_char(text: &str, at: usize) -> usize { + let (start, end) = (line_start(text, at), line_end(text, at)); + if end > start { previous(text, end) } else { start } +} + +/// The first character on `at`'s line that isn't a space or a tab. +pub fn first_non_blank(text: &str, at: usize) -> usize { + let (start, end) = (line_start(text, at), line_end(text, at)); + text[start..end].find(|c: char| c != ' ' && c != '\t').map_or(end, |found| start + found) +} + +/// How many lines there are. +pub fn line_count(text: &str) -> usize { + text.matches('\n').count() + 1 +} + +/// The start of line `number`, counting from 1; the last line when there +/// are fewer. +pub fn nth_line(text: &str, number: usize) -> usize { + let mut start = 0; + for _ in 1..number { + match text[start..].find('\n') { + Some(found) => start += found + 1, + None => break, + } + } + start +} + +/// The screen column of `at` on its line. +pub fn column(text: &str, at: usize) -> usize { + text[line_start(text, at)..at].width() +} + +/// `lines` lines down (up when negative), at `column` or the end of a +/// shorter line. `None` when there's no such line. +pub fn vertical(text: &str, at: usize, lines: isize, column: usize) -> Option { + let mut start = line_start(text, at); + for _ in 0..lines.unsigned_abs() { + start = if lines > 0 { + let end = line_end(text, start); + if end == text.len() { + return None; + } + end + 1 + } else { + if start == 0 { + return None; + } + line_start(text, start - 1) + }; + } + Some(at_column(text, start, column)) +} + +/// The character at a screen column on the line starting at `start`, or +/// the line's end when it's shorter. +pub fn at_column(text: &str, start: usize, column: usize) -> usize { + let end = line_end(text, start); + let mut width = 0; + for (offset, grapheme) in text[start..end].grapheme_indices(true) { + width += grapheme.width(); + if width > column { + return start + offset; + } + } + end +} + +/// What a character counts as for word motions. As in vim, a word is a run +/// of letters, digits and `_`, or a run of other signs; `can't` is three +/// words. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Class { + Blank, + LineBreak, + Word, + Sign, +} + +/// The class of the character at `at`, or `None` at the end of the text. +pub fn class_at(text: &str, at: usize) -> Option { + let c = text[at..].chars().next()?; + Some(if c == '\n' { + Class::LineBreak + } else if c.is_whitespace() { + Class::Blank + } else if c.is_alphanumeric() || c == '_' { + Class::Word + } else { + Class::Sign + }) +} + +/// `w`: the start of the next word. An empty line counts as a word. +pub fn word_forward(text: &str, at: usize) -> usize { + let mut pos = at; + if let Some(here @ (Class::Word | Class::Sign)) = class_at(text, at) { + while class_at(text, pos) == Some(here) { + pos = next(text, pos); + } + } + loop { + match class_at(text, pos) { + Some(Class::Blank) => pos = next(text, pos), + Some(Class::LineBreak) => { + pos = next(text, pos); + if class_at(text, pos) == Some(Class::LineBreak) { + return pos; + } + } + _ => return pos, + } + } +} + +/// `b`: the start of this word, or of the one before when already there. +pub fn word_backward(text: &str, at: usize) -> usize { + let mut pos = at; + while pos > 0 { + let before = previous(text, pos); + match class_at(text, before) { + Some(Class::Blank) => pos = before, + // An empty line counts as a word. + Some(Class::LineBreak) if line_start(text, before) == before => return before, + Some(Class::LineBreak) => pos = before, + here => { + pos = before; + while pos > 0 && class_at(text, previous(text, pos)) == here { + pos = previous(text, pos); + } + return pos; + } + } + } + 0 +} + +/// `e`: the last character of this word, or of the next one when already +/// there. +pub fn word_end(text: &str, at: usize) -> usize { + let mut pos = next(text, at); + while let Some(Class::Blank | Class::LineBreak) = class_at(text, pos) { + pos = next(text, pos); + } + let Some(here) = class_at(text, pos) else { return at }; + run_end(text, pos, here) +} + +/// The last character of the run of `class` that `at` is in. +pub fn run_end(text: &str, at: usize, class: Class) -> usize { + let mut pos = at; + while class_at(text, next(text, pos)) == Some(class) { + pos = next(text, pos); + } + pos +} + +/// `f`, `t`, `F` and `T`: the `count`th `c` after (or before) `at` on its +/// line, or just before (after) it for a till. `again` is for `;` and `,`: +/// a till then skips the `c` right next to the cursor, so it moves on. +pub fn find( + text: &str, + at: usize, + c: char, + forward: bool, + till: bool, + count: usize, + again: bool, +) -> Option { + let is_c = |grapheme: &str| grapheme.chars().eq(std::iter::once(c)); + let skip = till && again; + if forward { + let end = line_end(text, at); + let from = if skip { next(text, next(text, at)) } else { next(text, at) }.min(end); + let (offset, _) = text[from..end].grapheme_indices(true).filter(|(_, g)| is_c(g)).nth(count - 1)?; + let found = from + offset; + Some(if till { previous(text, found) } else { found }) + } else { + let start = line_start(text, at); + let to = if skip { previous(text, at).max(start) } else { at }; + let (offset, _) = + text[start..to].grapheme_indices(true).rev().filter(|(_, g)| is_c(g)).nth(count - 1)?; + let found = start + offset; + Some(if till { next(text, found) } else { found }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Where a motion goes, applied to "text|with a cursor" ('|' marks the + /// cursor), shown the same way. + fn moved(marked: &str, motion: impl Fn(&str, usize) -> usize) -> String { + let at = marked.find('|').unwrap(); + let text = marked.replacen('|', "", 1); + let to = motion(&text, at); + format!("{}|{}", &text[..to], &text[to..]) + } + + #[test] + fn words() { + assert_eq!(moved("|can't stop", word_forward), "can|'t stop"); + assert_eq!(moved("can|'t stop", word_forward), "can'|t stop"); + assert_eq!(moved("can't |stop", word_backward), "can'|t stop"); + assert_eq!(moved("one |two", word_backward), "|one two"); + assert_eq!(moved("|one two", word_end), "on|e two"); + assert_eq!(moved("on|e two", word_end), "one tw|o"); + assert_eq!(moved("last|", word_forward), "last|"); + assert_eq!(moved("one\n\n|two", word_backward), "one\n|\ntwo", "an empty line is a word"); + assert_eq!(moved("o|ne\n\ntwo", word_forward), "one\n|\ntwo"); + } + + #[test] + fn lines() { + let body = "first\n second\nthird"; + assert_eq!(first_non_blank(body, 8), 8); + assert_eq!(nth_line(body, 3), 15); + assert_eq!(nth_line(body, 9), 15, "past the end: the last line"); + assert_eq!(line_count(body), 3); + assert_eq!(vertical(body, 3, 1, 3), Some(9), "same column"); + assert_eq!(vertical(body, 3, 2, 9), Some(20), "a shorter line: its end"); + assert_eq!(vertical(body, 3, -1, 3), None, "no line above the first"); + assert_eq!(last_char("ab\n\ncd", 3), 3, "an empty line: its start"); + } + + #[test] + fn finding_characters() { + let line = "a,b,c,d"; + assert_eq!(find(line, 0, ',', true, false, 2, false), Some(3)); + assert_eq!(find(line, 0, ',', true, true, 1, false), Some(0), "t stops just before"); + assert_eq!(find(line, 0, ',', true, true, 1, true), Some(2), "; after t moves on"); + assert_eq!(find(line, 6, ',', false, false, 1, false), Some(5)); + assert_eq!(find(line, 6, ',', false, true, 1, false), Some(6)); + assert_eq!(find(line, 0, 'z', true, false, 1, false), None); + assert_eq!(find("é,é", 0, 'é', true, false, 1, false), Some(3), "é is one character"); + } +} diff --git a/src/ui/text_editor/object.rs b/src/ui/text_editor/object.rs new file mode 100644 index 0000000..59f9031 --- /dev/null +++ b/src/ui/text_editor/object.rs @@ -0,0 +1,152 @@ +//! Text objects: the word, the quoted text or the bracketed text the cursor +//! is in, for `diw`, `ca"` or `yi(`. + +use std::ops::Range; + +use super::motion::{Class, class_at, line_end, line_start, next, previous}; +use crate::input::command::TextObject; + +/// What a text object covers at `at` (with its edges when `around`), or +/// `None` when the cursor isn't in one. +pub fn range(text: &str, at: usize, object: TextObject, around: bool) -> Option> { + match object { + TextObject::Word => word(text, at, around), + TextObject::Quotes => quotes(text, at, around), + TextObject::Parens => parens(text, at, around), + } +} + +/// `iw`: the word (or the run of spaces) the cursor is on. `aw`: the word +/// and the spaces after it, or else the ones before it. +fn word(text: &str, at: usize, around: bool) -> Option> { + let (start, end) = (line_start(text, at), line_end(text, at)); + let class = class_at(text, at).filter(|_| at < end)?; + let run = |from: usize, class: Class| { + let mut to = from; + while to < end && class_at(text, to) == Some(class) { + to = next(text, to); + } + to + }; + let mut first = at; + while first > start && class_at(text, previous(text, first)) == Some(class) { + first = previous(text, first); + } + let last = run(at, class); + if !around { + return Some(first..last); + } + if class == Class::Blank { + // On spaces, `aw` takes them and the word after. + return Some(first..class_at(text, last).map_or(last, |after| run(last, after))); + } + let spaces_after = run(last, Class::Blank); + if spaces_after > last { + return Some(first..spaces_after); + } + let mut spaces_before = first; + while spaces_before > start && class_at(text, previous(text, spaces_before)) == Some(Class::Blank) { + spaces_before = previous(text, spaces_before); + } + Some(spaces_before..last) +} + +/// `i"`: the text between the quotes the cursor is in or on, on its line; +/// the first quoted text after the cursor when it's in none. `a"`: the +/// quotes too, and the spaces after them (or else before), as in vim. +fn quotes(text: &str, at: usize, around: bool) -> Option> { + let (start, end) = (line_start(text, at), line_end(text, at)); + let quotes: Vec = text[start..end].match_indices('"').map(|(found, _)| start + found).collect(); + let (open, close) = match quotes.iter().position(|"e| quote == at) { + Some(index) if index % 2 == 0 => (index, index + 1), + Some(index) => (index - 1, index), + None => { + let before = quotes.iter().filter(|&"e| quote < at).count(); + if before % 2 == 1 { (before - 1, before) } else { (before, before + 1) } + } + }; + let (open, close) = (*quotes.get(open)?, *quotes.get(close)?); + if !around { + return Some(open + 1..close); + } + // Quotes and spaces are one byte each. + let after = close + 1 + text[close + 1..end].len() - text[close + 1..end].trim_start_matches(' ').len(); + let before = if after > close + 1 { open } else { start + text[start..open].trim_end_matches(' ').len() }; + Some(before..after) +} + +/// `i(`: what's between the brackets around the cursor (they can be on +/// other lines). `a(`: the brackets too. +fn parens(text: &str, at: usize, around: bool) -> Option> { + let open = if text[at..].starts_with('(') { + at + } else { + let mut depth = 0; + let mut found = None; + for (index, c) in text[..at].char_indices().rev() { + match c { + ')' => depth += 1, + '(' if depth == 0 => { + found = Some(index); + break; + } + '(' => depth -= 1, + _ => {} + } + } + found? + }; + let mut depth = 0; + let mut close = None; + for (index, c) in text[open + 1..].char_indices() { + match c { + '(' => depth += 1, + ')' if depth == 0 => { + close = Some(open + 1 + index); + break; + } + ')' => depth -= 1, + _ => {} + } + } + let close = close?; + Some(if around { open..close + 1 } else { open + 1..close }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The text an object covers, the cursor at '|' in `marked`. + fn covered(marked: &str, object: TextObject, around: bool) -> Option { + let at = marked.find('|').unwrap(); + let text = marked.replacen('|', "", 1); + range(&text, at, object, around).map(|range| text[range].to_string()) + } + + #[test] + fn words() { + assert_eq!(covered("raised b|eds here", TextObject::Word, false).unwrap(), "beds"); + assert_eq!(covered("raised b|eds here", TextObject::Word, true).unwrap(), "beds "); + assert_eq!(covered("raised b|eds", TextObject::Word, true).unwrap(), " beds", "no space after"); + assert_eq!(covered("raised | beds", TextObject::Word, true).unwrap(), " beds", "on spaces"); + assert_eq!(covered("|", TextObject::Word, false), None, "nothing on an empty line"); + } + + #[test] + fn quotes_and_brackets() { + let quoted = "say \"hi there\" now"; + assert_eq!(covered("say \"hi th|ere\" now", TextObject::Quotes, false).unwrap(), "hi there"); + assert_eq!(covered("say \"hi th|ere\" now", TextObject::Quotes, true).unwrap(), "\"hi there\" "); + assert_eq!( + covered("s|ay \"hi there\" now", TextObject::Quotes, false).unwrap(), + "hi there", + "the next pair" + ); + assert_eq!(covered(&format!("{quoted}|"), TextObject::Quotes, false), None); + assert_eq!(covered("f(a, (b|), c)", TextObject::Parens, false).unwrap(), "b"); + assert_eq!(covered("f(a, (b), |c)", TextObject::Parens, true).unwrap(), "(a, (b), c)"); + assert_eq!(covered("f(a,\n|b)", TextObject::Parens, false).unwrap(), "a,\nb", "across lines"); + assert_eq!(covered("no |brackets", TextObject::Parens, false), None); + } +} diff --git a/src/ui/text_editor/tests.rs b/src/ui/text_editor/tests.rs new file mode 100644 index 0000000..00e4c4b --- /dev/null +++ b/src/ui/text_editor/tests.rs @@ -0,0 +1,178 @@ +//! The editor driven by keys, the way the app drives it: vim keys through +//! the key engine and the `text` table, typing through the `insert` table. + +use super::*; +use crate::input::bindings::Context; +use crate::input::command::Bound; +use crate::input::engine::{KeyEngine, Outcome}; +use crate::input::key::parse_keys; +use crate::input::keymap::{DEFAULT_LEADER, Keymap}; + +/// An editor, the keys, and one register for yanks and pastes. +struct Keys { + editor: TextEditor, + keymap: Keymap, + engine: KeyEngine, + register: Option, + /// What the commands said, when it wasn't just "done". + replies: Vec, +} + +impl Keys { + /// `marked` is the text with a `|` where the cursor is; the editor starts + /// with vim keys. + fn new(marked: &str, multiline: bool) -> Keys { + let at = marked.find('|').unwrap(); + let mut editor = TextEditor::new(&marked.replacen('|', "", 1), multiline); + editor.set_cursor(at); + Keys { + editor, + keymap: Keymap::new(DEFAULT_LEADER), + engine: KeyEngine::default(), + register: None, + replies: Vec::new(), + } + } + + /// Presses keys written in vim notation. While typing, Enter is a new + /// line, as in a body. + fn press(&mut self, keys: &str) -> &mut Keys { + for key in parse_keys(keys, DEFAULT_LEADER).unwrap() { + if self.editor.is_typing() { + match self.keymap.tables(&[Context::Insert]).bound(&[key]) { + Some(Bound::Command(Command::Escape)) => self.editor.stop_typing(), + Some(Bound::Command(Command::Confirm)) => _ = self.editor.type_in(Typing::Char('\n')), + Some(Bound::Command(command)) => _ = self.editor.type_in(Typing::Command(command)), + _ => _ = key.as_char().map(|c| self.editor.type_in(Typing::Char(c))), + } + } else if let Outcome::Run(typed) = self.engine.press(key, &self.keymap.tables(&[Context::Text])) + { + match self.editor.command(&typed, self.register.as_ref()) { + Reply::Yanked(yank) => self.register = Some(yank), + Reply::Done => {} + other => self.replies.push(other), + } + } + } + self + } + + /// The text, with a `|` where the cursor is. + fn shown(&self) -> String { + let (text, at) = (self.editor.text(), self.editor.cursor()); + format!("{}|{}", &text[..at], &text[at..]) + } +} + +fn yank(text: &str, linewise: bool) -> Option { + Some(Yank { text: text.into(), linewise }) +} + +#[test] +fn words_change_and_repeat() { + let mut keys = Keys::new("|raised beds for cedar", false); + keys.press("dw"); + assert_eq!(keys.shown(), "|beds for cedar"); + assert_eq!(keys.register, yank("raised ", false)); + keys.press("wcwfrom"); + assert_eq!(keys.shown(), "beds fro|m cedar", "cw stops at the end of the word, like ce"); + keys.press("w."); + assert_eq!(keys.shown(), "beds from fro|m", ". changes the next word the same way"); + keys.press("u"); + assert_eq!(keys.shown(), "beds from |cedar"); + keys.press("u"); + assert_eq!(keys.shown(), "beds |for cedar"); + keys.press(""); + assert_eq!(keys.shown(), "beds from |cedar"); + keys.press("uuu"); + assert_eq!(keys.replies, [Reply::Refused("nothing to undo in this text")], "two changes left to undo"); + assert_eq!(keys.shown(), "|raised beds for cedar"); +} + +#[test] +fn finding_characters_and_text_objects() { + let mut keys = Keys::new("|say \"hi there\" (to the garden)", false); + keys.press("ci\"hello"); + assert_eq!(keys.shown(), "say \"hell|o\" (to the garden)"); + keys.press("f(da("); + assert_eq!(keys.shown(), "say \"hello\"| "); + let mut keys = Keys::new("|a,b,c,d", false); + keys.press("f,;"); + assert_eq!(keys.shown(), "a,b|,c,d"); + keys.press(","); + assert_eq!(keys.shown(), "a|,b,c,d"); + keys.press("dt,"); + assert_eq!(keys.shown(), "a|,c,d", "t takes up to the comma, inclusive"); + keys.press("$dT,"); + assert_eq!(keys.shown(), "a,c,|d"); +} + +#[test] +fn typing_keys() { + let mut keys = Keys::new("|", false); + keys.press("iRaised bedstallX!"); + assert_eq!(keys.shown(), "Raised tXll|!"); + keys.press("A okyes"); + assert_eq!(keys.shown(), "Raised tXll! ok ye|s", "a title stays on one line"); + keys.press("0Ito "); + assert_eq!(keys.shown(), "to| Raised tXll! ok yes"); +} + +#[test] +fn small_changes() { + let mut keys = Keys::new("|garden plans", false); + keys.press("~~"); + assert_eq!(keys.shown(), "GA|rden plans"); + keys.press("rx"); + assert_eq!(keys.shown(), "GA|xden plans"); + keys.press("2X"); + assert_eq!(keys.shown(), "|xden plans"); + keys.press("3x"); + assert_eq!(keys.shown(), "|n plans"); + keys.press("wD"); + assert_eq!(keys.shown(), "n| "); + keys.press("0C"); + assert!(keys.editor.is_typing()); + keys.press("seedsY"); + assert_eq!(keys.register, yank("seeds", true)); + keys.press("sS"); + assert_eq!(keys.shown(), "seed|S"); + keys.press("Snew"); + assert_eq!(keys.shown(), "ne|w"); +} + +#[test] +fn lines_in_a_body() { + let mut keys = Keys::new("|one\ntwo\nthree", true); + keys.press("2dd"); + assert_eq!(keys.shown(), "|three"); + assert_eq!(keys.register, yank("one\ntwo", true)); + keys.press("p"); + assert_eq!(keys.shown(), "three\n|one\ntwo"); + keys.press("kJ"); + assert_eq!(keys.shown(), "three| one\ntwo"); + keys.press("oend"); + assert_eq!(keys.shown(), "three one\nen|d\ntwo"); + keys.press("Gdk"); + assert_eq!(keys.shown(), "|three one"); + keys.press("Omoreand more"); + assert_eq!(keys.shown(), "more\nand mor|e\nthree one"); + keys.press("ggjcc"); + assert_eq!(keys.shown(), "more\n|\nthree one"); + keys.press("2G$"); + assert_eq!(keys.shown(), "more\n|\nthree one", "an empty line keeps the cursor at its start"); + keys.press("j$y0"); + assert_eq!(keys.register, yank("three on", false)); +} + +#[test] +fn in_a_title_o_is_for_the_caller() { + let mut keys = Keys::new("|Soil", false); + keys.press("o"); + keys.press(""); + assert_eq!(keys.replies, [Reply::NotMine, Reply::NotMine]); + keys.press("p"); + assert_eq!(keys.replies[2], Reply::Refused("nothing to paste yet")); + keys.press("yyP"); + assert_eq!(keys.shown(), "Soi|lSoil", "a whole line goes in like any text"); +} diff --git a/src/ui/text_input.rs b/src/ui/text_input.rs new file mode 100644 index 0000000..55af6d4 --- /dev/null +++ b/src/ui/text_input.rs @@ -0,0 +1,231 @@ +//! Text being typed, with its cursor: the command line, titles and bodies. +//! The cursor moves a whole character at a time, so an accented letter or +//! an emoji made of several code points is one step. The text can have +//! several lines (a body); Home, End and `Ctrl-u` work on the cursor's line. + +use std::ops::Range; + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::input::key::{Key, KeyName}; + +/// Text and a cursor in it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TextInput { + text: String, + /// A byte offset in `text`, always between two characters. + cursor: usize, +} + +impl TextInput { + /// `text`, with the cursor at its end. + pub fn new(text: &str) -> TextInput { + TextInput { text: text.to_string(), cursor: text.len() } + } + + pub fn text(&self) -> &str { + &self.text + } + + /// Where the cursor is: a byte offset in the text. + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Whether the cursor is after the last character. + pub fn at_end(&self) -> bool { + self.cursor == self.text.len() + } + + /// The screen column of the cursor: how wide its line is before it. + pub fn cursor_column(&self) -> usize { + self.text[self.line().start..self.cursor].width() + } + + /// The cursor's line, without its line break. + pub fn line(&self) -> Range { + let start = self.text[..self.cursor].rfind('\n').map_or(0, |at| at + 1); + let end = self.text[self.cursor..].find('\n').map_or(self.text.len(), |at| self.cursor + at); + start..end + } + + /// Replaces the text, and puts the cursor at its end. + pub fn set(&mut self, text: &str) { + *self = TextInput::new(text); + } + + /// Moves the cursor to `at`, or to the start of the character `at` is + /// inside. + pub fn set_cursor(&mut self, at: usize) { + self.cursor = if at >= self.text.len() { + self.text.len() + } else { + let starts = self.text.grapheme_indices(true).map(|(start, _)| start); + starts.take_while(|&start| start <= at).last().unwrap_or(0) + }; + } + + /// Replaces the bytes `from..to` with `text`, leaving the cursor after it. + pub fn replace(&mut self, from: usize, to: usize, text: &str) { + self.text.replace_range(from..to, text); + self.cursor = from + text.len(); + } + + /// Types text at the cursor. + pub fn insert(&mut self, text: &str) { + self.text.insert_str(self.cursor, text); + self.cursor += text.len(); + } + + /// Backspace: the character before the cursor (at the start of a line, + /// the line break, so the line joins the one above). + pub fn delete_before(&mut self) { + let start = self.previous_boundary(); + self.replace(start, self.cursor, ""); + } + + /// Delete: the character after the cursor. + pub fn delete_after(&mut self) { + let (cursor, end) = (self.cursor, self.next_boundary()); + self.replace(cursor, end, ""); + self.cursor = cursor; + } + + /// `Ctrl-w`, as in vim: back past any spaces, then a run of letters and + /// digits, or else a run of other signs (so `notes/` goes in two steps). + /// At the start of a line, it takes the line break. + pub fn delete_word_before(&mut self) { + let line_start = self.line().start; + if self.cursor == line_start { + return self.delete_before(); + } + let before = &self.text[line_start..self.cursor]; + let end = before.trim_end().len(); + let is_word = |c: char| c.is_alphanumeric() || c == '_'; + let last_is_word = before[..end].chars().next_back().is_some_and(is_word); + let start = before[..end] + .char_indices() + .rev() + .take_while(|&(_, c)| !c.is_whitespace() && is_word(c) == last_is_word) + .last() + .map_or(end, |(start, _)| start); + self.replace(line_start + start, self.cursor, ""); + } + + /// `Ctrl-u`: everything before the cursor on its line. + pub fn delete_line_before(&mut self) { + let start = self.line().start; + self.replace(start, self.cursor, ""); + } + + /// ←: one character back, staying on the line. + pub fn left(&mut self) { + if self.cursor > self.line().start { + self.cursor = self.previous_boundary(); + } + } + + /// →: one character on, staying on the line. + pub fn right(&mut self) { + if self.cursor < self.line().end { + self.cursor = self.next_boundary(); + } + } + + /// Home: the start of the line. + pub fn line_start(&mut self) { + self.cursor = self.line().start; + } + + /// End: the end of the line. + pub fn line_end(&mut self) { + self.cursor = self.line().end; + } + + /// Edits the text with one of the keys every text field shares. Returns + /// false for keys it doesn't use, for the caller to handle. + /// + /// | Key | What it does | + /// |----------------------|--------------------------------------| + /// | a character | types it | + /// | Backspace, Del | deletes before / after the cursor | + /// | ← →, Home, End | move the cursor | + /// | `Ctrl-b`, `Ctrl-e` | to the start / the end (as in vim's command line) | + /// | `Ctrl-w` | deletes the word before the cursor | + /// | `Ctrl-u` | deletes everything before the cursor | + pub fn edit(&mut self, key: Key) -> bool { + match (key.name, key.ctrl) { + (KeyName::Char(c), false) if !key.alt => self.insert(c.encode_utf8(&mut [0; 4])), + (KeyName::Backspace, _) => self.delete_before(), + (KeyName::Delete, _) => self.delete_after(), + (KeyName::Left, _) => self.left(), + (KeyName::Right, _) => self.right(), + (KeyName::Home, _) | (KeyName::Char('b'), true) => self.line_start(), + (KeyName::End, _) | (KeyName::Char('e'), true) => self.line_end(), + (KeyName::Char('w'), true) => self.delete_word_before(), + (KeyName::Char('u'), true) => self.delete_line_before(), + _ => return false, + } + true + } + + /// Where the character before the cursor starts. + pub fn previous_boundary(&self) -> usize { + self.text[..self.cursor].grapheme_indices(true).next_back().map_or(0, |(start, _)| start) + } + + /// Where the character after the cursor ends. + pub fn next_boundary(&self) -> usize { + self.text[self.cursor..].graphemes(true).next().map_or(self.cursor, |next| self.cursor + next.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::key::parse_keys; + + fn typed(start: &str, keys: &str) -> TextInput { + let mut input = TextInput::new(start); + for key in parse_keys(keys, Key::char(' ')).unwrap() { + input.edit(key); + } + input + } + + #[test] + fn typing_and_moving() { + let input = typed("", "e gardenx!"); + assert_eq!(input.text(), "exgarden!"); + assert_eq!(input.cursor(), input.text().len()); + assert_eq!(typed("café", "e").text(), "cafe", "é is one character"); + let flag = typed("ok 👍🏽", ""); + assert_eq!(flag.cursor(), 2, "an emoji with a skin tone is one step"); + assert_eq!(flag.cursor_column(), 2); + } + + #[test] + fn deleting_words_and_lines() { + assert_eq!(typed("e notes/garden", "").text(), "e notes/"); + assert_eq!(typed("e notes/", "").text(), "e notes"); + assert_eq!(typed("fold open ", "").text(), "fold "); + assert_eq!(typed("fold open", "").text(), "en"); + } + + #[test] + fn several_lines() { + let mut input = typed("Bring a bucket.\nThe bin", ""); + assert_eq!(input.cursor(), 16, "Home goes to the start of the line"); + assert_eq!(input.cursor_column(), 0); + input.edit(Key::plain(KeyName::Left)); + assert_eq!(input.cursor(), 16, "← stays on the line"); + input.line_end(); + input.delete_line_before(); + assert_eq!(input.text(), "Bring a bucket.\n"); + input.delete_word_before(); + assert_eq!(input.text(), "Bring a bucket.", "at the start of a line, Ctrl-w takes the line break"); + input.set_cursor(3); + assert_eq!(input.line(), 0..15); + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs index e6ffd70..87f94c2 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -40,6 +40,8 @@ pub struct Theme { pub accent: Color, /// Quiet things: lines, hints, counts. pub muted: Color, + /// Typing: the status bar's INSERT, and the box being typed in. + pub insert: Color, /// Node colors, by name. pub palette: BTreeMap, /// How nodes look, unless something says otherwise. @@ -84,6 +86,7 @@ impl Default for Theme { glyphs: Glyphs::Unicode, accent: Color::Cyan, muted: Color::DarkGray, + insert: Color::Green, palette, node: NodeLook { border: Some(Border::Square), ..NodeLook::default() }, root: NodeLook { border: Some(Border::Rounded), ..NodeLook::default() }, diff --git a/src/view/canvas/boxes.rs b/src/view/canvas/boxes.rs index 3d1b255..9a89ef9 100644 --- a/src/view/canvas/boxes.rs +++ b/src/view/canvas/boxes.rs @@ -7,11 +7,13 @@ //! ``` use std::collections::BTreeMap; +use std::ops::Range; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::model::{CanvasRect, CanvasSize, Content, Document, Node, NodeId}; use crate::ui::glyphs::Glyphs; +use crate::ui::text_editor::layout; /// The width titles wrap at, unless the config says otherwise. pub const DEFAULT_MAX_WIDTH: u16 = 30; @@ -51,6 +53,33 @@ impl NodeBox { } } +/// The box of a node whose title is being edited. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DraftBox { + /// The box, showing the text as typed. + pub node_box: NodeBox, + /// The rows the text wraps into (see [`layout::rows`]). + pub rows: Vec>, + /// The cursor's row and column in the text. + pub cursor: (usize, usize), +} + +/// Lays out the box of a node whose title is being edited: the text as +/// typed (trailing spaces too), wrapped the way the editor wraps it, with +/// room for the cursor after the last character. +pub fn draft_box(node: &Node, text: &str, cursor: usize, badge: String, max_width: u16) -> DraftBox { + let max_width = usize::from(max_width.max(4)); + let rows = layout::rows(text, max_width); + let cursor = layout::cell(text, &rows, cursor); + let lines = rows.iter().map(|row| text[row.clone()].trim_end_matches('\n').to_string()).collect(); + let tags = node.tags.to_string(); + let text_width = (0..rows.len()).map(|index| layout::row_width(text, &rows, index)).max().unwrap_or(0); + let inner = text_width.max(cursor.1).max(footer_width(&tags, &badge).saturating_sub(2)).min(max_width); + let size = CanvasSize::new(clamp_u16(inner + 4), clamp_u16(rows.len() + 2)); + let node_box = NodeBox { rect: CanvasRect::new(node.pos, size), lines, tags, badge }; + DraftBox { node_box, rows, cursor } +} + /// The boxes of every node in a document at Normal zoom, without badges: /// enough to tell where nodes are and how much room they take. pub fn box_rects(doc: &Document, max_width: u16, glyphs: Glyphs) -> BTreeMap { diff --git a/src/view/canvas/placement.rs b/src/view/canvas/placement.rs index 17acc6e..2e4651a 100644 --- a/src/view/canvas/placement.rs +++ b/src/view/canvas/placement.rs @@ -40,8 +40,14 @@ pub fn first_child_spot( /// Where a new node just after `sibling` goes: right below it, then further /// down until it's clear. pub fn below_spot(sibling: NodeId, size: CanvasSize, rects: &BTreeMap) -> CanvasPos { - let Some(rect) = rects.get(&sibling) else { return CanvasPos::default() }; - free_spot(CanvasPos::new(rect.left(), rect.bottom() + ROW_GAP), size, rects) + let Some(&rect) = rects.get(&sibling) else { return CanvasPos::default() }; + spot_below(rect, size, rects) +} + +/// Where a box goes below another box: right below it, then further down +/// until it's clear. The box above doesn't need to be in `rects`. +pub fn spot_below(above: CanvasRect, size: CanvasSize, rects: &BTreeMap) -> CanvasPos { + free_spot(CanvasPos::new(above.left(), above.bottom() + ROW_GAP), size, rects) } /// The first spot at `start` or below it where a box of `size` keeps clear of diff --git a/src/view/canvas/render.rs b/src/view/canvas/render.rs index 261e5a9..657c2e3 100644 --- a/src/view/canvas/render.rs +++ b/src/view/canvas/render.rs @@ -170,9 +170,8 @@ mod tests { use super::*; use crate::model::test_support::garden; use crate::model::{CanvasPos, Fold, Label, Link}; - use crate::ui::theme::Theme; - use crate::view::ViewSettings; use crate::view::canvas::{Camera, Zoom}; + use crate::view::test_support::Setup; /// Prints the example document (`examples/garden.cum`) at each zoom, to /// look at the canvas without a terminal: @@ -181,16 +180,10 @@ mod tests { #[ignore = "prints the canvas; run it by name"] fn show_canvas() { let doc = crate::storage::document_file::parse(include_str!("../../../examples/garden.cum")).unwrap(); + let setup = Setup::default(); for zoom in [Zoom::Normal, Zoom::Far, Zoom::Overview] { let camera = Camera { origin: CanvasPos::new(-1, 0), zoom }; - let scene = Scene::build( - &doc, - doc.root(), - camera, - Size::new(84, 36), - &Theme::default(), - &ViewSettings::default(), - ); + let scene = Scene::build(&setup.ctx(&doc, doc.root()), camera, Size::new(84, 36)); println!("── {} {}", zoom.name(), "─".repeat(70)); for row in snapshot(&scene) { println!("{row}"); @@ -211,15 +204,8 @@ mod tests { #[test] fn the_garden_at_normal_zoom() { - let (doc, g) = garden(); - let scene = Scene::build( - &doc, - g.beds, - Camera::default(), - Size::new(52, 17), - &Theme::default(), - &ViewSettings::default(), - ); + let ((doc, g), setup) = (garden(), Setup::default()); + let scene = Scene::build(&setup.ctx(&doc, g.beds), Camera::default(), Size::new(52, 17)); assert_eq!( snapshot(&scene), vec![ @@ -250,9 +236,9 @@ mod tests { let (mut doc, g) = garden(); let orange = crate::model::ColorName::parse("orange").unwrap(); doc.edit(|e| e.update(g.beds, |node| node.look.color = Some(orange.clone()))).unwrap(); - let theme = Theme::default(); - let scene = - Scene::build(&doc, g.soil, Camera::default(), Size::new(52, 8), &theme, &ViewSettings::default()); + let setup = Setup::default(); + let theme = &setup.theme; + let scene = Scene::build(&setup.ctx(&doc, g.soil), Camera::default(), Size::new(52, 8)); let area = Rect::new(0, 0, 52, 8); let mut buf = Buffer::empty(area); scene.render(area, &mut buf); @@ -272,14 +258,8 @@ mod tests { e.update(g.compost, |node| node.pos = CanvasPos::new(0, 16)) }) .unwrap(); - let scene = Scene::build( - &doc, - g.root, - Camera::default(), - Size::new(52, 19), - &Theme::default(), - &ViewSettings::default(), - ); + let setup = Setup::default(); + let scene = Scene::build(&setup.ctx(&doc, g.root), Camera::default(), Size::new(52, 19)); let text = snapshot(&scene); assert!(text[6].ends_with("── +1 ─┘"), "the hidden Cedar shows as +1: {text:#?}"); assert!(text.iter().any(|row| row.contains("feeds")), "the link's label: {text:#?}"); @@ -288,8 +268,7 @@ mod tests { // Far zoom: one line per node, half the height. The link climbs from // the compost note up into Watering, labeled where it runs level. let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far }; - let scene = - Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), &ViewSettings::default()); + let scene = Scene::build(&setup.ctx(&doc, g.root), far, Size::new(40, 9)); assert_eq!( snapshot(&scene), vec![ diff --git a/src/view/canvas/scene.rs b/src/view/canvas/scene.rs index 5ccf184..bf641bd 100644 --- a/src/view/canvas/scene.rs +++ b/src/view/canvas/scene.rs @@ -3,19 +3,23 @@ //! the same scene, so what you click is what you see. use std::collections::BTreeMap; +use std::ops::Range; use ratatui::layout::Size; use ratatui::style::Color; use unicode_width::UnicodeWidthStr; -use super::boxes::{NodeBox, truncate}; +use super::boxes::{NodeBox, draft_box, truncate}; use super::camera::{Camera, ScreenPos, ScreenRect, Zoom}; use super::lines::{LineLayer, LineOwner, Paint}; use super::route::{Bend, route}; +use crate::app::mode::Mode; +use crate::app::text_edit::TextEdit; use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible}; use crate::ui::glyphs::Glyphs; +use crate::ui::text_editor::layout; use crate::ui::theme::{LineStyle, Theme}; -use crate::view::ViewSettings; +use crate::view::ViewContext; /// How many columns of a title show at Far zoom. const FAR_TITLE_WIDTH: usize = 14; @@ -53,6 +57,16 @@ pub(super) struct Mark { pub(super) color: Color, } +/// The title being edited, as drawn: where the text cursor is, and how the +/// text sits in its box, for clicks. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Editing { + rect: ScreenRect, + text: String, + rows: Vec>, + cursor: ScreenPos, +} + /// Everything the canvas draws in one frame. #[derive(Debug, Clone, Default)] pub struct Scene { @@ -61,6 +75,9 @@ pub struct Scene { pub(super) glyphs: Glyphs, pub(super) accent: Color, pub(super) muted: Color, + /// The border of the cursor's box: the accent, or the typing color while + /// its title is typed. + cursor_color: Color, /// The nodes in drawing order: the cursor's last, on top. pub(super) nodes: Vec, /// Where each node is in `nodes`. @@ -70,19 +87,20 @@ pub struct Scene { covered: Vec>, pub(super) lines: LineLayer, pub(super) marks: Vec, + editing: Option, } impl Scene { /// Works out the frame: which nodes show (folding, the cursor), where - /// they are on screen, and every line between them. - pub fn build( - doc: &Document, - cursor: NodeId, - camera: Camera, - size: Size, - theme: &Theme, - settings: &ViewSettings, - ) -> Scene { + /// they are on screen, and every line between them. A title being edited + /// shows as typed, with the text cursor in it. + pub fn build(ctx: &ViewContext<'_>, camera: Camera, size: Size) -> Scene { + let (doc, cursor, theme, settings) = (ctx.doc, ctx.cursor, ctx.theme, ctx.settings); + let edit = match ctx.mode { + Mode::Text(edit) => Some(edit), + _ => None, + }; + let typing = edit.is_some_and(|edit| edit.editor.is_typing()); let visible = Visible::new(doc, cursor, settings.focus); let mut scene = Scene { size, @@ -90,6 +108,7 @@ impl Scene { glyphs: theme.glyphs, accent: theme.accent, muted: theme.muted, + cursor_color: if typing { theme.insert } else { theme.accent }, covered: vec![None; size.area() as usize], lines: LineLayer::new(size), ..Scene::default() @@ -98,8 +117,11 @@ impl Scene { let Some(node) = doc.node(id) else { continue }; let style = theme.node_style(node, id == doc.root()); let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default(); - let (rect, shape) = - shape(node, badge, style.border, camera, theme.glyphs, settings.max_node_width); + let max_width = settings.max_node_width; + let (rect, shape) = match edit.filter(|edit| edit.node == id && camera.zoom == Zoom::Normal) { + Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width), + None => shape(node, badge, style.border, camera, theme.glyphs, max_width), + }; scene.nodes.push(SceneNode { id, rect, shape, color: style.color, selected: id == cursor }); } // The cursor's node goes last, on top of any it overlaps. @@ -139,6 +161,45 @@ impl Scene { self.lines.owner(at) } + /// The cell the text cursor is on, while a title is edited. + pub fn text_cursor(&self) -> Option { + self.editing.as_ref().map(|editing| editing.cursor) + } + + /// Where a click lands in the title being edited, if it's in its box. + pub fn text_offset_at(&self, at: ScreenPos) -> Option { + let editing = self.editing.as_ref().filter(|editing| editing.rect.contains(at))?; + let row = usize::try_from(at.row - editing.rect.top() - 1).unwrap_or(0); + let column = usize::try_from(at.col - editing.rect.left() - 2).unwrap_or(0); + Some(layout::offset(&editing.text, &editing.rows, row, column)) + } + + /// The box of the node whose title is being edited, showing the text as + /// typed. Remembers where the text and its cursor are. + fn draft( + &mut self, + node: &Node, + edit: &TextEdit, + badge: String, + border: Border, + camera: Camera, + max_width: u16, + ) -> (ScreenRect, Shape) { + let text = edit.editor.text(); + let draft = draft_box(node, text, edit.editor.cursor(), badge, max_width); + let at = camera.to_screen(node.pos); + let size = draft.node_box.rect.size; + let rect = ScreenRect::new(at.col, at.row, i32::from(size.width), i32::from(size.height)); + let (row, column) = draft.cursor; + let cursor = ScreenPos::new( + rect.left() + 2 + i32::try_from(column).unwrap_or(0), + rect.top() + 1 + i32::try_from(row).unwrap_or(0), + ); + self.editing = Some(Editing { rect, text: text.to_string(), rows: draft.rows, cursor }); + let NodeBox { lines, tags, badge, .. } = draft.node_box; + (rect, Shape::Box { lines, tags, badge, border }) + } + /// The index (in `nodes`) of the topmost node on a cell. pub(super) fn covered_at(&self, at: ScreenPos) -> Option { covered_at(&self.covered, self.size, at) @@ -158,13 +219,14 @@ impl Scene { } /// Adds the boxes' borders to the line layer, so lines joining them get - /// the right characters. The cursor's box is thick, in the accent color. + /// the right characters. The cursor's box is thick, in the accent color + /// (the typing color while its title is typed). fn add_borders(&mut self) { - let Scene { nodes, covered, lines, size, accent, .. } = self; + let Scene { nodes, covered, lines, size, cursor_color, .. } = self; for (index, node) in nodes.iter().enumerate() { let Shape::Box { border, .. } = &node.shape else { continue }; let (border, color) = - if node.selected { (Border::Thick, *accent) } else { (*border, node.color) }; + if node.selected { (Border::Thick, *cursor_color) } else { (*border, node.color) }; let paint = Paint { color, owner: LineOwner::Border(node.id) }; lines.add_border(node.rect, border, paint, |at| covered_at(covered, *size, at) == Some(index)); } diff --git a/src/view/canvas/view.rs b/src/view/canvas/view.rs index fa08dc6..eefe8c5 100644 --- a/src/view/canvas/view.rs +++ b/src/view/canvas/view.rs @@ -3,18 +3,20 @@ //! it wants to the document goes out as an action. use ratatui::Frame; -use ratatui::layout::Rect; +use ratatui::layout::{Position, Rect}; -use super::camera::{Camera, ScreenPos, Zoom}; +use super::camera::{Camera, ScreenPos, ScreenRect, Zoom}; use super::navigate::nearest; use super::scene::Scene; use crate::app::action::{Action, AppAction, Message}; use crate::app::mode::{Grab, Mode}; +use crate::app::text_edit::{EditAt, StartEdit}; 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::{CanvasOffset, Direction, EditOp, Group, NodeId, Visible}; +use crate::ui::text_editor::layout; use crate::view::{View, ViewContext, ViewKind}; /// How close (in cells) the cursor's node may come to an edge before the @@ -65,7 +67,37 @@ impl CanvasView { } fn build(&self, ctx: &ViewContext<'_>) -> Scene { - Scene::build(ctx.doc, ctx.cursor, self.camera, self.area.as_size(), ctx.theme, ctx.settings) + Scene::build(ctx, self.camera, self.area.as_size()) + } + + /// A left press: on a node, selects it and starts dragging its branch; + /// on empty space, starts panning. + fn press(&mut self, at: ScreenPos) -> Vec { + match self.scene.node_at(at) { + Some(id) => { + let moved = CanvasOffset::default(); + self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() }); + vec![Action::cursor(id)] + } + None => { + self.drag = Some(Drag::Pan { last: at }); + Vec::new() + } + } + } + + /// Where a double-click on a node's title lands in it, to start editing + /// there (at the end when zoomed out, where titles are cut short). + fn title_spot(&self, id: NodeId, at: ScreenPos, ctx: &ViewContext<'_>) -> EditAt { + let (Some(rect), Some(node)) = (self.scene.rect_of(id), ctx.doc.node(id)) else { return EditAt::End }; + if self.camera.zoom != Zoom::Normal { + return EditAt::End; + } + let title = node.title().as_str(); + let rows = layout::rows(title, usize::from(ctx.settings.max_node_width.max(4))); + let row = usize::try_from(at.row - rect.top() - 1).unwrap_or(0); + let column = usize::try_from(at.col - rect.left() - 2).unwrap_or(0); + EditAt::Offset(layout::offset(title, &rows, row, column)) } /// hjkl: the nearest node on show that way, `times` times over. Folds are @@ -182,6 +214,8 @@ impl View for CanvasView { fn contexts(&self, mode: &Mode) -> &'static [Context] { match mode { Mode::Normal => &[Context::Canvas, Context::Normal, Context::Motions], + Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert], + Mode::Text(_) => &[Context::Text], Mode::Move(_) => &[Context::Move], // The command line reads its own keys. Mode::Command(_) => &[], @@ -190,25 +224,46 @@ impl View for CanvasView { fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) { self.area = area; + // A title is edited in its box: zoomed out, there's no box. + if matches!(ctx.mode, Mode::Text(_)) && self.camera.zoom != Zoom::Normal { + self.zoom(Zoom::Normal, ctx); + } let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor); // While a node is dragged, the view stays put under the mouse. let follow = std::mem::take(&mut self.reveal) || (moved && self.drag.is_none()); self.scene = self.build(ctx); - if follow - && let Some(rect) = self.scene.rect_of(ctx.cursor) - && self.camera.reveal(rect, area.as_size(), SCROLL_OFF) - { + let mut panned = follow + && self + .scene + .rect_of(ctx.cursor) + .is_some_and(|rect| self.camera.reveal(rect, area.as_size(), SCROLL_OFF)); + // While typing, the text cursor stays on screen as the box grows. + if let Some(cell) = self.scene.text_cursor() { + let rect = ScreenRect::new(cell.col, cell.row, 1, 1); + panned |= self.camera.reveal(rect, area.as_size(), 0); + } + if panned { self.scene = self.build(ctx); } } fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) { frame.render_widget(&self.scene, self.area); + if let Some(cell) = self.scene.text_cursor() + && let (Ok(col), Ok(row)) = (u16::try_from(cell.col), u16::try_from(cell.row)) + && col < self.area.width + && row < self.area.height + { + frame.set_cursor_position(Position::new(self.area.x + col, self.area.y + row)); + } } fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option> { - if let Mode::Move(grab) = ctx.mode { - return self.move_command(typed, *grab, ctx); + match ctx.mode { + Mode::Move(grab) => return self.move_command(typed, *grab, ctx), + // Keys inside a title are the edit's, not the view's. + Mode::Text(_) => return None, + Mode::Normal | Mode::Command(_) => {} } let steps = cells(typed); let half_page = i32::from(self.area.height / 2); @@ -229,21 +284,34 @@ impl View for CanvasView { Some(Vec::new()) } - fn mouse(&mut self, gesture: Gesture, _ctx: &ViewContext<'_>) -> Vec { + fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec { match gesture { Gesture::Press { at, .. } => { let at = self.screen(at); - match self.scene.node_at(at) { - Some(id) => { - let moved = CanvasOffset::default(); - self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() }); - vec![Action::cursor(id)] - } - None => { - self.drag = Some(Drag::Pan { last: at }); - Vec::new() - } + let Mode::Text(edit) = ctx.mode else { return self.press(at) }; + // 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.scene.text_offset_at(at) { + return vec![Action::App(AppAction::TextCursor(offset))]; } + let mut actions = edit.finish(ctx); + actions.extend(self.press(at)); + actions + } + Gesture::DoublePress { at } => { + let at = self.screen(at); + if let Some(offset) = self.scene.text_offset_at(at) { + return vec![Action::App(AppAction::TextCursor(offset))]; + } + let Some(id) = self.scene.node_at(at) else { return Vec::new() }; + let mut actions = match ctx.mode { + Mode::Text(edit) => edit.finish(ctx), + _ => Vec::new(), + }; + let start = StartEdit { at: self.title_spot(id, at, ctx), new_node: None }; + actions.extend([Action::cursor(id), Action::App(AppAction::StartEdit(start))]); + actions } Gesture::Drag { at, .. } => self.drag_to(self.screen(at)), Gesture::Release { .. } => { @@ -260,9 +328,6 @@ impl View for CanvasView { self.camera.pan(cols, rows); Vec::new() } - Gesture::DoublePress { .. } => { - vec![Action::say(Message::info("editing titles comes in stage 6"))] - } Gesture::RightPress { .. } => { vec![Action::say(Message::info("the right-click menu comes in stage 7"))] }