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"))] }