Edit titles in their boxes, with vim keys inside the text
Titles are typed right in their boxes on the canvas: the box grows with the text, the terminal's cursor sits in it (a bar while typing, a block on the letter with vim keys), and the border turns the insert color. `i`, `A` and Enter edit at the end, `I` at the start, `cc` and `S` type the title anew. The document follows the text as it's typed, so the layout stays current, and the whole edit is one undo step. Esc gives vim's normal mode inside the text (TextEditor, in ui/): h l w b e 0 ^ $, f t F T with ; and ,, the d c y operators with any of those motions or the iw aw i" a" i( a( text objects, x X s S D C Y, r ~, p P, counts, u and Ctrl-r for the edit's own changes, and `.` for the last one, typing included. j k gg G and J work on lines, for bodies. It follows vim where it's quirky too: cw acts like ce, dw stops at the end of the line, Esc steps back onto the last letter. Keys have two new tables, `text` and `insert`, and in text an operator takes motions as targets (`dw`, `c$`, `2d3fx`), which which-key lists. Rapid entry: after `o`, `O` or `a`, the new idea's title is typed at once; Enter starts the next idea, Tab puts the one being typed under the idea above, Shift-Tab takes it out a level, and Enter on an empty idea stops. An idea left empty goes away without a trace in the undo history (a group whose edits cancel out leaves no step); a title left empty comes back as it was. Inside a title, `o` and `O` start a new sibling. Registers are shared by the whole app, so text yanked in one title pastes into another; `"a`-`"z` and `"_` work, and `"+` stays inside cumulus until the app core connects the clipboard. The mouse while editing: a click in the text moves the text cursor, a click anywhere else keeps the text and finishes the edit, and a double-click on a title starts editing at that letter. `>>` and `<<` now move nodes: under the node above, as its last child, or out a level, right after the old parent, with counts taking in the next siblings. The command line's input became TextInput, line-aware so the editor can use it for bodies too; bodies themselves get edited in the Note view. The example's tests type every key above into the app and check the text and the cursor after each one against what vim does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6eedbe0ce2
commit
84aefbecdb
31 files changed
+3289
-341
No files matched your search
@@ -91,8 +91,13 @@ lines.
|
|||||||
deletes this node and the next two, with their branches, as vim's `3dd`
|
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).
|
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
|
- 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.
|
sibling, Tab / Shift-Tab indent and outdent the idea being typed, and Enter
|
||||||
Esc switches to vim motions inside the text; a second Esc leaves.
|
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
|
- `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.
|
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.
|
- `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.
|
bar shows key hints, and `?` opens searchable help.
|
||||||
- Keys can be changed in `keys.toml`, in vim notation.
|
- 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**
|
**Files and saving**
|
||||||
- Documents are RON files ending in `.cum`. Configs are TOML: `config.toml`,
|
- Documents are RON files ending in `.cum`. Configs are TOML: `config.toml`,
|
||||||
`keys.toml` and `themes/<name>.toml`.
|
`keys.toml` and `themes/<name>.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).
|
breadcrumb (or a message), and the keys typed so far (`2g` above).
|
||||||
|
|
||||||
**Mouse** (on the canvas; other views get click, double-click and scroll):
|
**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
|
click selects, double-click edits the title (from the letter clicked),
|
||||||
branch, dragging empty space pans, scrolling pans, Ctrl+drag selects a
|
dragging a node moves it with its branch, dragging empty space pans, scrolling
|
||||||
rectangle, Ctrl+click adds or removes one node, right-click opens a menu built
|
pans, Ctrl+drag selects a rectangle, Ctrl+click adds or removes one node,
|
||||||
from the same actions as the keys. Zoom is on the `+` and `-` keys.
|
right-click opens a menu built from the same actions as the keys. Zoom is on
|
||||||
|
the `+` and `-` keys.
|
||||||
|
|
||||||
## Modes and keys
|
## Modes and keys
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
enum Mode {
|
enum Mode {
|
||||||
Normal,
|
Normal,
|
||||||
Insert(TextEdit), // typing a title or body; rapid entry lives here
|
Text(TextEdit), // a title or body being edited: typing (INSERT) or vim
|
||||||
TextNormal(TextEdit), // vim motions inside that text
|
// 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
|
Visual(Visual), // anchor + Rectangle or Branches
|
||||||
Move(Grab), // a node or a branch being moved
|
Move(Grab), // a node or a branch being moved
|
||||||
Connect(Connect), // the source node and the link being styled
|
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 |
|
| **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal |
|
||||||
| **Leader** `<Space>` | `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 |
|
| **Leader** `<Space>` | `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 |
|
| **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 |
|
| **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 ^ $ 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 |
|
| **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 |
|
| **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 · `<Space>` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel |
|
| **Connect mode** | move to the target as usual · Enter link · `d` remove · `>` attach as child · `<Space>` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel |
|
||||||
| **Visual** | `d y > < M C` and `<Space>c` `<Space>s` `<Space>t` act on the whole selection · `o` swaps ends |
|
| **Visual** | `d y > < M C` and `<Space>c` `<Space>s` `<Space>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
|
completion_popup = "as-you-type" # "on-tab": only after Tab, like vim's wildmenu
|
||||||
|
|
||||||
# keys.toml: sections motions, normal, canvas, outline, note, visual, move,
|
# 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 = "<Space>"
|
leader = "<Space>"
|
||||||
[normal]
|
[normal]
|
||||||
"<Space>x" = "delete-branch"
|
"<Space>x" = "delete-branch"
|
||||||
@@ -399,13 +438,15 @@ cumulus/
|
|||||||
├─ command/ registry, parse, complete, history
|
├─ command/ registry, parse, complete, history
|
||||||
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render,
|
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render,
|
||||||
│ navigate, placement, view), outline, note, board
|
│ navigate, placement, view), outline, note, board
|
||||||
├─ ui/ text_editor (+ vim normal), which_key, command_line, line_input, status_bar,
|
├─ ui/ text_input (a line of text and its cursor), text_editor/ (vim inside text:
|
||||||
│ document_bar, card, menu, picker, help, theme, glyphs
|
│ 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)
|
├─ config/ paths, settings, keys_file, theme_file (TOML)
|
||||||
├─ storage/ document_file (RON), atomic save, backups, lock, state, watch (change polling)
|
├─ storage/ document_file (RON), atomic save, backups, lock, state, watch (change polling)
|
||||||
└─ app/ action, mode, buffer, commands (keys every view shares), calls (`:` commands);
|
└─ app/ action, mode, buffer, commands (keys every view shares), calls (`:` commands),
|
||||||
later App, panes (split tree), jumps, registers, portals (cache, backlinks,
|
text_edit (a title being edited, rapid entry), registers; later App, panes
|
||||||
renames), update, render, terminal (setup, teardown, panic hook)
|
(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
|
**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
|
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.
|
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`
|
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
|
```rust
|
||||||
trait View { // settled in stage 5
|
trait View { // settled in stage 5
|
||||||
@@ -488,5 +537,10 @@ one commit.
|
|||||||
- **Model:** tree rules, undo/redo round trips, folding.
|
- **Model:** tree rules, undo/redo round trips, folding.
|
||||||
- **Canvas:** camera conversions, routing and junctions (small text snapshots),
|
- **Canvas:** camera conversions, routing and junctions (small text snapshots),
|
||||||
hit-testing, nearest-node moves.
|
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
|
- **Files:** RON round trip, load errors, config parsing, and backlink and
|
||||||
rename fix-ups, in temporary folders.
|
rename fix-ups, in temporary folders.
|
||||||
@@ -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 |
|
| `hjkl` | move to the nearest node that way |
|
||||||
| `gh gj gk gl` | parent, next sibling, previous sibling, first child |
|
| `gh gj gk gl` | parent, next sibling, previous sibling, first child |
|
||||||
| `o` `O` `a` | new sibling below / above, new child |
|
| `o` `O` `a` | new sibling below / above, new child: type its title, Enter for the next one |
|
||||||
| `i` or Enter | edit the title |
|
| `i` or Enter | edit the title (Esc: vim keys inside it, Esc again: done) |
|
||||||
| `dd` `x` | delete the branch / just the node |
|
| `dd` `x` | delete the branch / just the node |
|
||||||
|
| `>>` `<<` | under the node above / out a level |
|
||||||
| `u` `Ctrl-r` | undo / redo |
|
| `u` `Ctrl-r` | undo / redo |
|
||||||
| `za` `zf` `zi` | fold, auto-fold, focus |
|
| `za` `zf` `zi` | fold, auto-fold, focus |
|
||||||
| `+` `-` | zoom in / out |
|
| `+` `-` | zoom in / out |
|
||||||
@@ -58,7 +59,7 @@ cargo fmt # format the code
|
|||||||
While the app is being built, the examples try out each part:
|
While the app is being built, the examples try out each part:
|
||||||
|
|
||||||
```sh
|
```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 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
|
cargo test --example canvas # type key sequences into it, and check what they do
|
||||||
```
|
```
|
||||||
+403
-42
@@ -1,31 +1,46 @@
|
|||||||
//! A playground for the canvas (stages 2 to 5 of the build): a sample
|
//! A playground for the canvas (stages 2 to 6 of the build): a sample
|
||||||
//! document to move around, zoom, fold and rearrange, with the real vim keys,
|
//! document to move around, zoom, fold, rearrange and write in, with the real
|
||||||
//! the which-key popup and the `:` command line. Text editing and the other
|
//! vim keys, the which-key popup and the `:` command line. The other views
|
||||||
//! views come in later stages.
|
//! come in stage 7.
|
||||||
//!
|
//!
|
||||||
//! It runs the way the app will: the key engine turns keys into commands,
|
//! 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
|
//! 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:
|
//! Run it with `cargo run --example canvas`. A few keys to start with:
|
||||||
//!
|
//!
|
||||||
//! | Keys | What they do |
|
//! | Keys | What they do |
|
||||||
//! |----------------|------------------------------------------------------|
|
//! |-----------------|-----------------------------------------------------------|
|
||||||
//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) |
|
//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) |
|
||||||
//! | `g` | go to: `gh gl gj gk` the tree, `gg` the root |
|
//! | `g` | go to: `gh gl gj gk` the tree, `gg` the root |
|
||||||
//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` |
|
//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` |
|
||||||
//! | `+` `-` | zoom in / out |
|
//! | `+` `-` | zoom in / out |
|
||||||
//! | `a` `o` `O` | add a child / a sibling below / above |
|
//! | `i` `A` `Enter` | edit the title, typing at its end (`I`: at its start) |
|
||||||
//! | `x` `dd` `dib` | delete the node / the branch / the children |
|
//! | `cc` `S` | type the title anew |
|
||||||
//! | `M` | move mode: hjkl moves the branch, Enter ends |
|
//! | `a` `o` `O` | add a child / a sibling below / above, and type its title |
|
||||||
//! | `u` `Ctrl-r` | undo / redo |
|
//! | `x` `dd` `dib` | delete the node / the branch / the children |
|
||||||
//! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) |
|
//! | `>>` `<<` | the node goes under the one above it / out a level |
|
||||||
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
|
//! | `M` | move mode: hjkl moves the branch, Enter ends |
|
||||||
//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) |
|
//! | `u` `Ctrl-r` | undo / redo |
|
||||||
//! | `q`, `ZZ` | quit |
|
//! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) |
|
||||||
|
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
|
||||||
|
//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) |
|
||||||
|
//! | `q`, `ZZ` | quit |
|
||||||
|
//!
|
||||||
|
//! Typing a title (INSERT, a bar cursor): Enter is done. After `a`, `o` or
|
||||||
|
//! `O`, Enter starts the next idea instead, Tab puts the new idea under the
|
||||||
|
//! one above, Shift-Tab takes it out a level, and an idea left empty goes
|
||||||
|
//! away. Esc gives vim keys inside the title (TEXT, a block cursor):
|
||||||
|
//! `h l w b e 0 ^ $`, `f t F T ; ,`, `d c y` with any of those or with
|
||||||
|
//! `iw aw i" a" i( a(`, `x X s S D C Y r ~ p P`, `u Ctrl-r .`, counts
|
||||||
|
//! (`3x`, `d2f.`), `i a I A` to type again, `o O` for a new sibling. Esc
|
||||||
|
//! again: done.
|
||||||
//!
|
//!
|
||||||
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
//! 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::io;
|
||||||
use std::time::{Duration, Instant};
|
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::action::{Action, AppAction, Message, Tone};
|
||||||
use cumulus::app::buffer::Buffer;
|
use cumulus::app::buffer::Buffer;
|
||||||
use cumulus::app::mode::{Grab, Mode};
|
use cumulus::app::mode::{Grab, Mode};
|
||||||
|
use cumulus::app::registers::Registers;
|
||||||
|
use cumulus::app::text_edit::TextEdit;
|
||||||
use cumulus::app::{calls, commands};
|
use cumulus::app::{calls, commands};
|
||||||
use cumulus::command::history::History as CommandHistory;
|
use cumulus::command::history::History as CommandHistory;
|
||||||
use cumulus::command::parse::{SetArg, Sources, parse};
|
use cumulus::command::parse::{SetArg, Sources, parse};
|
||||||
@@ -48,6 +65,7 @@ use cumulus::ui::theme::Theme;
|
|||||||
use cumulus::ui::which_key::WhichKey;
|
use cumulus::ui::which_key::WhichKey;
|
||||||
use cumulus::view::canvas::CanvasView;
|
use cumulus::view::canvas::CanvasView;
|
||||||
use cumulus::view::{View, ViewContext, ViewSettings};
|
use cumulus::view::{View, ViewContext, ViewSettings};
|
||||||
|
use ratatui::crossterm::cursor::SetCursorStyle;
|
||||||
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
|
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
|
||||||
use ratatui::crossterm::execute;
|
use ratatui::crossterm::execute;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
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 doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?;
|
||||||
let mut terminal = ratatui::try_init()?;
|
let mut terminal = ratatui::try_init()?;
|
||||||
execute!(io::stdout(), EnableMouseCapture)?;
|
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();
|
let restore_the_rest = std::panic::take_hook();
|
||||||
std::panic::set_hook(Box::new(move |info| {
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape);
|
||||||
restore_the_rest(info);
|
restore_the_rest(info);
|
||||||
}));
|
}));
|
||||||
let result = Playground::new(doc).run(&mut terminal);
|
let result = Playground::new(doc).run(&mut terminal);
|
||||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape);
|
||||||
ratatui::restore();
|
ratatui::restore();
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -109,6 +128,8 @@ struct Playground {
|
|||||||
state: State,
|
state: State,
|
||||||
keymap: Keymap,
|
keymap: Keymap,
|
||||||
keys: KeyEngine,
|
keys: KeyEngine,
|
||||||
|
/// Yanked and deleted text, kept from one edit to the next.
|
||||||
|
registers: Registers,
|
||||||
mouse: MouseReader,
|
mouse: MouseReader,
|
||||||
which_key: bool,
|
which_key: bool,
|
||||||
command_history: CommandHistory,
|
command_history: CommandHistory,
|
||||||
@@ -116,6 +137,8 @@ struct Playground {
|
|||||||
popup: PopupMode,
|
popup: PopupMode,
|
||||||
/// The status bar's message, until the next key.
|
/// The status bar's message, until the next key.
|
||||||
message: Option<Message>,
|
message: Option<Message>,
|
||||||
|
/// The cursor shape last sent to the terminal.
|
||||||
|
cursor_shape: SetCursorStyle,
|
||||||
quit: bool,
|
quit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,11 +155,13 @@ impl Playground {
|
|||||||
},
|
},
|
||||||
keymap,
|
keymap,
|
||||||
keys: KeyEngine::default(),
|
keys: KeyEngine::default(),
|
||||||
|
registers: Registers::default(),
|
||||||
mouse: MouseReader::default(),
|
mouse: MouseReader::default(),
|
||||||
which_key: true,
|
which_key: true,
|
||||||
command_history: CommandHistory::default(),
|
command_history: CommandHistory::default(),
|
||||||
popup: PopupMode::default(),
|
popup: PopupMode::default(),
|
||||||
message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))),
|
message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))),
|
||||||
|
cursor_shape: SetCursorStyle::DefaultUserShape,
|
||||||
quit: false,
|
quit: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +169,7 @@ impl Playground {
|
|||||||
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||||
while !self.quit {
|
while !self.quit {
|
||||||
terminal.draw(|frame| self.draw(frame))?;
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
self.set_cursor_shape()?;
|
||||||
// While a sequence is half typed, wake up now and then: its
|
// While a sequence is half typed, wake up now and then: its
|
||||||
// which-key popup may be due.
|
// which-key popup may be due.
|
||||||
let wait =
|
let wait =
|
||||||
@@ -169,17 +195,35 @@ impl Playground {
|
|||||||
Ok(())
|
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 ----------------------------------------------------------
|
// ----- Input ----------------------------------------------------------
|
||||||
|
|
||||||
fn gesture(&mut self, gesture: Gesture) {
|
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());
|
let actions = self.view.mouse(gesture, &self.state.ctx());
|
||||||
self.update(actions);
|
self.update(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key(&mut self, key: Key) {
|
fn key(&mut self, key: Key) {
|
||||||
self.message = None;
|
self.message = None;
|
||||||
if let Mode::Command(_) = self.state.mode {
|
match self.state.mode {
|
||||||
return self.command_line_key(key);
|
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));
|
let tables = self.keymap.tables(self.view.contexts(&self.state.mode));
|
||||||
match self.keys.press(key, &tables) {
|
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.
|
/// A key while the command line is open.
|
||||||
fn command_line_key(&mut self, key: Key) {
|
fn command_line_key(&mut self, key: Key) {
|
||||||
let sources = self.sources();
|
let sources = self.sources();
|
||||||
@@ -268,6 +335,18 @@ impl Playground {
|
|||||||
|
|
||||||
fn app(&mut self, action: AppAction) -> Option<Message> {
|
fn app(&mut self, action: AppAction) -> Option<Message> {
|
||||||
match action {
|
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::EnterMoveMode => self.state.mode = Mode::Move(Grab::new()),
|
||||||
AppAction::ToggleWhole => {
|
AppAction::ToggleWhole => {
|
||||||
if let Mode::Move(grab) = &mut self.state.mode {
|
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
|
/// 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.
|
/// message or key hints, and the keys typed so far on the right.
|
||||||
fn draw_status(&self, frame: &mut Frame, area: Rect) {
|
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, .. }) => {
|
Mode::Move(Grab { whole: true, .. }) => {
|
||||||
"hjkl moves the branch (HJKL by 5) · M just this node · Enter done"
|
"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::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(_) => {
|
Mode::Normal | Mode::Command(_) => {
|
||||||
"hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · : command · <Space> menu · q quit"
|
"hjkl move · g go to · z folds · +/- zoom · i edit · a/o add · x/dd delete · >>/<< in/out · M move · u undo · : command · <Space> menu · q quit"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// While a title is edited, the status bar shows it as typed.
|
||||||
|
let title = match mode {
|
||||||
|
Mode::Text(edit) => edit.editor.text().to_string(),
|
||||||
|
_ => {
|
||||||
|
let buffer = &self.state.buffer;
|
||||||
|
buffer.doc().node(buffer.cursor()).map(|node| node.title().to_string()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let doc = self.state.buffer.doc();
|
|
||||||
let title =
|
|
||||||
doc.node(self.state.buffer.cursor()).map(|node| node.title().to_string()).unwrap_or_default();
|
|
||||||
let focus = if self.state.settings.focus { " · focus" } else { "" };
|
let focus = if self.state.settings.focus { " · focus" } else { "" };
|
||||||
let middle = match &self.message {
|
let middle = match &self.message {
|
||||||
None => hints.dark_gray(),
|
None => hints.dark_gray(),
|
||||||
@@ -364,8 +457,9 @@ impl Playground {
|
|||||||
Some(Message { text, tone: Tone::Done }) => text.clone().green(),
|
Some(Message { text, tone: Tone::Done }) => text.clone().green(),
|
||||||
Some(Message { text, tone: Tone::Info }) => text.clone().yellow(),
|
Some(Message { text, tone: Tone::Info }) => text.clone().yellow(),
|
||||||
};
|
};
|
||||||
|
let label_color = if typing { theme.insert } else { theme.accent };
|
||||||
let line = Line::from(vec![
|
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(),
|
format!(" {}{focus} │ {title} │ ", self.view.camera().zoom.name()).into(),
|
||||||
middle,
|
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.
|
/// What a command does, in words: its own help, or its operator's.
|
||||||
fn describe(command: Command) -> &'static str {
|
fn describe(command: Command) -> &'static str {
|
||||||
match (help_for(Bound::Command(command)), command) {
|
match (help_for(Bound::Command(command)), command) {
|
||||||
@@ -406,6 +511,7 @@ mod tests {
|
|||||||
use cumulus::input::keymap::DEFAULT_LEADER;
|
use cumulus::input::keymap::DEFAULT_LEADER;
|
||||||
use cumulus::input::mouse::Cell;
|
use cumulus::input::mouse::Cell;
|
||||||
use cumulus::model::{CanvasPos, NodeId};
|
use cumulus::model::{CanvasPos, NodeId};
|
||||||
|
use cumulus::view::canvas::ScreenPos;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
@@ -442,10 +548,50 @@ mod tests {
|
|||||||
self.draw();
|
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.
|
/// The title of the cursor's node.
|
||||||
fn cursor(&self) -> String {
|
fn cursor(&self) -> String {
|
||||||
let buffer = &self.playground.state.buffer;
|
let buffer = &self.playground.state.buffer;
|
||||||
buffer.doc().node(buffer.cursor()).unwrap().title().to_string()
|
self.title(buffer.cursor())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, id: NodeId) -> String {
|
||||||
|
self.playground.state.buffer.doc().node(id).unwrap().title().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> &'static str {
|
||||||
|
self.playground.state.mode.label()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title being edited, as typed so far.
|
||||||
|
fn draft(&self) -> &str {
|
||||||
|
match &self.playground.state.mode {
|
||||||
|
Mode::Text(edit) => edit.editor.text(),
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the text cursor is in the title being edited: a byte offset.
|
||||||
|
fn draft_cursor(&self) -> Option<usize> {
|
||||||
|
match &self.playground.state.mode {
|
||||||
|
Mode::Text(edit) => Some(edit.editor.cursor()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn message(&self) -> &str {
|
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()
|
doc.outline().into_iter().find(|&id| doc.node(id).unwrap().title().as_str() == title).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn children(&self, title: &str) -> Vec<String> {
|
||||||
|
let doc = self.playground.state.buffer.doc();
|
||||||
|
doc.children(self.node(title)).iter().map(|&id| self.title(id)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parent(&self, title: &str) -> String {
|
||||||
|
self.title(self.playground.state.buffer.doc().parent(self.node(title)).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
fn pos(&self, title: &str) -> CanvasPos {
|
fn pos(&self, title: &str) -> CanvasPos {
|
||||||
self.playground.state.buffer.doc().node(self.node(title)).unwrap().pos
|
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.
|
/// What's on screen, row by row.
|
||||||
fn screen(&self) -> String {
|
fn screen(&self) -> String {
|
||||||
let buffer = self.terminal.backend().buffer();
|
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]
|
#[test]
|
||||||
fn marks_counts_and_undo() {
|
fn marks_counts_and_undo() {
|
||||||
let mut h = Harness::new();
|
let mut h = Harness::new();
|
||||||
@@ -494,9 +667,9 @@ mod tests {
|
|||||||
let mut h = Harness::new();
|
let mut h = Harness::new();
|
||||||
h.type_keys("glgjM");
|
h.type_keys("glgjM");
|
||||||
assert_eq!(h.cursor(), "Raised beds");
|
assert_eq!(h.cursor(), "Raised beds");
|
||||||
assert_eq!(h.playground.state.mode.label(), "MOVE");
|
assert_eq!(h.label(), "MOVE");
|
||||||
h.type_keys("3lJ<CR>");
|
h.type_keys("3lJ<CR>");
|
||||||
assert_eq!(h.playground.state.mode.label(), "NORMAL");
|
assert_eq!(h.label(), "NORMAL");
|
||||||
assert_eq!(h.pos("Raised beds"), CanvasPos::new(25, 12), "3 right, 5 down");
|
assert_eq!(h.pos("Raised beds"), CanvasPos::new(25, 12), "3 right, 5 down");
|
||||||
assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(46, 10), "its branch came along");
|
assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(46, 10), "its branch came along");
|
||||||
h.type_keys("u");
|
h.type_keys("u");
|
||||||
@@ -507,10 +680,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dragging_a_node_moves_its_branch() {
|
fn dragging_a_node_moves_its_branch() {
|
||||||
let mut h = Harness::new();
|
let mut h = Harness::new();
|
||||||
let soil = h.playground.view.camera().to_screen(h.pos("Soil"));
|
let soil = h.on_screen("Soil");
|
||||||
let at =
|
let (start, end) = (cell(soil.col + 1, soil.row + 1), cell(soil.col + 5, soil.row + 3));
|
||||||
|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));
|
|
||||||
h.mouse(Gesture::Press { at: start, ctrl: false });
|
h.mouse(Gesture::Press { at: start, ctrl: false });
|
||||||
assert_eq!(h.cursor(), "Soil", "the press selects");
|
assert_eq!(h.cursor(), "Soil", "the press selects");
|
||||||
h.mouse(Gesture::Drag { from: start, at: end, ctrl: false });
|
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"));
|
assert_eq!(root.look.color.as_ref().map(|color| color.as_str()), Some("teal"));
|
||||||
h.type_keys(":in mulch<CR>");
|
h.type_keys(":in mulch<CR>");
|
||||||
assert_eq!(h.message(), "captured \"mulch\" to the Inbox");
|
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");
|
assert_eq!(h.cursor(), "Garden plans", "capturing leaves the cursor where it was");
|
||||||
h.type_keys(":find drip<CR>");
|
h.type_keys(":find drip<CR>");
|
||||||
assert_eq!(h.cursor(), "Drip line");
|
assert_eq!(h.cursor(), "Drip line");
|
||||||
@@ -542,8 +713,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn adding_folding_zooming_and_quitting() {
|
fn adding_folding_zooming_and_quitting() {
|
||||||
let mut h = Harness::new();
|
let mut h = Harness::new();
|
||||||
h.type_keys("a");
|
h.type_keys("aMulch<Esc><Esc>");
|
||||||
assert_eq!(h.cursor(), "new idea");
|
assert_eq!(h.cursor(), "Mulch");
|
||||||
h.type_keys("u");
|
h.type_keys("u");
|
||||||
assert_eq!(h.cursor(), "Garden plans", "undo goes back to where the change was");
|
assert_eq!(h.cursor(), "Garden plans", "undo goes back to where the change was");
|
||||||
h.type_keys("glgjza");
|
h.type_keys("glgjza");
|
||||||
@@ -556,12 +727,202 @@ mod tests {
|
|||||||
assert!(h.playground.quit);
|
assert!(h.playground.quit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_a_title_in_its_box() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.type_keys("gli mix");
|
||||||
|
assert_eq!(h.label(), "INSERT");
|
||||||
|
assert!(h.screen().contains("Soil mix"), "typed into the box:\n{}", h.screen());
|
||||||
|
let soil = h.on_screen("Soil mix");
|
||||||
|
assert_eq!(h.text_cursor(), Some((soil.col + 2 + 8, soil.row + 1)), "after the last letter");
|
||||||
|
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBar);
|
||||||
|
h.type_keys("<Esc>");
|
||||||
|
assert_eq!(h.label(), "TEXT");
|
||||||
|
let soil = h.on_screen("Soil mix");
|
||||||
|
assert_eq!(h.text_cursor(), Some((soil.col + 2 + 7, soil.row + 1)), "on the last letter, as in vim");
|
||||||
|
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBlock);
|
||||||
|
h.type_keys("<Esc>");
|
||||||
|
assert_eq!(h.label(), "NORMAL");
|
||||||
|
assert_eq!(h.text_cursor(), None, "no text cursor outside a title");
|
||||||
|
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::DefaultUserShape);
|
||||||
|
h.type_keys("u");
|
||||||
|
assert_eq!(h.cursor(), "Soil", "the whole edit was one undo step");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rapid_entry_with_enter_tab_and_shift_tab() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.type_keys("glgjgjaBucket<CR>Hose<Tab>");
|
||||||
|
assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line", "Bucket"]);
|
||||||
|
assert_eq!(h.children("Bucket"), ["Hose"], "Tab: under the idea above");
|
||||||
|
h.type_keys("<CR>Can<S-Tab>");
|
||||||
|
assert_eq!(
|
||||||
|
h.children("Watering"),
|
||||||
|
["Rain barrel", "Drip line", "Bucket", "Can"],
|
||||||
|
"Shift-Tab: out a level"
|
||||||
|
);
|
||||||
|
h.type_keys("<CR><CR>");
|
||||||
|
assert_eq!(h.label(), "NORMAL", "Enter on an empty idea stops");
|
||||||
|
assert_eq!(
|
||||||
|
h.children("Watering"),
|
||||||
|
["Rain barrel", "Drip line", "Bucket", "Can"],
|
||||||
|
"the empty one went"
|
||||||
|
);
|
||||||
|
assert_eq!(h.children("Bucket"), ["Hose"]);
|
||||||
|
assert_eq!(h.cursor(), "Can");
|
||||||
|
assert_eq!(h.message(), "");
|
||||||
|
assert!(h.screen().contains("Can"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retyping_and_repeating() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.type_keys("glccCompost<Esc><Esc>");
|
||||||
|
assert_eq!(h.cursor(), "Compost", "cc types the title anew");
|
||||||
|
h.type_keys("A!<Esc>..");
|
||||||
|
assert_eq!(h.draft(), "Compost!!!", ". repeats the typing too");
|
||||||
|
h.type_keys("u<Esc>");
|
||||||
|
assert_eq!(h.cursor(), "Compost!!", "u inside the title undoes its last change");
|
||||||
|
h.type_keys("u");
|
||||||
|
assert_eq!(h.cursor(), "Compost", "u outside undoes the whole edit");
|
||||||
|
h.type_keys("u");
|
||||||
|
assert_eq!(h.cursor(), "Soil");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vim_keys_inside_a_title() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
// "Cedar, untreated": change the second word, then find the comma.
|
||||||
|
h.type_keys("glgjgli<Esc>bcwtreated<Esc>0f,x");
|
||||||
|
assert_eq!(h.draft(), "Cedar treated");
|
||||||
|
h.type_keys("w~");
|
||||||
|
assert_eq!(h.draft(), "Cedar Treated", "~ switches the case and steps right");
|
||||||
|
h.type_keys("$diw");
|
||||||
|
assert_eq!(h.draft(), "Cedar ");
|
||||||
|
h.type_keys("<Esc>");
|
||||||
|
assert_eq!(h.cursor(), "Cedar", "the title is trimmed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every other vim key inside a title, one step at a time, each checked
|
||||||
|
/// against what vim does: the text after it, and where the cursor is.
|
||||||
|
#[test]
|
||||||
|
fn the_rest_of_the_vim_keys_inside_a_title() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.type_keys("glccplant (two) \"red\" beans<Esc>");
|
||||||
|
let steps: &[(&str, &str, usize)] = &[
|
||||||
|
("0e", "plant (two) \"red\" beans", 4),
|
||||||
|
("^", "plant (two) \"red\" beans", 0),
|
||||||
|
("$F(", "plant (two) \"red\" beans", 6),
|
||||||
|
("$T\"", "plant (two) \"red\" beans", 17),
|
||||||
|
// ; repeats the T, skipping the quote it's already next to.
|
||||||
|
(";", "plant (two) \"red\" beans", 13),
|
||||||
|
// , repeats it the other way: t".
|
||||||
|
(",", "plant (two) \"red\" beans", 15),
|
||||||
|
("0f(ci(six<Esc>", "plant (six) \"red\" beans", 9),
|
||||||
|
("f\"di\"", "plant (six) \"\" beans", 13),
|
||||||
|
("da\"", "plant (six) beans", 12),
|
||||||
|
("F(da(", "plant beans", 6),
|
||||||
|
("X", "plant beans", 5),
|
||||||
|
("wdaw", "plant", 4),
|
||||||
|
("A beans<Esc>", "plant beans", 10),
|
||||||
|
("0sP<Esc>", "Plant beans", 0),
|
||||||
|
("$r!", "Plant bean!", 10),
|
||||||
|
("u", "Plant beans", 10),
|
||||||
|
("<C-r>", "Plant bean!", 10),
|
||||||
|
("0wD", "Plant ", 5),
|
||||||
|
("0CSeed<Esc>", "Seed", 3),
|
||||||
|
("0yiwP", "SeedSeed", 3),
|
||||||
|
("I><Esc>", ">SeedSeed", 0),
|
||||||
|
("a-<Esc>", ">-SeedSeed", 1),
|
||||||
|
("SGo<Esc>", "Go", 1),
|
||||||
|
("0iE<Esc>", "EGo", 0),
|
||||||
|
("3~", "egO", 2),
|
||||||
|
("Sa.b.c.d.e.f.g<Esc>0d3f.", "d.e.f.g", 0),
|
||||||
|
("2x", "e.f.g", 0),
|
||||||
|
("d2f.", "g", 0),
|
||||||
|
// A title has one line, so Y's line goes in like any text.
|
||||||
|
("YP", "gg", 0),
|
||||||
|
];
|
||||||
|
for &(keys, text, cursor) in steps {
|
||||||
|
h.type_keys(keys);
|
||||||
|
assert_eq!((h.draft(), h.draft_cursor()), (text, Some(cursor)), "after {keys}");
|
||||||
|
}
|
||||||
|
h.type_keys("<Esc>u");
|
||||||
|
assert_eq!(h.cursor(), "Soil", "all of it was one undo step");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registers_carry_text_from_title_to_title() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.type_keys("gli<Esc>\"ayiw<Esc>");
|
||||||
|
assert_eq!(h.label(), "NORMAL");
|
||||||
|
h.type_keys("gjA <Esc>\"ap<Esc>");
|
||||||
|
assert_eq!(h.cursor(), "Raised beds Soil", "\"a kept Soil");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn o_inside_a_title_starts_a_sibling() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.type_keys("gli<Esc>oCompost<Esc><Esc>");
|
||||||
|
assert_eq!(h.children("Garden plans"), ["Soil", "Compost", "Raised beds", "Watering", "Kitchen"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mouse_while_editing() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
let watering = h.node("Watering");
|
||||||
|
// A double-click on the first e: typing goes in right before it.
|
||||||
|
let at = h.on_screen("Watering");
|
||||||
|
h.double_click(at.col + 2 + 3, at.row + 1);
|
||||||
|
assert_eq!(h.label(), "INSERT");
|
||||||
|
h.type_keys("-");
|
||||||
|
assert_eq!(h.draft(), "Wat-ering");
|
||||||
|
// A click in the title moves the text cursor.
|
||||||
|
let at = h.on_screen("Wat-ering");
|
||||||
|
h.click(at.col + 2, at.row + 1);
|
||||||
|
h.type_keys("~");
|
||||||
|
assert_eq!(h.draft(), "~Wat-ering");
|
||||||
|
// A click anywhere else keeps the edit, finishes it, and selects.
|
||||||
|
let soil = h.on_screen("Soil");
|
||||||
|
h.click(soil.col + 2, soil.row + 1);
|
||||||
|
assert_eq!(h.label(), "NORMAL");
|
||||||
|
assert_eq!(h.cursor(), "Soil");
|
||||||
|
assert_eq!(h.title(watering), "~Wat-ering");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indenting_and_outdenting_nodes() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.type_keys("glgj>>");
|
||||||
|
assert_eq!(h.cursor(), "Raised beds");
|
||||||
|
assert_eq!(h.parent("Raised beds"), "Soil", ">>: under the node above");
|
||||||
|
h.type_keys("<<");
|
||||||
|
assert_eq!(
|
||||||
|
h.children("Garden plans"),
|
||||||
|
["Soil", "Raised beds", "Watering", "Kitchen"],
|
||||||
|
"<<: back out"
|
||||||
|
);
|
||||||
|
h.type_keys("u");
|
||||||
|
assert_eq!(h.parent("Raised beds"), "Soil");
|
||||||
|
h.type_keys("u");
|
||||||
|
assert_eq!(h.parent("Raised beds"), "Garden plans");
|
||||||
|
assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7), "back where it was");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_from_later_stages_say_so() {
|
fn keys_from_later_stages_say_so() {
|
||||||
let mut h = Harness::new();
|
let mut h = Harness::new();
|
||||||
h.type_keys("yy");
|
h.type_keys("yy");
|
||||||
assert_eq!(h.message(), "\"yank (copy)…\" comes in a later stage of the build");
|
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");
|
h.type_keys("i");
|
||||||
assert_eq!(h.message(), "\"edit the title\" comes in a later stage of the build");
|
assert_eq!(h.message(), "a portal shows its document's name: rename that document instead");
|
||||||
|
assert_eq!(h.label(), "NORMAL");
|
||||||
|
h.type_keys("<CR>");
|
||||||
|
assert_eq!(h.message(), "going through a portal comes in stage 8");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//!
|
//!
|
||||||
//! [`Buffer::apply`]: super::buffer::Buffer::apply
|
//! [`Buffer::apply`]: super::buffer::Buffer::apply
|
||||||
|
|
||||||
|
use super::text_edit::StartEdit;
|
||||||
use crate::command::parse::SetArg;
|
use crate::command::parse::SetArg;
|
||||||
use crate::model::{EditOp, Group, NodeId};
|
use crate::model::{EditOp, Group, NodeId};
|
||||||
|
|
||||||
@@ -77,6 +78,10 @@ pub enum DocAction {
|
|||||||
/// What happens around the documents.
|
/// What happens around the documents.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum AppAction {
|
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.
|
/// Move mode: hjkl moves the cursor's branch.
|
||||||
EnterMoveMode,
|
EnterMoveMode,
|
||||||
/// In move mode: the whole branch, or just the node.
|
/// In move mode: the whole branch, or just the node.
|
||||||
|
|||||||
+5
-2
@@ -62,10 +62,13 @@ impl Buffer {
|
|||||||
};
|
};
|
||||||
self.keep_cursor();
|
self.keep_cursor();
|
||||||
match applied {
|
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))))
|
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),
|
DocAction::Undo(times) => self.undo_or_redo(times, true),
|
||||||
|
|||||||
+117
-15
@@ -5,12 +5,17 @@
|
|||||||
//! own meaning (in the Note view, `dd` will delete a line of text).
|
//! own meaning (in the Note view, `dd` will delete a line of text).
|
||||||
//! [`shared`] answers the ones it leaves.
|
//! [`shared`] answers the ones it leaves.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
|
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::command::{Command, Motion, Operator, Target};
|
||||||
use crate::input::engine::Typed;
|
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::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
|
/// The actions for a command every view shares, or `None` when it isn't
|
||||||
/// one.
|
/// one.
|
||||||
@@ -23,12 +28,20 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
|||||||
Command::Redo => vec![Action::Doc(DocAction::Redo(times))],
|
Command::Redo => vec![Action::Doc(DocAction::Redo(times))],
|
||||||
Command::SetMark(name) => vec![Action::Doc(DocAction::SetMark(name))],
|
Command::SetMark(name) => vec![Action::Doc(DocAction::SetMark(name))],
|
||||||
Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(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::AddChild => add(ctx, Spot::LastChildOf(cursor)),
|
||||||
Command::AddBelow => add(ctx, Spot::After(cursor)),
|
Command::AddBelow => add(ctx, Spot::After(cursor)),
|
||||||
Command::AddAbove => add(ctx, Spot::Before(cursor)),
|
Command::AddAbove => add(ctx, Spot::Before(cursor)),
|
||||||
Command::DeleteNode => delete_node(ctx),
|
Command::DeleteNode => delete_node(ctx),
|
||||||
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => delete_branches(ctx, times),
|
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => delete_branches(ctx, times),
|
||||||
Command::Operate(Operator::Delete, Target::Children) => delete_children(ctx),
|
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::ToggleFold => toggle_fold(ctx),
|
||||||
Command::OpenFold => set_fold(ctx, Fold::Open),
|
Command::OpenFold => set_fold(ctx, Fold::Open),
|
||||||
Command::CloseFold => set_fold(ctx, Fold::Closed),
|
Command::CloseFold => set_fold(ctx, Fold::Closed),
|
||||||
@@ -60,6 +73,108 @@ pub fn set_fold(ctx: &ViewContext<'_>, fold: Fold) -> Vec<Action> {
|
|||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a node at `spot`, next to its parent or sibling, and starts typing
|
||||||
|
/// its title: rapid entry. The add and the typing are one undo step.
|
||||||
|
pub fn add(ctx: &ViewContext<'_>, spot: Spot) -> Vec<Action> {
|
||||||
|
// Until something is typed, the node holds a placeholder (a title can't
|
||||||
|
// be empty).
|
||||||
|
let Ok(title) = Title::parse("new idea") else { return Vec::new() };
|
||||||
|
let node = Node::idea(title, CanvasPos::default());
|
||||||
|
let (size, rects) = (ctx.box_size(&node), ctx.box_rects());
|
||||||
|
let pos = match spot {
|
||||||
|
Spot::LastChildOf(parent) => child_spot(ctx.doc, parent, size, &rects),
|
||||||
|
_ => below_spot(ctx.cursor, size, &rects),
|
||||||
|
};
|
||||||
|
let group = Group::new();
|
||||||
|
let op = Box::new(EditOp::Add { node: Node { pos, ..node }, spot });
|
||||||
|
vec![
|
||||||
|
Action::Doc(DocAction::Edit { op, cursor: CursorAfter::Added, group: Some(group) }),
|
||||||
|
Action::App(AppAction::StartEdit(StartEdit { at: EditAt::End, new_node: Some(group) })),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `>>` (and Tab on a new idea): the nodes become the last children of the
|
||||||
|
/// sibling above them, placed to its right; their branches come along.
|
||||||
|
pub fn indent(ctx: &ViewContext<'_>, ids: &[NodeId], group: Option<Group>) -> Vec<Action> {
|
||||||
|
let doc = ctx.doc;
|
||||||
|
let Some(parent) = ids.first().and_then(|&first| doc.previous_sibling(first)) else {
|
||||||
|
return say("there's no node above to go under");
|
||||||
|
};
|
||||||
|
let (mut rects, moving) = make_room(ctx, ids);
|
||||||
|
let Some(&parent_rect) = rects.get(&parent) else { return Vec::new() };
|
||||||
|
let group = group.unwrap_or_default();
|
||||||
|
let mut last = doc.children(parent).last().and_then(|child| rects.get(child)).copied();
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for (id, size) in moving {
|
||||||
|
let pos = match last {
|
||||||
|
Some(rect) => spot_below(rect, size, &rects),
|
||||||
|
None => first_child_spot(parent_rect, size, &rects),
|
||||||
|
};
|
||||||
|
actions.push(Action::edit_in(EditOp::MoveTo { id, spot: Spot::LastChildOf(parent) }, group));
|
||||||
|
actions.extend(shift_to(doc, id, pos, group));
|
||||||
|
let rect = CanvasRect::new(pos, size);
|
||||||
|
rects.insert(id, rect);
|
||||||
|
last = Some(rect);
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<<` (and Shift-Tab on a new idea): the nodes come out from under their
|
||||||
|
/// parent, to just after it, placed below it; their branches come along.
|
||||||
|
pub fn outdent(ctx: &ViewContext<'_>, ids: &[NodeId], group: Option<Group>) -> Vec<Action> {
|
||||||
|
let doc = ctx.doc;
|
||||||
|
let Some(parent) = ids.first().and_then(|&first| doc.parent(first)) else {
|
||||||
|
return say("this node isn't under another one");
|
||||||
|
};
|
||||||
|
if parent == doc.root() {
|
||||||
|
return say("this node is right under the root already");
|
||||||
|
}
|
||||||
|
let (mut rects, moving) = make_room(ctx, ids);
|
||||||
|
let Some(&parent_rect) = rects.get(&parent) else { return Vec::new() };
|
||||||
|
let group = group.unwrap_or_default();
|
||||||
|
let (mut after, mut last) = (parent, parent_rect);
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for (id, size) in moving {
|
||||||
|
let pos = spot_below(last, size, &rects);
|
||||||
|
actions.push(Action::edit_in(EditOp::MoveTo { id, spot: Spot::After(after) }, group));
|
||||||
|
actions.extend(shift_to(doc, id, pos, group));
|
||||||
|
last = CanvasRect::new(pos, size);
|
||||||
|
rects.insert(id, last);
|
||||||
|
after = id;
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The boxes of every node except the moving branches (so they don't get in
|
||||||
|
/// their own way), and the size of each moving node's box.
|
||||||
|
fn make_room(
|
||||||
|
ctx: &ViewContext<'_>,
|
||||||
|
ids: &[NodeId],
|
||||||
|
) -> (BTreeMap<NodeId, CanvasRect>, Vec<(NodeId, CanvasSize)>) {
|
||||||
|
let mut rects = ctx.box_rects();
|
||||||
|
let moving = ids.iter().filter_map(|&id| Some((id, rects.get(&id)?.size))).collect();
|
||||||
|
for branch in ids.iter().flat_map(|&id| ctx.doc.branch(id)) {
|
||||||
|
rects.remove(&branch);
|
||||||
|
}
|
||||||
|
(rects, moving)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves a node's branch so the node lands at `pos`.
|
||||||
|
fn shift_to(doc: &Document, id: NodeId, pos: CanvasPos, group: Group) -> Option<Action> {
|
||||||
|
let offset = pos - doc.node(id)?.pos;
|
||||||
|
let moved = offset != Default::default();
|
||||||
|
moved.then(|| Action::edit_in(EditOp::Shift { id, offset, branch: true }, group))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cursor's node and the next siblings, `count` in all.
|
||||||
|
fn siblings(ctx: &ViewContext<'_>, count: u32) -> Vec<NodeId> {
|
||||||
|
ctx.doc.siblings_from(ctx.cursor, usize::try_from(count).unwrap_or(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit(at: EditAt) -> Vec<Action> {
|
||||||
|
app(AppAction::StartEdit(StartEdit { at, new_node: None }))
|
||||||
|
}
|
||||||
|
|
||||||
fn app(action: AppAction) -> Vec<Action> {
|
fn app(action: AppAction) -> Vec<Action> {
|
||||||
vec![Action::App(action)]
|
vec![Action::App(action)]
|
||||||
}
|
}
|
||||||
@@ -126,19 +241,6 @@ fn tree_step(ctx: &ViewContext<'_>, from: NodeId, motion: Motion) -> Option<Node
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a "new idea" node at `spot`, placed next to its parent or sibling,
|
|
||||||
/// and moves the cursor to it.
|
|
||||||
fn add(ctx: &ViewContext<'_>, spot: Spot) -> Vec<Action> {
|
|
||||||
let Ok(title) = Title::parse("new idea") else { return Vec::new() };
|
|
||||||
let node = Node::idea(title, CanvasPos::default());
|
|
||||||
let (size, rects) = (ctx.box_size(&node), ctx.box_rects());
|
|
||||||
let pos = match spot {
|
|
||||||
Spot::LastChildOf(parent) => child_spot(ctx.doc, parent, size, &rects),
|
|
||||||
_ => below_spot(ctx.cursor, size, &rects),
|
|
||||||
};
|
|
||||||
vec![Action::edit_then(EditOp::Add { node: Node { pos, ..node }, spot }, CursorAfter::Added)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `x`: deletes just the cursor's node; its children take its place.
|
/// `x`: deletes just the cursor's node; its children take its place.
|
||||||
fn delete_node(ctx: &ViewContext<'_>) -> Vec<Action> {
|
fn delete_node(ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
let doc = ctx.doc;
|
let doc = ctx.doc;
|
||||||
|
|||||||
+5
-2
@@ -4,11 +4,12 @@
|
|||||||
//! 1. A key goes through the key engine and comes out as a command; the
|
//! 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`]
|
//! focused view answers it with [`action`]s, and [`commands::shared`]
|
||||||
//! answers what the view leaves. A `:` command line is answered by
|
//! 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
|
//! 2. Document actions go to the [`buffer`] holding the document: the one
|
||||||
//! place a document changes. The rest change the [`mode`] or the app.
|
//! 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.
|
//! panes, files and settings.
|
||||||
|
|
||||||
pub mod action;
|
pub mod action;
|
||||||
@@ -16,3 +17,5 @@ pub mod buffer;
|
|||||||
pub mod calls;
|
pub mod calls;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
|
pub mod registers;
|
||||||
|
pub mod text_edit;
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
//! what it needs, so a mode's state can't outlive it (there's no "move mode
|
//! what it needs, so a mode's state can't outlive it (there's no "move mode
|
||||||
//! flag" to forget to clear).
|
//! flag" to forget to clear).
|
||||||
|
|
||||||
|
use super::text_edit::TextEdit;
|
||||||
use crate::model::Group;
|
use crate::model::Group;
|
||||||
use crate::ui::command_line::CommandLine;
|
use crate::ui::command_line::CommandLine;
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ pub enum Mode {
|
|||||||
/// Moving around and acting on nodes.
|
/// Moving around and acting on nodes.
|
||||||
#[default]
|
#[default]
|
||||||
Normal,
|
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.
|
/// Moving the cursor's node (or its branch) with hjkl.
|
||||||
Move(Grab),
|
Move(Grab),
|
||||||
/// Typing a `:` command.
|
/// Typing a `:` command.
|
||||||
@@ -44,6 +48,8 @@ impl Mode {
|
|||||||
pub fn label(&self) -> &'static str {
|
pub fn label(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Mode::Normal => "NORMAL",
|
Mode::Normal => "NORMAL",
|
||||||
|
Mode::Text(edit) if edit.editor.is_typing() => "INSERT",
|
||||||
|
Mode::Text(_) => "TEXT",
|
||||||
Mode::Move(_) => "MOVE",
|
Mode::Move(_) => "MOVE",
|
||||||
Mode::Command(_) => "COMMAND",
|
Mode::Command(_) => "COMMAND",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! Registers, as in vim: where yanked and deleted text goes, and where `p`
|
||||||
|
//! takes it from. There's one set for the whole app, so text yanked in one
|
||||||
|
//! title can be pasted into another.
|
||||||
|
//!
|
||||||
|
//! Everything yanked or deleted goes in the unnamed register, which `p`
|
||||||
|
//! uses when no register is named, and also in the one named with `"a`–`"z`.
|
||||||
|
//! `"_` keeps nothing. `"+` is kept inside cumulus for now: stage 8
|
||||||
|
//! connects it to the system clipboard.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::input::command::Register;
|
||||||
|
use crate::ui::text_editor::Yank;
|
||||||
|
|
||||||
|
/// The registers' contents.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Registers {
|
||||||
|
unnamed: Option<Yank>,
|
||||||
|
named: BTreeMap<char, Yank>,
|
||||||
|
clipboard: Option<Yank>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registers {
|
||||||
|
/// Keeps text that was yanked or deleted into `register` (or none).
|
||||||
|
pub fn store(&mut self, register: Option<Register>, yank: Yank) {
|
||||||
|
match register {
|
||||||
|
Some(Register::BlackHole) => return,
|
||||||
|
Some(Register::Named(name)) => {
|
||||||
|
self.named.insert(name, yank.clone());
|
||||||
|
}
|
||||||
|
Some(Register::Clipboard) => self.clipboard = Some(yank.clone()),
|
||||||
|
Some(Register::Unnamed) | None => {}
|
||||||
|
}
|
||||||
|
self.unnamed = Some(yank);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a register holds; the unnamed one when none is named.
|
||||||
|
pub fn get(&self, register: Option<Register>) -> Option<&Yank> {
|
||||||
|
match register {
|
||||||
|
None | Some(Register::Unnamed) => self.unnamed.as_ref(),
|
||||||
|
Some(Register::Named(name)) => self.named.get(&name),
|
||||||
|
Some(Register::Clipboard) => self.clipboard.as_ref(),
|
||||||
|
Some(Register::BlackHole) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_registers_and_the_black_hole() {
|
||||||
|
let yank = |text: &str| Yank { text: text.into(), linewise: false };
|
||||||
|
let mut registers = Registers::default();
|
||||||
|
registers.store(Some(Register::Named('a')), yank("cedar"));
|
||||||
|
registers.store(None, yank("soil"));
|
||||||
|
registers.store(Some(Register::BlackHole), yank("gone"));
|
||||||
|
assert_eq!(registers.get(Some(Register::Named('a'))), Some(&yank("cedar")));
|
||||||
|
assert_eq!(registers.get(None), Some(&yank("soil")), "the black hole keeps nothing");
|
||||||
|
assert_eq!(registers.get(Some(Register::Named('b'))), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//! Editing a node's title: the [`TextEditor`] does the typing and the vim
|
||||||
|
//! keys, and this is what the edit means for the document.
|
||||||
|
//!
|
||||||
|
//! The document follows the text as you type, so the layout and every view
|
||||||
|
//! stay current, and all of it joins one undo step. Rapid entry lives here
|
||||||
|
//! too: after `o`, `O` or `a`, Enter starts the next idea, Tab and
|
||||||
|
//! Shift-Tab indent and outdent the one being typed, and an idea left empty
|
||||||
|
//! goes away.
|
||||||
|
|
||||||
|
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
|
||||||
|
use super::commands;
|
||||||
|
use super::registers::Registers;
|
||||||
|
use crate::input::command::{Bound, Command, Operator, Target};
|
||||||
|
use crate::input::engine::Typed;
|
||||||
|
use crate::input::key::Key;
|
||||||
|
use crate::model::{Content, EditOp, Group, NodeId, Spot, Title};
|
||||||
|
use crate::ui::text_editor::{Reply, TextEditor, Typing};
|
||||||
|
use crate::view::ViewContext;
|
||||||
|
|
||||||
|
/// Where the text cursor starts when an edit begins.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EditAt {
|
||||||
|
/// After the last character (`i`, `A`, Enter).
|
||||||
|
End,
|
||||||
|
/// Before the first one (`I`).
|
||||||
|
Start,
|
||||||
|
/// With the title cleared, to type it again (`cc`, `S`).
|
||||||
|
Retype,
|
||||||
|
/// At this byte offset (a double-click on a letter).
|
||||||
|
Offset(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an edit of the cursor's title starts.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct StartEdit {
|
||||||
|
pub at: EditAt,
|
||||||
|
/// For a node `o`, `O` or `a` just added: the add's undo step, which
|
||||||
|
/// the edit joins. The node goes away if it's left empty.
|
||||||
|
pub new_node: Option<Group>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A title being edited.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TextEdit {
|
||||||
|
/// The node whose title it is.
|
||||||
|
pub node: NodeId,
|
||||||
|
pub editor: TextEditor,
|
||||||
|
/// The node was just added by `o`, `O` or `a`: rapid entry.
|
||||||
|
pub new_node: bool,
|
||||||
|
/// Every change the edit makes to the document joins this undo step.
|
||||||
|
group: Group,
|
||||||
|
/// The title before the edit, to put back if it's left empty.
|
||||||
|
original: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextEdit {
|
||||||
|
/// Starts editing the cursor's title. A portal's title is its
|
||||||
|
/// document's name, so it isn't edited here.
|
||||||
|
pub fn start(ctx: &ViewContext<'_>, start: StartEdit) -> Result<TextEdit, Message> {
|
||||||
|
let node = ctx.doc.node(ctx.cursor).ok_or_else(|| Message::problem("that node is gone"))?;
|
||||||
|
let Content::Idea(idea) = &node.content else {
|
||||||
|
return Err(Message::info("a portal shows its document's name: rename that document instead"));
|
||||||
|
};
|
||||||
|
// A new node's title is a placeholder until something is typed.
|
||||||
|
let original = if start.new_node.is_some() { String::new() } else { idea.title.to_string() };
|
||||||
|
let mut editor = TextEditor::new(&original, false);
|
||||||
|
// Typing starts the way vim's commands start it.
|
||||||
|
let command = match start.at {
|
||||||
|
EditAt::End => Command::InsertAtEnd,
|
||||||
|
EditAt::Start => Command::InsertAtStart,
|
||||||
|
EditAt::Retype => Command::Operate(Operator::Change, Target::Current),
|
||||||
|
EditAt::Offset(at) if at >= original.len() => Command::InsertAtEnd,
|
||||||
|
EditAt::Offset(at) => {
|
||||||
|
editor.set_cursor(at);
|
||||||
|
Command::InsertBefore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
editor.command(&Typed { command, count: None, register: None }, None);
|
||||||
|
let new_node = start.new_node.is_some();
|
||||||
|
Ok(TextEdit {
|
||||||
|
node: ctx.cursor,
|
||||||
|
editor,
|
||||||
|
new_node,
|
||||||
|
group: start.new_node.unwrap_or_default(),
|
||||||
|
original,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key while typing, with what the `insert` table binds it to (keys it
|
||||||
|
/// doesn't bind type themselves).
|
||||||
|
pub fn typing(&mut self, key: Key, bound: Option<Bound>, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
|
let typing = match bound {
|
||||||
|
Some(Bound::Command(Command::Escape)) => {
|
||||||
|
self.editor.stop_typing();
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
Some(Bound::Command(Command::Confirm)) => return self.enter(ctx),
|
||||||
|
Some(Bound::Command(Command::Operate(operator @ (Operator::Indent | Operator::Outdent), _))) => {
|
||||||
|
return self.change_level(ctx, operator == Operator::Indent);
|
||||||
|
}
|
||||||
|
Some(Bound::Command(command)) => Typing::Command(command),
|
||||||
|
Some(_) => return Vec::new(),
|
||||||
|
None => match key.as_char() {
|
||||||
|
Some(c) => Typing::Char(c),
|
||||||
|
None => return Vec::new(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.editor.type_in(typing);
|
||||||
|
self.follow(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command typed with vim keys inside the title.
|
||||||
|
pub fn vim(&mut self, typed: &Typed, registers: &mut Registers, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
|
match typed.command {
|
||||||
|
Command::Escape => return self.finish(ctx),
|
||||||
|
// In a title, o and O start a new sibling, as they do on nodes.
|
||||||
|
Command::AddBelow | Command::AddAbove => {
|
||||||
|
let spot = if typed.command == Command::AddBelow {
|
||||||
|
Spot::After(self.node)
|
||||||
|
} else {
|
||||||
|
Spot::Before(self.node)
|
||||||
|
};
|
||||||
|
let kept = Title::parse(self.editor.text()).is_ok();
|
||||||
|
let mut actions = self.finish(ctx);
|
||||||
|
if kept {
|
||||||
|
actions.extend(commands::add(ctx, spot));
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let paste = registers.get(typed.register).cloned();
|
||||||
|
let reply = self.editor.command(typed, paste.as_ref());
|
||||||
|
let mut actions = self.follow(ctx);
|
||||||
|
match reply {
|
||||||
|
Reply::Yanked(yank) => registers.store(typed.register, yank),
|
||||||
|
Reply::Refused(why) => actions.push(Action::say(Message::info(why))),
|
||||||
|
Reply::Done | Reply::NotMine => {}
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The edit is done. The title stays as typed; left empty, the old one
|
||||||
|
/// comes back (and a new node goes away instead).
|
||||||
|
pub fn finish(&self, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
if Title::parse(self.editor.text()).is_err() {
|
||||||
|
if self.new_node {
|
||||||
|
let doc = ctx.doc;
|
||||||
|
let back = doc.previous_sibling(self.node).or_else(|| doc.parent(self.node));
|
||||||
|
let op = Box::new(EditOp::RemoveBranches(vec![self.node]));
|
||||||
|
let cursor = back.map_or(CursorAfter::Stay, CursorAfter::To);
|
||||||
|
actions.push(Action::Doc(DocAction::Edit { op, cursor, group: Some(self.group) }));
|
||||||
|
} else {
|
||||||
|
if let Ok(title) = Title::parse(&self.original) {
|
||||||
|
actions.push(self.set_title(title));
|
||||||
|
}
|
||||||
|
actions.push(Action::say(Message::info("a title can't be empty: it's back as it was")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions.push(Action::App(AppAction::LeaveMode));
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter: done with an existing title. In rapid entry, the next idea
|
||||||
|
/// starts below this one, or, on an empty idea, rapid entry stops.
|
||||||
|
fn enter(&mut self, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
|
let kept = Title::parse(self.editor.text()).is_ok();
|
||||||
|
let mut actions = self.finish(ctx);
|
||||||
|
if self.new_node && kept {
|
||||||
|
actions.extend(commands::add(ctx, Spot::After(self.node)));
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tab and Shift-Tab: the new idea goes under the one above, or out a
|
||||||
|
/// level.
|
||||||
|
fn change_level(&self, ctx: &ViewContext<'_>, indent: bool) -> Vec<Action> {
|
||||||
|
if !self.new_node {
|
||||||
|
return vec![Action::say(Message::info(
|
||||||
|
"Tab and Shift-Tab move a new idea; >> and << move others",
|
||||||
|
))];
|
||||||
|
}
|
||||||
|
if indent {
|
||||||
|
commands::indent(ctx, &[self.node], Some(self.group))
|
||||||
|
} else {
|
||||||
|
commands::outdent(ctx, &[self.node], Some(self.group))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document follows the text: the title becomes what's typed (once
|
||||||
|
/// it's a title at all: not empty).
|
||||||
|
fn follow(&self, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
|
let Ok(title) = Title::parse(self.editor.text()) else { return Vec::new() };
|
||||||
|
if ctx.doc.node(self.node).is_some_and(|node| *node.title() == title) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
vec![self.set_title(title)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_title(&self, title: Title) -> Action {
|
||||||
|
Action::edit_in(EditOp::SetTitle { id: self.node, title }, self.group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! Editing titles: rapid entry, empty titles, `o` inside a title, and the
|
||||||
|
//! document following the text.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::app::buffer::Buffer;
|
||||||
|
use crate::app::mode::Mode;
|
||||||
|
use crate::input::bindings::Context;
|
||||||
|
use crate::input::engine::{KeyEngine, Outcome};
|
||||||
|
use crate::input::key::parse_keys;
|
||||||
|
use crate::input::keymap::{DEFAULT_LEADER, Keymap};
|
||||||
|
use crate::model::Document;
|
||||||
|
use crate::model::test_support::garden;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use crate::view::ViewSettings;
|
||||||
|
|
||||||
|
/// The app around an edit, as small as it gets: a document, the mode, the
|
||||||
|
/// keys and the registers. Normal-mode keys go to the shared commands.
|
||||||
|
struct App {
|
||||||
|
buffer: Buffer,
|
||||||
|
mode: Mode,
|
||||||
|
keymap: Keymap,
|
||||||
|
keys: KeyEngine,
|
||||||
|
registers: Registers,
|
||||||
|
theme: Theme,
|
||||||
|
settings: ViewSettings,
|
||||||
|
said: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new(doc: Document, cursor: NodeId) -> App {
|
||||||
|
let mut buffer = Buffer::new(doc);
|
||||||
|
buffer.apply(DocAction::MoveCursor { to: cursor, jump: false });
|
||||||
|
App {
|
||||||
|
buffer,
|
||||||
|
mode: Mode::Normal,
|
||||||
|
keymap: Keymap::new(DEFAULT_LEADER),
|
||||||
|
keys: KeyEngine::default(),
|
||||||
|
registers: Registers::default(),
|
||||||
|
theme: Theme::default(),
|
||||||
|
settings: ViewSettings::default(),
|
||||||
|
said: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presses keys written in vim notation.
|
||||||
|
fn press(&mut self, keys: &str) {
|
||||||
|
for key in parse_keys(keys, DEFAULT_LEADER).unwrap() {
|
||||||
|
let normal = Mode::Normal;
|
||||||
|
let ctx = ViewContext {
|
||||||
|
doc: self.buffer.doc(),
|
||||||
|
cursor: self.buffer.cursor(),
|
||||||
|
mode: &normal,
|
||||||
|
theme: &self.theme,
|
||||||
|
settings: &self.settings,
|
||||||
|
};
|
||||||
|
let actions = match &mut self.mode {
|
||||||
|
Mode::Text(edit) if edit.editor.is_typing() => {
|
||||||
|
edit.typing(key, self.keymap.tables(&[Context::Insert]).bound(&[key]), &ctx)
|
||||||
|
}
|
||||||
|
Mode::Text(edit) => match self.keys.press(key, &self.keymap.tables(&[Context::Text])) {
|
||||||
|
Outcome::Run(typed) => edit.vim(&typed, &mut self.registers, &ctx),
|
||||||
|
_ => Vec::new(),
|
||||||
|
},
|
||||||
|
_ => match self.keys.press(key, &self.keymap.tables(&[Context::Normal, Context::Motions])) {
|
||||||
|
Outcome::Run(typed) => commands::shared(&typed, &ctx).unwrap_or_default(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.run(actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carries out actions in order, as the app's update does.
|
||||||
|
fn run(&mut self, actions: Vec<Action>) {
|
||||||
|
for action in actions {
|
||||||
|
match action {
|
||||||
|
Action::Doc(action) => {
|
||||||
|
self.said.extend(self.buffer.apply(action).map(|message| message.text))
|
||||||
|
}
|
||||||
|
Action::App(AppAction::StartEdit(start)) => {
|
||||||
|
let normal = Mode::Normal;
|
||||||
|
let ctx = ViewContext {
|
||||||
|
doc: self.buffer.doc(),
|
||||||
|
cursor: self.buffer.cursor(),
|
||||||
|
mode: &normal,
|
||||||
|
theme: &self.theme,
|
||||||
|
settings: &self.settings,
|
||||||
|
};
|
||||||
|
match TextEdit::start(&ctx, start) {
|
||||||
|
Ok(edit) => self.mode = Mode::Text(edit),
|
||||||
|
Err(message) => self.said.push(message.text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::App(AppAction::LeaveMode) => self.mode = Mode::Normal,
|
||||||
|
Action::App(AppAction::Say(message)) => self.said.push(message.text),
|
||||||
|
Action::App(other) => panic!("unexpected here: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(&self) -> &Document {
|
||||||
|
self.buffer.doc()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, id: NodeId) -> &str {
|
||||||
|
self.doc().node(id).unwrap().title().as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_title(&self) -> &str {
|
||||||
|
self.title(self.buffer.cursor())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn titles(&self, ids: &[NodeId]) -> Vec<&str> {
|
||||||
|
ids.iter().map(|&id| self.title(id)).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rapid_entry_with_tab_and_enter() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let mut app = App::new(doc, g.watering);
|
||||||
|
app.press("aRain barrel<CR>Drip line<Tab>");
|
||||||
|
assert_eq!(app.mode.label(), "INSERT");
|
||||||
|
assert_eq!(app.titles(app.doc().children(g.watering)), ["Rain barrel"]);
|
||||||
|
let barrel = app.doc().children(g.watering)[0];
|
||||||
|
assert_eq!(app.titles(app.doc().children(barrel)), ["Drip line"], "Tab: under the idea above");
|
||||||
|
let drip = app.doc().children(barrel)[0];
|
||||||
|
assert!(
|
||||||
|
app.doc().node(drip).unwrap().pos.x > app.doc().node(barrel).unwrap().pos.x,
|
||||||
|
"placed to its right"
|
||||||
|
);
|
||||||
|
// Enter starts the next idea next to Drip line; Shift-Tab takes it back
|
||||||
|
// out a level, after Rain barrel.
|
||||||
|
app.press("<CR>Timer<S-Tab><CR><CR>");
|
||||||
|
assert_eq!(app.mode.label(), "NORMAL", "Enter on an empty idea stops");
|
||||||
|
assert_eq!(app.titles(app.doc().children(g.watering)), ["Rain barrel", "Timer"]);
|
||||||
|
assert_eq!(app.titles(app.doc().children(barrel)), ["Drip line"]);
|
||||||
|
assert_eq!(app.cursor_title(), "Timer", "back on the last idea typed");
|
||||||
|
assert!(app.said.is_empty(), "the empty idea went away quietly: {:?}", app.said);
|
||||||
|
// One undo step per idea.
|
||||||
|
app.buffer.apply(DocAction::Undo(1));
|
||||||
|
assert_eq!(app.titles(app.doc().children(g.watering)), ["Rain barrel"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_a_title_with_vim_keys() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let mut app = App::new(doc, g.soil);
|
||||||
|
app.press("i mix");
|
||||||
|
assert_eq!(app.title(g.soil), "Soil mix", "the document follows the text as it's typed");
|
||||||
|
app.press("<Esc>bcwblend<Esc>");
|
||||||
|
assert_eq!(app.mode.label(), "TEXT");
|
||||||
|
app.press("<Esc>");
|
||||||
|
assert_eq!(app.mode.label(), "NORMAL");
|
||||||
|
assert_eq!(app.title(g.soil), "Soil blend");
|
||||||
|
app.buffer.apply(DocAction::Undo(1));
|
||||||
|
assert_eq!(app.title(g.soil), "Soil", "the whole edit is one undo step");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_titles_come_back_and_empty_ideas_go_away() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let mut app = App::new(doc, g.soil);
|
||||||
|
app.press("cc<Esc><Esc>");
|
||||||
|
assert_eq!(app.title(g.soil), "Soil");
|
||||||
|
assert_eq!(app.said, ["a title can't be empty: it's back as it was"]);
|
||||||
|
assert!(!app.buffer.history().can_undo(), "nothing changed, nothing to undo");
|
||||||
|
app.press("o<Esc><Esc>");
|
||||||
|
assert_eq!(app.doc().node_count(), 6, "the new idea left empty went away");
|
||||||
|
assert_eq!(app.cursor_title(), "Soil");
|
||||||
|
assert!(!app.buffer.history().can_undo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn o_inside_a_title_starts_a_sibling() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let mut app = App::new(doc, g.soil);
|
||||||
|
app.press("A!<Esc>oCompost<Esc><Esc>");
|
||||||
|
assert_eq!(app.title(g.soil), "Soil!");
|
||||||
|
assert_eq!(app.titles(app.doc().children(g.root)), ["Soil!", "Compost", "Raised beds", "Watering"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registers_last_from_one_edit_to_the_next() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let mut app = App::new(doc, g.soil);
|
||||||
|
app.press("i<Esc>yiw<Esc>");
|
||||||
|
app.buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false });
|
||||||
|
app.press("A <Esc>p<Esc>");
|
||||||
|
assert_eq!(app.title(g.cedar), "Cedar Soil");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_tab_takes_a_new_idea_out_a_level() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let mut app = App::new(doc, g.beds);
|
||||||
|
app.press("aPaths<S-Tab><Esc><Esc>");
|
||||||
|
let paths = app.buffer.cursor();
|
||||||
|
assert_eq!(app.title(paths), "Paths");
|
||||||
|
assert_eq!(app.doc().parent(paths), Some(g.root));
|
||||||
|
assert_eq!(app.doc().next_sibling(g.beds), Some(paths), "right after its old parent");
|
||||||
|
// Under the root already: Shift-Tab can't take it further out.
|
||||||
|
app.buffer.apply(DocAction::MoveCursor { to: g.soil, jump: false });
|
||||||
|
app.press("oMore<S-Tab>");
|
||||||
|
assert_eq!(app.said.last().map(String::as_str), Some("this node is right under the root already"));
|
||||||
|
}
|
||||||
+117
-2
@@ -5,7 +5,9 @@
|
|||||||
//! Keys are written in vim notation (see [`key`](super::key)); `<leader>` is
|
//! Keys are written in vim notation (see [`key`](super::key)); `<leader>` is
|
||||||
//! the leader key, `<Space>` unless `keys.toml` says otherwise.
|
//! the leader key, `<Space>` 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`.
|
/// Where a table's keys work. Each is a section in `keys.toml`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
@@ -25,10 +27,15 @@ pub enum Context {
|
|||||||
Connect,
|
Connect,
|
||||||
/// Lists in popups: pickers, menus, the help.
|
/// Lists in popups: pickers, menus, the help.
|
||||||
List,
|
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 {
|
impl Context {
|
||||||
pub const ALL: [Context; 9] = [
|
pub const ALL: [Context; 11] = [
|
||||||
Context::Motions,
|
Context::Motions,
|
||||||
Context::Normal,
|
Context::Normal,
|
||||||
Context::Canvas,
|
Context::Canvas,
|
||||||
@@ -38,6 +45,8 @@ impl Context {
|
|||||||
Context::Move,
|
Context::Move,
|
||||||
Context::Connect,
|
Context::Connect,
|
||||||
Context::List,
|
Context::List,
|
||||||
|
Context::Text,
|
||||||
|
Context::Insert,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The section's name in `keys.toml`.
|
/// The section's name in `keys.toml`.
|
||||||
@@ -52,6 +61,8 @@ impl Context {
|
|||||||
Context::Move => "move",
|
Context::Move => "move",
|
||||||
Context::Connect => "connect",
|
Context::Connect => "connect",
|
||||||
Context::List => "list",
|
Context::List => "list",
|
||||||
|
Context::Text => "text",
|
||||||
|
Context::Insert => "insert",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +82,25 @@ impl Context {
|
|||||||
Context::Move => MOVE,
|
Context::Move => MOVE,
|
||||||
Context::Connect => CONNECT,
|
Context::Connect => CONNECT,
|
||||||
Context::List => LIST,
|
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:
|
/// 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]),
|
("move mode", &[Context::Move]),
|
||||||
("connect mode", &[Context::Connect, Context::Canvas, Context::Motions]),
|
("connect mode", &[Context::Connect, Context::Canvas, Context::Motions]),
|
||||||
("lists", &[Context::List]),
|
("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
|
/// 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"),
|
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("<Left>", M::Left),
|
||||||
|
go("j", M::Down),
|
||||||
|
go("<Down>", M::Down),
|
||||||
|
go("k", M::Up),
|
||||||
|
go("<Up>", M::Up),
|
||||||
|
go("l", M::Right),
|
||||||
|
go("<Right>", 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("<Home>", M::LineStart),
|
||||||
|
go("^", M::FirstNonBlank),
|
||||||
|
go("$", M::LineEnd),
|
||||||
|
go("<End>", 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-r>", C::Redo),
|
||||||
|
bind(".", C::Repeat),
|
||||||
|
bind_help("<Esc>", C::Escape, "done"),
|
||||||
|
bind_help("<C-c>", C::Escape, "done"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Typing a title or body. A key that isn't here types itself.
|
||||||
|
const INSERT: &[DefaultBinding] = &[
|
||||||
|
bind_help("<CR>", C::Confirm, "done (after o, O or a: the next idea; in a body: a new line)"),
|
||||||
|
bind_help("<Tab>", C::Operate(Op::Indent, Target::Current), "a new idea goes under the one above"),
|
||||||
|
bind_help("<S-Tab>", C::Operate(Op::Outdent, Target::Current), "a new idea moves out a level"),
|
||||||
|
bind_help("<Esc>", C::Escape, "vim keys inside the text"),
|
||||||
|
bind_help("<C-c>", C::Escape, "vim keys inside the text"),
|
||||||
|
bind("<BS>", C::DeleteBefore),
|
||||||
|
bind("<Del>", C::DeleteAfter),
|
||||||
|
bind("<C-w>", C::DeleteWordBefore),
|
||||||
|
bind("<C-u>", C::DeleteLineBefore),
|
||||||
|
go("<Left>", M::Left),
|
||||||
|
go("<Right>", M::Right),
|
||||||
|
go("<Up>", M::Up),
|
||||||
|
go("<Down>", M::Down),
|
||||||
|
go("<Home>", M::LineStart),
|
||||||
|
go("<End>", M::LineEnd),
|
||||||
|
];
|
||||||
|
|
||||||
/// What an operator can act on, besides its own key again (`dd`: this node
|
/// What an operator can act on, besides its own key again (`dd`: this node
|
||||||
/// and its branch).
|
/// and its branch).
|
||||||
pub const NODE_TARGETS: &[(&str, TargetBound, &str)] = &[
|
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"),
|
("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`
|
/// Names for key groups: the title of which-key's popup, and its `+group`
|
||||||
/// entries.
|
/// entries.
|
||||||
pub const GROUPS: &[(&str, &str)] = &[
|
pub const GROUPS: &[(&str, &str)] = &[
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ pub enum Motion {
|
|||||||
First,
|
First,
|
||||||
/// The last node (in text: the end).
|
/// The last node (in text: the end).
|
||||||
Last,
|
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,
|
SearchNext,
|
||||||
SearchPrevious,
|
SearchPrevious,
|
||||||
/// To the next time a character shows up on the line (`f`, in text).
|
/// To the next time a character shows up on the line (`f`, in text).
|
||||||
@@ -36,6 +44,10 @@ pub enum Motion {
|
|||||||
TillForward(char),
|
TillForward(char),
|
||||||
/// To just after the previous time a character shows up (`T`).
|
/// To just after the previous time a character shows up (`T`).
|
||||||
TillBackward(char),
|
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`.
|
/// Keys that wait for a target, like vim's `d`: `dd`, `dab`.
|
||||||
@@ -75,6 +87,22 @@ pub enum Target {
|
|||||||
Selection,
|
Selection,
|
||||||
/// From the cursor to where a motion goes (`dw`, `dfx`, in text).
|
/// From the cursor to where a motion goes (`dw`, `dfx`, in text).
|
||||||
Motion(Motion),
|
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
|
/// Keys that need one more character, typed right after them: `m` then a
|
||||||
@@ -87,6 +115,8 @@ pub enum CharKey {
|
|||||||
FindBackward,
|
FindBackward,
|
||||||
TillForward,
|
TillForward,
|
||||||
TillBackward,
|
TillBackward,
|
||||||
|
/// `r`, in text: the character to put in place of the cursor's.
|
||||||
|
Replace,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CharKey {
|
impl CharKey {
|
||||||
@@ -99,9 +129,19 @@ impl CharKey {
|
|||||||
CharKey::FindBackward => Command::Move(Motion::FindBackward(c)),
|
CharKey::FindBackward => Command::Move(Motion::FindBackward(c)),
|
||||||
CharKey::TillForward => Command::Move(Motion::TillForward(c)),
|
CharKey::TillForward => Command::Move(Motion::TillForward(c)),
|
||||||
CharKey::TillBackward => Command::Move(Motion::TillBackward(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`).
|
/// As the target of an operator, once the character is known (`dfx`).
|
||||||
/// Only motions can be targets.
|
/// Only motions can be targets.
|
||||||
pub fn target(self, c: char) -> Option<Target> {
|
pub fn target(self, c: char) -> Option<Target> {
|
||||||
@@ -118,6 +158,7 @@ impl CharKey {
|
|||||||
CharKey::JumpToMark => {
|
CharKey::JumpToMark => {
|
||||||
&[("a–z A–Z", "go to that mark"), ("'", "back to where you were before the last jump")]
|
&[("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")],
|
_ => &[("any key", "the character to go to")],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +255,32 @@ pub enum Command {
|
|||||||
Redo,
|
Redo,
|
||||||
Repeat,
|
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.
|
// Looks, tags, portals.
|
||||||
PickColor,
|
PickColor,
|
||||||
PickStyle,
|
PickStyle,
|
||||||
@@ -331,6 +398,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
|||||||
motion("previous", M::Previous, "the previous node, in reading order"),
|
motion("previous", M::Previous, "the previous node, in reading order"),
|
||||||
motion("first", M::First, "the root"),
|
motion("first", M::First, "the root"),
|
||||||
motion("last", M::Last, "the last node"),
|
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-next", M::SearchNext, "the next match"),
|
||||||
motion("search-previous", M::SearchPrevious, "the previous match"),
|
motion("search-previous", M::SearchPrevious, "the previous match"),
|
||||||
with_char("find-forward", CharKey::FindForward, "to a character"),
|
with_char("find-forward", CharKey::FindForward, "to a character"),
|
||||||
@@ -390,6 +463,21 @@ pub const COMMANDS: &[CommandInfo] = &[
|
|||||||
info("undo", C::Undo, "undo"),
|
info("undo", C::Undo, "undo"),
|
||||||
info("redo", C::Redo, "redo"),
|
info("redo", C::Redo, "redo"),
|
||||||
info("repeat", C::Repeat, "repeat the last change"),
|
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.
|
// Looks, tags, portals.
|
||||||
info("pick-color", C::PickColor, "color…"),
|
info("pick-color", C::PickColor, "color…"),
|
||||||
info("pick-style", C::PickStyle, "style…"),
|
info("pick-style", C::PickStyle, "style…"),
|
||||||
|
|||||||
+42
-5
@@ -191,10 +191,14 @@ impl KeyEngine {
|
|||||||
if self.keys.is_empty() {
|
if self.keys.is_empty() {
|
||||||
// A count takes in the next siblings, as `3dd` takes three
|
// A count takes in the next siblings, as `3dd` takes three
|
||||||
// lines in vim: say exactly how many.
|
// lines in vim: say exactly how many.
|
||||||
let help = match multiply(*count, self.count).unwrap_or(1) {
|
let help = match (tables.in_text(), multiply(*count, self.count).unwrap_or(1)) {
|
||||||
1 => "this node, with its branch".to_string(),
|
(true, 1) => "the line".to_string(),
|
||||||
2 => "2 nodes: this one and the next sibling, with their branches".to_string(),
|
(true, many) => format!("{many} lines"),
|
||||||
many => format!(
|
(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} nodes: this one and the next {} siblings, with their branches",
|
||||||
many - 1
|
many - 1
|
||||||
),
|
),
|
||||||
@@ -356,7 +360,7 @@ fn next_keys(typed: &[Key], candidates: &[&Binding], tables: &Tables<'_>) -> Vec
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::input::bindings::Context;
|
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::key::parse_keys;
|
||||||
use crate::input::keymap::{DEFAULT_LEADER, Keymap};
|
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));
|
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]
|
#[test]
|
||||||
fn which_key_lists_what_can_follow() {
|
fn which_key_lists_what_can_follow() {
|
||||||
let keymap = Keymap::new(DEFAULT_LEADER);
|
let keymap = Keymap::new(DEFAULT_LEADER);
|
||||||
|
|||||||
+67
-14
@@ -5,8 +5,8 @@
|
|||||||
//! leader = "<Space>" # the key <leader> stands for
|
//! leader = "<Space>" # the key <leader> stands for
|
||||||
//!
|
//!
|
||||||
//! [normal] # a section per place: motions, normal, canvas,
|
//! [normal] # a section per place: motions, normal, canvas,
|
||||||
//! "<leader>x" = "delete-branch" # outline, note, visual, move, connect, list
|
//! "<leader>x" = "delete-branch" # outline, note, visual, move, connect, list,
|
||||||
//! "Q" = "close"
|
//! "Q" = "close" # text (vim keys in text), insert (typing)
|
||||||
//! "x" = "nothing" # "nothing" removes a default key
|
//! "x" = "nothing" # "nothing" removes a default key
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use super::bindings::{Context, GROUPS, NODE_TARGETS, SITUATIONS, TARGET_GROUPS};
|
use super::bindings::{Context, GROUPS, SITUATIONS, TARGET_GROUPS};
|
||||||
use super::command::{self, Bound, COMMANDS, TargetBound};
|
use super::command::{self, Bound, COMMANDS, Command, Target, TargetBound};
|
||||||
use super::key::{Key, notation, parse_keys};
|
use super::key::{Key, notation, parse_keys};
|
||||||
|
|
||||||
/// The leader key unless `keys.toml` names another.
|
/// The leader key unless `keys.toml` names another.
|
||||||
@@ -43,7 +43,8 @@ pub struct TargetBinding {
|
|||||||
pub struct Keymap {
|
pub struct Keymap {
|
||||||
leader: Key,
|
leader: Key,
|
||||||
tables: BTreeMap<Context, Vec<Binding>>,
|
tables: BTreeMap<Context, Vec<Binding>>,
|
||||||
targets: Vec<TargetBinding>,
|
/// Each context's operator targets (motions aside).
|
||||||
|
targets: BTreeMap<Context, Vec<TargetBinding>>,
|
||||||
groups: Vec<(Vec<Key>, &'static str)>,
|
groups: Vec<(Vec<Key>, &'static str)>,
|
||||||
target_groups: Vec<(Vec<Key>, &'static str)>,
|
target_groups: Vec<(Vec<Key>, &'static str)>,
|
||||||
}
|
}
|
||||||
@@ -78,10 +79,17 @@ impl Keymap {
|
|||||||
(context, bindings)
|
(context, bindings)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let targets = NODE_TARGETS
|
let targets = Context::ALL
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter_map(|(keys, bound, help)| {
|
.map(|context| {
|
||||||
Some(TargetBinding { keys: parse(keys)?, bound: *bound, help: help.to_string() })
|
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();
|
.collect();
|
||||||
let named = |groups: &[(&str, &'static str)]| -> Vec<(Vec<Key>, &'static str)> {
|
let named = |groups: &[(&str, &'static str)]| -> Vec<(Vec<Key>, &'static str)> {
|
||||||
@@ -148,6 +156,9 @@ impl Keymap {
|
|||||||
/// named by `name`, or unbind it for "nothing".
|
/// named by `name`, or unbind it for "nothing".
|
||||||
fn apply(&mut self, context: Context, keys_text: &str, name: &toml::Value) -> Result<(), String> {
|
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())?;
|
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 let Some(first) = keys[0].as_char() {
|
||||||
if first.is_ascii_digit() && first != '0' {
|
if first.is_ascii_digit() && first != '0' {
|
||||||
return Err("keys can't start with 1–9: those type a count".into());
|
return Err("keys can't start with 1–9: those type a count".into());
|
||||||
@@ -224,14 +235,54 @@ impl<'a> Tables<'a> {
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What exactly these keys are bound to (for typing, where each key
|
||||||
|
/// stands alone).
|
||||||
|
pub fn bound(&self, keys: &[Key]) -> Option<Bound> {
|
||||||
|
self.bindings().into_iter().find(|binding| binding.keys == keys).map(|binding| binding.bound)
|
||||||
|
}
|
||||||
|
|
||||||
/// The bindings whose keys start with `keys`.
|
/// The bindings whose keys start with `keys`.
|
||||||
pub fn commands_starting_with(&self, keys: &[Key]) -> Vec<&'a Binding> {
|
pub fn commands_starting_with(&self, keys: &[Key]) -> Vec<&'a Binding> {
|
||||||
self.bindings().into_iter().filter(|binding| binding.keys.starts_with(keys)).collect()
|
self.bindings().into_iter().filter(|binding| binding.keys.starts_with(keys)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The targets whose keys start with `keys`.
|
/// The targets whose keys start with `keys`: each context's own, and
|
||||||
pub fn targets_starting_with(&self, keys: &[Key]) -> Vec<&'a TargetBinding> {
|
/// its motions where motions can be targets (inside text).
|
||||||
self.keymap.targets.iter().filter(|target| target.keys.starts_with(keys)).collect()
|
pub fn targets_starting_with(&self, keys: &[Key]) -> Vec<TargetBinding> {
|
||||||
|
let mut found: Vec<TargetBinding> = 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<TargetBinding> {
|
||||||
|
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`.
|
/// The name of a group of keys, like "go to" for `g`.
|
||||||
@@ -275,7 +326,9 @@ mod tests {
|
|||||||
for context in Context::ALL {
|
for context in Context::ALL {
|
||||||
assert_eq!(keymap.tables[&context].len(), context.defaults().len(), "{context:?}");
|
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::<String>::new());
|
assert_eq!(keymap.hidden_bindings(), Vec::<String>::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +373,7 @@ mod tests {
|
|||||||
"[normal] \"<Spcae>\": there's no key called <Spcae>",
|
"[normal] \"<Spcae>\": there's no key called <Spcae>",
|
||||||
"[normal] \"X\": there's no command \"split-vert\"; did you mean \"split-vertical\"?",
|
"[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\"",
|
"[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";
|
let hidden = "in normal mode on the canvas, g hides gt: typing g runs \"help: every key\" first";
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ impl History {
|
|||||||
Some(last) if group.is_some() && last.group == group => {
|
Some(last) if group.is_some() && last.group == group => {
|
||||||
last.change = std::mem::take(&mut last.change).then(change);
|
last.change = std::mem::take(&mut last.change).then(change);
|
||||||
last.state = state;
|
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 }),
|
_ => self.done.push(Step { state, change, group }),
|
||||||
}
|
}
|
||||||
@@ -216,4 +221,18 @@ mod tests {
|
|||||||
assert_eq!(doc, start, "three moves, one step");
|
assert_eq!(doc, start, "three moves, one step");
|
||||||
assert!(!history.can_undo());
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, BorderType, Clear, Paragraph};
|
use ratatui::widgets::{Block, BorderType, Clear, Paragraph};
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use super::line_input::LineInput;
|
use super::text_input::TextInput;
|
||||||
use super::theme::Theme;
|
use super::theme::Theme;
|
||||||
use crate::command::complete::{Completion, complete};
|
use crate::command::complete::{Completion, complete};
|
||||||
use crate::command::history::History;
|
use crate::command::history::History;
|
||||||
@@ -56,7 +56,7 @@ impl PopupMode {
|
|||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct CommandLine {
|
pub struct CommandLine {
|
||||||
popup: PopupMode,
|
popup: PopupMode,
|
||||||
input: LineInput,
|
input: TextInput,
|
||||||
completion: Completion,
|
completion: Completion,
|
||||||
/// The candidate Tab put in the line, while Tab cycles through them.
|
/// The candidate Tab put in the line, while Tab cycles through them.
|
||||||
selected: Option<usize>,
|
selected: Option<usize>,
|
||||||
|
|||||||
@@ -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 garden<Home><Right><Del>x<End>!");
|
|
||||||
assert_eq!(line.text(), "exgarden!");
|
|
||||||
assert_eq!(line.cursor(), line.text().len());
|
|
||||||
assert_eq!(typed("café", "<BS>e").text(), "cafe", "é is one character");
|
|
||||||
let flag = typed("ok 👍🏽", "<Left><Left>");
|
|
||||||
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", "<C-w>").text(), "e notes/");
|
|
||||||
assert_eq!(typed("e notes/", "<C-w>").text(), "e notes");
|
|
||||||
assert_eq!(typed("fold open ", "<C-w>").text(), "fold ");
|
|
||||||
assert_eq!(typed("fold open", "<Left><Left><C-u>").text(), "en");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-5
@@ -1,11 +1,12 @@
|
|||||||
//! What the views share: the characters they draw with ([`glyphs`]), the
|
//! What the views share: the characters they draw with ([`glyphs`]), the
|
||||||
//! colors and looks ([`theme`]), the which-key popup ([`which_key`]), and the
|
//! colors and looks ([`theme`]), the which-key popup ([`which_key`]), the
|
||||||
//! command line ([`command_line`]) with the line input it types into
|
//! command line ([`command_line`]), and text being typed ([`text_input`])
|
||||||
//! ([`line_input`]). The bars and the other shared widgets come in later
|
//! or edited with vim keys ([`text_editor`]). The bars and the other shared
|
||||||
//! stages.
|
//! widgets come in later stages.
|
||||||
|
|
||||||
pub mod command_line;
|
pub mod command_line;
|
||||||
pub mod glyphs;
|
pub mod glyphs;
|
||||||
pub mod line_input;
|
pub mod text_editor;
|
||||||
|
pub mod text_input;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod which_key;
|
pub mod which_key;
|
||||||
@@ -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<Range<usize>> {
|
||||||
|
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<usize>], 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<usize>], 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<usize>], 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<String> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Typing>,
|
||||||
|
pasted: Option<Yank>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an operator acts on.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Span {
|
||||||
|
range: Range<usize>,
|
||||||
|
/// For whole lines: the lines themselves, without the line break the
|
||||||
|
/// range takes with them.
|
||||||
|
lines: Option<Range<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Span {
|
||||||
|
fn chars(range: Range<usize>) -> 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<Snapshot>,
|
||||||
|
redo: Vec<Snapshot>,
|
||||||
|
/// The column `j` and `k` aim for, kept while they pass shorter lines.
|
||||||
|
column: Option<usize>,
|
||||||
|
/// The last `f`, `F`, `t` or `T`, for `;` and `,`.
|
||||||
|
last_find: Option<Motion>,
|
||||||
|
/// The change being made, while it's typed in.
|
||||||
|
recording: Option<Change>,
|
||||||
|
/// The last change, for `.`.
|
||||||
|
last_change: Option<Change>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Yank>) {
|
||||||
|
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<u32>) -> 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<u32>) {
|
||||||
|
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<u32>, operator: bool) -> Option<usize> {
|
||||||
|
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<u32>) -> Option<Span> {
|
||||||
|
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<u32>) -> Option<Span> {
|
||||||
|
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<u32>) -> 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<u32>) -> 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<dyn Iterator<Item = char>> {
|
||||||
|
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<Motion>) -> 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<usize> {
|
||||||
|
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;
|
||||||
@@ -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<usize> {
|
||||||
|
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<Class> {
|
||||||
|
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<usize> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Range<usize>> {
|
||||||
|
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<Range<usize>> {
|
||||||
|
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<Range<usize>> {
|
||||||
|
let (start, end) = (line_start(text, at), line_end(text, at));
|
||||||
|
let quotes: Vec<usize> = 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<Range<usize>> {
|
||||||
|
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<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Yank>,
|
||||||
|
/// What the commands said, when it wasn't just "done".
|
||||||
|
replies: Vec<Reply>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Yank> {
|
||||||
|
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<Esc>");
|
||||||
|
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("<C-r>");
|
||||||
|
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<Esc>");
|
||||||
|
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 beds<C-w>tall<Left><Left><BS>X<End>!<Esc>");
|
||||||
|
assert_eq!(keys.shown(), "Raised tXll|!");
|
||||||
|
keys.press("A ok<CR>yes<Esc>");
|
||||||
|
assert_eq!(keys.shown(), "Raised tXll! ok ye|s", "a title stays on one line");
|
||||||
|
keys.press("0Ito <Esc>");
|
||||||
|
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("seeds<Esc>Y");
|
||||||
|
assert_eq!(keys.register, yank("seeds", true));
|
||||||
|
keys.press("sS<Esc>");
|
||||||
|
assert_eq!(keys.shown(), "seed|S");
|
||||||
|
keys.press("Snew<Esc>");
|
||||||
|
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<Esc>");
|
||||||
|
assert_eq!(keys.shown(), "three one\nen|d\ntwo");
|
||||||
|
keys.press("Gdk");
|
||||||
|
assert_eq!(keys.shown(), "|three one");
|
||||||
|
keys.press("Omore<CR>and more<Esc>");
|
||||||
|
assert_eq!(keys.shown(), "more\nand mor|e\nthree one");
|
||||||
|
keys.press("ggjcc<Esc>");
|
||||||
|
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("<Esc>");
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -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<usize> {
|
||||||
|
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 garden<Home><Right><Del>x<End>!");
|
||||||
|
assert_eq!(input.text(), "exgarden!");
|
||||||
|
assert_eq!(input.cursor(), input.text().len());
|
||||||
|
assert_eq!(typed("café", "<BS>e").text(), "cafe", "é is one character");
|
||||||
|
let flag = typed("ok 👍🏽", "<Left><Left>");
|
||||||
|
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", "<C-w>").text(), "e notes/");
|
||||||
|
assert_eq!(typed("e notes/", "<C-w>").text(), "e notes");
|
||||||
|
assert_eq!(typed("fold open ", "<C-w>").text(), "fold ");
|
||||||
|
assert_eq!(typed("fold open", "<Left><Left><C-u>").text(), "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn several_lines() {
|
||||||
|
let mut input = typed("Bring a bucket.\nThe bin", "<Home>");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ pub struct Theme {
|
|||||||
pub accent: Color,
|
pub accent: Color,
|
||||||
/// Quiet things: lines, hints, counts.
|
/// Quiet things: lines, hints, counts.
|
||||||
pub muted: Color,
|
pub muted: Color,
|
||||||
|
/// Typing: the status bar's INSERT, and the box being typed in.
|
||||||
|
pub insert: Color,
|
||||||
/// Node colors, by name.
|
/// Node colors, by name.
|
||||||
pub palette: BTreeMap<ColorName, Color>,
|
pub palette: BTreeMap<ColorName, Color>,
|
||||||
/// How nodes look, unless something says otherwise.
|
/// How nodes look, unless something says otherwise.
|
||||||
@@ -84,6 +86,7 @@ impl Default for Theme {
|
|||||||
glyphs: Glyphs::Unicode,
|
glyphs: Glyphs::Unicode,
|
||||||
accent: Color::Cyan,
|
accent: Color::Cyan,
|
||||||
muted: Color::DarkGray,
|
muted: Color::DarkGray,
|
||||||
|
insert: Color::Green,
|
||||||
palette,
|
palette,
|
||||||
node: NodeLook { border: Some(Border::Square), ..NodeLook::default() },
|
node: NodeLook { border: Some(Border::Square), ..NodeLook::default() },
|
||||||
root: NodeLook { border: Some(Border::Rounded), ..NodeLook::default() },
|
root: NodeLook { border: Some(Border::Rounded), ..NodeLook::default() },
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use crate::model::{CanvasRect, CanvasSize, Content, Document, Node, NodeId};
|
use crate::model::{CanvasRect, CanvasSize, Content, Document, Node, NodeId};
|
||||||
use crate::ui::glyphs::Glyphs;
|
use crate::ui::glyphs::Glyphs;
|
||||||
|
use crate::ui::text_editor::layout;
|
||||||
|
|
||||||
/// The width titles wrap at, unless the config says otherwise.
|
/// The width titles wrap at, unless the config says otherwise.
|
||||||
pub const DEFAULT_MAX_WIDTH: u16 = 30;
|
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<Range<usize>>,
|
||||||
|
/// 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:
|
/// 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.
|
/// enough to tell where nodes are and how much room they take.
|
||||||
pub fn box_rects(doc: &Document, max_width: u16, glyphs: Glyphs) -> BTreeMap<NodeId, CanvasRect> {
|
pub fn box_rects(doc: &Document, max_width: u16, glyphs: Glyphs) -> BTreeMap<NodeId, CanvasRect> {
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ pub fn first_child_spot(
|
|||||||
/// Where a new node just after `sibling` goes: right below it, then further
|
/// Where a new node just after `sibling` goes: right below it, then further
|
||||||
/// down until it's clear.
|
/// down until it's clear.
|
||||||
pub fn below_spot(sibling: NodeId, size: CanvasSize, rects: &BTreeMap<NodeId, CanvasRect>) -> CanvasPos {
|
pub fn below_spot(sibling: NodeId, size: CanvasSize, rects: &BTreeMap<NodeId, CanvasRect>) -> CanvasPos {
|
||||||
let Some(rect) = rects.get(&sibling) else { return CanvasPos::default() };
|
let Some(&rect) = rects.get(&sibling) else { return CanvasPos::default() };
|
||||||
free_spot(CanvasPos::new(rect.left(), rect.bottom() + ROW_GAP), size, rects)
|
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<NodeId, CanvasRect>) -> 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
|
/// The first spot at `start` or below it where a box of `size` keeps clear of
|
||||||
|
|||||||
+11
-32
@@ -170,9 +170,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::test_support::garden;
|
use crate::model::test_support::garden;
|
||||||
use crate::model::{CanvasPos, Fold, Label, Link};
|
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::canvas::{Camera, Zoom};
|
||||||
|
use crate::view::test_support::Setup;
|
||||||
|
|
||||||
/// Prints the example document (`examples/garden.cum`) at each zoom, to
|
/// Prints the example document (`examples/garden.cum`) at each zoom, to
|
||||||
/// look at the canvas without a terminal:
|
/// look at the canvas without a terminal:
|
||||||
@@ -181,16 +180,10 @@ mod tests {
|
|||||||
#[ignore = "prints the canvas; run it by name"]
|
#[ignore = "prints the canvas; run it by name"]
|
||||||
fn show_canvas() {
|
fn show_canvas() {
|
||||||
let doc = crate::storage::document_file::parse(include_str!("../../../examples/garden.cum")).unwrap();
|
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] {
|
for zoom in [Zoom::Normal, Zoom::Far, Zoom::Overview] {
|
||||||
let camera = Camera { origin: CanvasPos::new(-1, 0), zoom };
|
let camera = Camera { origin: CanvasPos::new(-1, 0), zoom };
|
||||||
let scene = Scene::build(
|
let scene = Scene::build(&setup.ctx(&doc, doc.root()), camera, Size::new(84, 36));
|
||||||
&doc,
|
|
||||||
doc.root(),
|
|
||||||
camera,
|
|
||||||
Size::new(84, 36),
|
|
||||||
&Theme::default(),
|
|
||||||
&ViewSettings::default(),
|
|
||||||
);
|
|
||||||
println!("── {} {}", zoom.name(), "─".repeat(70));
|
println!("── {} {}", zoom.name(), "─".repeat(70));
|
||||||
for row in snapshot(&scene) {
|
for row in snapshot(&scene) {
|
||||||
println!("{row}");
|
println!("{row}");
|
||||||
@@ -211,15 +204,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_garden_at_normal_zoom() {
|
fn the_garden_at_normal_zoom() {
|
||||||
let (doc, g) = garden();
|
let ((doc, g), setup) = (garden(), Setup::default());
|
||||||
let scene = Scene::build(
|
let scene = Scene::build(&setup.ctx(&doc, g.beds), Camera::default(), Size::new(52, 17));
|
||||||
&doc,
|
|
||||||
g.beds,
|
|
||||||
Camera::default(),
|
|
||||||
Size::new(52, 17),
|
|
||||||
&Theme::default(),
|
|
||||||
&ViewSettings::default(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
snapshot(&scene),
|
snapshot(&scene),
|
||||||
vec![
|
vec![
|
||||||
@@ -250,9 +236,9 @@ mod tests {
|
|||||||
let (mut doc, g) = garden();
|
let (mut doc, g) = garden();
|
||||||
let orange = crate::model::ColorName::parse("orange").unwrap();
|
let orange = crate::model::ColorName::parse("orange").unwrap();
|
||||||
doc.edit(|e| e.update(g.beds, |node| node.look.color = Some(orange.clone()))).unwrap();
|
doc.edit(|e| e.update(g.beds, |node| node.look.color = Some(orange.clone()))).unwrap();
|
||||||
let theme = Theme::default();
|
let setup = Setup::default();
|
||||||
let scene =
|
let theme = &setup.theme;
|
||||||
Scene::build(&doc, g.soil, Camera::default(), Size::new(52, 8), &theme, &ViewSettings::default());
|
let scene = Scene::build(&setup.ctx(&doc, g.soil), Camera::default(), Size::new(52, 8));
|
||||||
let area = Rect::new(0, 0, 52, 8);
|
let area = Rect::new(0, 0, 52, 8);
|
||||||
let mut buf = Buffer::empty(area);
|
let mut buf = Buffer::empty(area);
|
||||||
scene.render(area, &mut buf);
|
scene.render(area, &mut buf);
|
||||||
@@ -272,14 +258,8 @@ mod tests {
|
|||||||
e.update(g.compost, |node| node.pos = CanvasPos::new(0, 16))
|
e.update(g.compost, |node| node.pos = CanvasPos::new(0, 16))
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let scene = Scene::build(
|
let setup = Setup::default();
|
||||||
&doc,
|
let scene = Scene::build(&setup.ctx(&doc, g.root), Camera::default(), Size::new(52, 19));
|
||||||
g.root,
|
|
||||||
Camera::default(),
|
|
||||||
Size::new(52, 19),
|
|
||||||
&Theme::default(),
|
|
||||||
&ViewSettings::default(),
|
|
||||||
);
|
|
||||||
let text = snapshot(&scene);
|
let text = snapshot(&scene);
|
||||||
assert!(text[6].ends_with("── +1 ─┘"), "the hidden Cedar shows as +1: {text:#?}");
|
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:#?}");
|
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
|
// Far zoom: one line per node, half the height. The link climbs from
|
||||||
// the compost note up into Watering, labeled where it runs level.
|
// the compost note up into Watering, labeled where it runs level.
|
||||||
let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far };
|
let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far };
|
||||||
let scene =
|
let scene = Scene::build(&setup.ctx(&doc, g.root), far, Size::new(40, 9));
|
||||||
Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), &ViewSettings::default());
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
snapshot(&scene),
|
snapshot(&scene),
|
||||||
vec![
|
vec![
|
||||||
|
|||||||
+78
-16
@@ -3,19 +3,23 @@
|
|||||||
//! the same scene, so what you click is what you see.
|
//! the same scene, so what you click is what you see.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
use ratatui::layout::Size;
|
use ratatui::layout::Size;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use unicode_width::UnicodeWidthStr;
|
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::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||||
use super::lines::{LineLayer, LineOwner, Paint};
|
use super::lines::{LineLayer, LineOwner, Paint};
|
||||||
use super::route::{Bend, route};
|
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::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
|
||||||
use crate::ui::glyphs::Glyphs;
|
use crate::ui::glyphs::Glyphs;
|
||||||
|
use crate::ui::text_editor::layout;
|
||||||
use crate::ui::theme::{LineStyle, Theme};
|
use crate::ui::theme::{LineStyle, Theme};
|
||||||
use crate::view::ViewSettings;
|
use crate::view::ViewContext;
|
||||||
|
|
||||||
/// How many columns of a title show at Far zoom.
|
/// How many columns of a title show at Far zoom.
|
||||||
const FAR_TITLE_WIDTH: usize = 14;
|
const FAR_TITLE_WIDTH: usize = 14;
|
||||||
@@ -53,6 +57,16 @@ pub(super) struct Mark {
|
|||||||
pub(super) color: Color,
|
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<Range<usize>>,
|
||||||
|
cursor: ScreenPos,
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the canvas draws in one frame.
|
/// Everything the canvas draws in one frame.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Scene {
|
pub struct Scene {
|
||||||
@@ -61,6 +75,9 @@ pub struct Scene {
|
|||||||
pub(super) glyphs: Glyphs,
|
pub(super) glyphs: Glyphs,
|
||||||
pub(super) accent: Color,
|
pub(super) accent: Color,
|
||||||
pub(super) muted: 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.
|
/// The nodes in drawing order: the cursor's last, on top.
|
||||||
pub(super) nodes: Vec<SceneNode>,
|
pub(super) nodes: Vec<SceneNode>,
|
||||||
/// Where each node is in `nodes`.
|
/// Where each node is in `nodes`.
|
||||||
@@ -70,19 +87,20 @@ pub struct Scene {
|
|||||||
covered: Vec<Option<usize>>,
|
covered: Vec<Option<usize>>,
|
||||||
pub(super) lines: LineLayer,
|
pub(super) lines: LineLayer,
|
||||||
pub(super) marks: Vec<Mark>,
|
pub(super) marks: Vec<Mark>,
|
||||||
|
editing: Option<Editing>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scene {
|
impl Scene {
|
||||||
/// Works out the frame: which nodes show (folding, the cursor), where
|
/// Works out the frame: which nodes show (folding, the cursor), where
|
||||||
/// they are on screen, and every line between them.
|
/// they are on screen, and every line between them. A title being edited
|
||||||
pub fn build(
|
/// shows as typed, with the text cursor in it.
|
||||||
doc: &Document,
|
pub fn build(ctx: &ViewContext<'_>, camera: Camera, size: Size) -> Scene {
|
||||||
cursor: NodeId,
|
let (doc, cursor, theme, settings) = (ctx.doc, ctx.cursor, ctx.theme, ctx.settings);
|
||||||
camera: Camera,
|
let edit = match ctx.mode {
|
||||||
size: Size,
|
Mode::Text(edit) => Some(edit),
|
||||||
theme: &Theme,
|
_ => None,
|
||||||
settings: &ViewSettings,
|
};
|
||||||
) -> Scene {
|
let typing = edit.is_some_and(|edit| edit.editor.is_typing());
|
||||||
let visible = Visible::new(doc, cursor, settings.focus);
|
let visible = Visible::new(doc, cursor, settings.focus);
|
||||||
let mut scene = Scene {
|
let mut scene = Scene {
|
||||||
size,
|
size,
|
||||||
@@ -90,6 +108,7 @@ impl Scene {
|
|||||||
glyphs: theme.glyphs,
|
glyphs: theme.glyphs,
|
||||||
accent: theme.accent,
|
accent: theme.accent,
|
||||||
muted: theme.muted,
|
muted: theme.muted,
|
||||||
|
cursor_color: if typing { theme.insert } else { theme.accent },
|
||||||
covered: vec![None; size.area() as usize],
|
covered: vec![None; size.area() as usize],
|
||||||
lines: LineLayer::new(size),
|
lines: LineLayer::new(size),
|
||||||
..Scene::default()
|
..Scene::default()
|
||||||
@@ -98,8 +117,11 @@ impl Scene {
|
|||||||
let Some(node) = doc.node(id) else { continue };
|
let Some(node) = doc.node(id) else { continue };
|
||||||
let style = theme.node_style(node, id == doc.root());
|
let style = theme.node_style(node, id == doc.root());
|
||||||
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
|
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
|
||||||
let (rect, shape) =
|
let max_width = settings.max_node_width;
|
||||||
shape(node, badge, style.border, camera, theme.glyphs, 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 });
|
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.
|
// The cursor's node goes last, on top of any it overlaps.
|
||||||
@@ -139,6 +161,45 @@ impl Scene {
|
|||||||
self.lines.owner(at)
|
self.lines.owner(at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cell the text cursor is on, while a title is edited.
|
||||||
|
pub fn text_cursor(&self) -> Option<ScreenPos> {
|
||||||
|
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<usize> {
|
||||||
|
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.
|
/// The index (in `nodes`) of the topmost node on a cell.
|
||||||
pub(super) fn covered_at(&self, at: ScreenPos) -> Option<usize> {
|
pub(super) fn covered_at(&self, at: ScreenPos) -> Option<usize> {
|
||||||
covered_at(&self.covered, self.size, at)
|
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
|
/// 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) {
|
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() {
|
for (index, node) in nodes.iter().enumerate() {
|
||||||
let Shape::Box { border, .. } = &node.shape else { continue };
|
let Shape::Box { border, .. } = &node.shape else { continue };
|
||||||
let (border, color) =
|
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) };
|
let paint = Paint { color, owner: LineOwner::Border(node.id) };
|
||||||
lines.add_border(node.rect, border, paint, |at| covered_at(covered, *size, at) == Some(index));
|
lines.add_border(node.rect, border, paint, |at| covered_at(covered, *size, at) == Some(index));
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-23
@@ -3,18 +3,20 @@
|
|||||||
//! it wants to the document goes out as an action.
|
//! it wants to the document goes out as an action.
|
||||||
|
|
||||||
use ratatui::Frame;
|
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::navigate::nearest;
|
||||||
use super::scene::Scene;
|
use super::scene::Scene;
|
||||||
use crate::app::action::{Action, AppAction, Message};
|
use crate::app::action::{Action, AppAction, Message};
|
||||||
use crate::app::mode::{Grab, Mode};
|
use crate::app::mode::{Grab, Mode};
|
||||||
|
use crate::app::text_edit::{EditAt, StartEdit};
|
||||||
use crate::input::bindings::Context;
|
use crate::input::bindings::Context;
|
||||||
use crate::input::command::{Command, Motion};
|
use crate::input::command::{Command, Motion};
|
||||||
use crate::input::engine::Typed;
|
use crate::input::engine::Typed;
|
||||||
use crate::input::mouse::{Cell, Gesture};
|
use crate::input::mouse::{Cell, Gesture};
|
||||||
use crate::model::{CanvasOffset, Direction, EditOp, Group, NodeId, Visible};
|
use crate::model::{CanvasOffset, Direction, EditOp, Group, NodeId, Visible};
|
||||||
|
use crate::ui::text_editor::layout;
|
||||||
use crate::view::{View, ViewContext, ViewKind};
|
use crate::view::{View, ViewContext, ViewKind};
|
||||||
|
|
||||||
/// How close (in cells) the cursor's node may come to an edge before the
|
/// 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 {
|
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<Action> {
|
||||||
|
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
|
/// 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] {
|
fn contexts(&self, mode: &Mode) -> &'static [Context] {
|
||||||
match mode {
|
match mode {
|
||||||
Mode::Normal => &[Context::Canvas, Context::Normal, Context::Motions],
|
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],
|
Mode::Move(_) => &[Context::Move],
|
||||||
// The command line reads its own keys.
|
// The command line reads its own keys.
|
||||||
Mode::Command(_) => &[],
|
Mode::Command(_) => &[],
|
||||||
@@ -190,25 +224,46 @@ impl View for CanvasView {
|
|||||||
|
|
||||||
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
|
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
|
||||||
self.area = area;
|
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);
|
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
|
||||||
// While a node is dragged, the view stays put under the mouse.
|
// 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());
|
let follow = std::mem::take(&mut self.reveal) || (moved && self.drag.is_none());
|
||||||
self.scene = self.build(ctx);
|
self.scene = self.build(ctx);
|
||||||
if follow
|
let mut panned = follow
|
||||||
&& let Some(rect) = self.scene.rect_of(ctx.cursor)
|
&& self
|
||||||
&& self.camera.reveal(rect, area.as_size(), SCROLL_OFF)
|
.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);
|
self.scene = self.build(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
|
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
|
||||||
frame.render_widget(&self.scene, self.area);
|
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<Vec<Action>> {
|
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||||
if let Mode::Move(grab) = ctx.mode {
|
match ctx.mode {
|
||||||
return self.move_command(typed, *grab, ctx);
|
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 steps = cells(typed);
|
||||||
let half_page = i32::from(self.area.height / 2);
|
let half_page = i32::from(self.area.height / 2);
|
||||||
@@ -229,21 +284,34 @@ impl View for CanvasView {
|
|||||||
Some(Vec::new())
|
Some(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mouse(&mut self, gesture: Gesture, _ctx: &ViewContext<'_>) -> Vec<Action> {
|
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
match gesture {
|
match gesture {
|
||||||
Gesture::Press { at, .. } => {
|
Gesture::Press { at, .. } => {
|
||||||
let at = self.screen(at);
|
let at = self.screen(at);
|
||||||
match self.scene.node_at(at) {
|
let Mode::Text(edit) = ctx.mode else { return self.press(at) };
|
||||||
Some(id) => {
|
// While a title is edited, a click in it moves the text
|
||||||
let moved = CanvasOffset::default();
|
// cursor; a click anywhere else keeps the edit, finishes it,
|
||||||
self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() });
|
// and does what a click does.
|
||||||
vec![Action::cursor(id)]
|
if let Some(offset) = self.scene.text_offset_at(at) {
|
||||||
}
|
return vec![Action::App(AppAction::TextCursor(offset))];
|
||||||
None => {
|
|
||||||
self.drag = Some(Drag::Pan { last: at });
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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::Drag { at, .. } => self.drag_to(self.screen(at)),
|
||||||
Gesture::Release { .. } => {
|
Gesture::Release { .. } => {
|
||||||
@@ -260,9 +328,6 @@ impl View for CanvasView {
|
|||||||
self.camera.pan(cols, rows);
|
self.camera.pan(cols, rows);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
Gesture::DoublePress { .. } => {
|
|
||||||
vec![Action::say(Message::info("editing titles comes in stage 6"))]
|
|
||||||
}
|
|
||||||
Gesture::RightPress { .. } => {
|
Gesture::RightPress { .. } => {
|
||||||
vec![Action::say(Message::info("the right-click menu comes in stage 7"))]
|
vec![Action::say(Message::info("the right-click menu comes in stage 7"))]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user