Add the outline and Note views, splits, popups and the bars
The outline shows the tree as an indented list: a row per node with its fold marker, tree lines in the parent's color, tags, a ¶ for a body and +N for what a fold hides. zb shows every body under its title, to read a document like a page. j/k go row by row, h/l to the parent and the first child, a click on a marker folds, and titles are edited in their row, rapid entry included. In move mode, j/k swap the node with its siblings, h takes it out a level and l puts it under the one above. The Note view shows one node at full size, as parts: the title, the tags, the body, each link and each child. j/k step through them, Enter or i edits the part (or goes to that link or child), and h/l turn the page to the sibling before or after. Bodies and tags are edited with the same editor as titles: Enter starts a line in a body, Tab types spaces, and tags that don't parse come back as they were. cc, C and s now start typing on an empty line too, as in vim. Panes split the screen the way vim's windows do (Ctrl-w v s h j k l w c o =, :split, :vsplit, :only), each in a box with its view and document, and any pane can show any open document (gt, gT, :b, the documents picker, a click on the document bar). The status bar has two lines: the mode, the view, the breadcrumb or a message, the keys typed; then key hints for what you're doing, which <Space>w hides with which-key. Popups are a mode of their own, built on one list: ? lists every key and command, / searches it and Enter runs what you pick; the right-click menu lists what you can do with a node (with the keys that do the same) or adds a new idea on the empty canvas; the pickers set a node's color, border, style or tags. K opens the body in a card beside the node, where i edits it in place. The example now opens two documents (the garden, and the kitchen its portal leads to) and its tests drive all of the above end to end. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
84aefbecdb
commit
c587c42115
37 files changed
+5579
-730
No files matched your search
@@ -136,8 +136,56 @@ lines.
|
||||
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.
|
||||
- Bodies are edited in the Note view and in the `K` card, with the same
|
||||
editor: Enter starts a new line, Tab types four spaces. `cc`, `C` and `s`
|
||||
start typing on an empty line too, as in vim (bodies are often empty).
|
||||
|
||||
**Views, panes and popups** (settled in stage 7)
|
||||
- The Note view is made of parts: the title, the tags, the body, each link
|
||||
(out, then in) and each child. `j` and `k` step through them, with a bar
|
||||
marking the one you're on; Enter, `i` or `A` edits it (`I` from its start,
|
||||
`cc` `S` anew), or goes to that link (a jump: `''` comes back) or child.
|
||||
`h` and `l` turn the page: the sibling before or after. Node keys (`dd`,
|
||||
`za`, `o`…) act on the note's node. The note follows the cursor, starting
|
||||
at the title of each node it comes to.
|
||||
- The outline has a row per node: its fold marker (`▾` `▸`), tree lines in
|
||||
the parent's color (as branches are on the canvas), its tags, `¶` when it
|
||||
has a body, `+N` for what a fold hides. `zb` shows every body under its
|
||||
title, where its children hang, to read the document like a page. `j` and
|
||||
`k` go row by row, `h` and `l` to the parent and the first child; a click
|
||||
on a marker folds. Titles are edited in their row, rapid entry included.
|
||||
- Move mode in the outline and the Note view moves the node through the
|
||||
tree: `j` and `k` swap it with its siblings (`J` and `K` five places), `h`
|
||||
takes it out a level, `l` puts it under the one above.
|
||||
- The status bar has two lines: the mode, the view, the breadcrumb (or a
|
||||
message in its place) and the keys typed so far; then the key hints for
|
||||
what you're doing. `<Space>w` hides the hint line along with which-key.
|
||||
- Help (`?`, `<Space>?`, `:help [topic]`) lists every key of the focused view
|
||||
and every `:` command, under which-key's headings. `/` searches (rows that
|
||||
start with what's typed first, then fuzzy matches), and Enter runs the row:
|
||||
a key that waits for more (`m`) is typed for you to finish, and a `:`
|
||||
command that needs an argument opens the command line with it typed.
|
||||
- The `K` card opens beside the node (in the middle when the node isn't on
|
||||
screen). `j` `k` `Ctrl-d` `Ctrl-u` scroll it; `i`, `A` or Enter edits the
|
||||
body right there (`I` from its start), and when that's done the card stays;
|
||||
`K`, `q` or Esc closes it.
|
||||
- Right-click opens a menu of what you can do with the node, each with the
|
||||
keys that do the same; on the empty canvas: a new idea right there (a loose
|
||||
node), zoom in and out. `<Space>c` `<Space>B` `<Space>s` `<Space>t` open
|
||||
the color, border, style and tags pickers, which start on what the node has
|
||||
now. In the tags picker Enter puts a tag on or off and the picker stays
|
||||
open; a search that matches no tag, then Enter, adds it as a new one.
|
||||
- Every pane has a box with its view and document in the top border, the
|
||||
focused one in the accent color. A new pane comes first (left or above)
|
||||
and takes the focus, as in vim, and closing the last pane quits (`q`,
|
||||
`ZZ`, `:q`). A click focuses the pane under it; a text edited in the old
|
||||
one is done first.
|
||||
- The document bar is always at the top, the focused pane's document
|
||||
standing out; a click on a name shows that document in the pane. Each pane
|
||||
can show any open document.
|
||||
- Popups (the help, the menu, the pickers, the card) are a mode of their own:
|
||||
keys and clicks go to them, a click outside a list closes it, and they stay
|
||||
above the status bar.
|
||||
|
||||
**Files and saving**
|
||||
- Documents are RON files ending in `.cum`. Configs are TOML: `config.toml`,
|
||||
@@ -255,12 +303,13 @@ backlinks, and the backlink paths in every document it points to.
|
||||
| **Note** | Writing: one node's title, tags, color and body (with vim motions inside), plus its links in and out and its children. It follows the cursor. |
|
||||
| **Board** (v1.1) | Sorting: columns by tag or color. Moving a card to another column changes its tag. |
|
||||
|
||||
- **Pickers:** Documents in v1, Search in v1.1.
|
||||
- **Pickers:** documents, colors, borders, styles and tags in v1; Search in v1.1.
|
||||
- **Popups:** which-key (bottom right, as in skyshift), `?` help, the `K` card,
|
||||
the right-click menu, confirmations, and the command line with its
|
||||
completion list.
|
||||
- **Top bar:** the open documents. **Bottom bar:** the mode, the view, the
|
||||
breadcrumb (or a message), and the keys typed so far (`2g` above).
|
||||
breadcrumb (or a message), and the keys typed so far (`2g` above); then a
|
||||
line of key hints, which `<Space>w` hides.
|
||||
|
||||
**Mouse** (on the canvas; other views get click, double-click and scroll):
|
||||
click selects, double-click edits the title (from the letter clicked),
|
||||
@@ -281,6 +330,8 @@ enum Mode {
|
||||
Move(Grab), // a node or a branch being moved
|
||||
Connect(Connect), // the source node and the link being styled
|
||||
Command(CommandLine), // `:` with completion
|
||||
Popup(Popup), // the help, the menu, a picker or the K card (which
|
||||
// holds its own TextEdit while the body is edited)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -297,7 +348,11 @@ text).
|
||||
| **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` `P` paste after / before · `]p` paste as child · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo |
|
||||
| **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 |
|
||||
| **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 w` the next pane · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane (the last one quits) |
|
||||
| **Outline** | `j` `k` rows · `h` `l` parent / first child · `zb` every body · `M` then `j` `k` (`J` `K`: 5) swap with siblings, `h` out, `l` under the one above |
|
||||
| **Note view** | `j` `k` parts · Enter `i` `A` edit the part (`I` from its start, `cc` `S` anew), or go to that link or child · `h` `l` the sibling before / after |
|
||||
| **Lists** (help, menu, pickers) | `j` `k` `gg` `G` `Ctrl-d` `Ctrl-u` move · `/` search · Enter pick or run · Esc or `q` close |
|
||||
| **K card** | `j` `k` `Ctrl-d` `Ctrl-u` scroll · `i` `A` Enter edit the body (`I` from its start) · `K` `q` Esc close |
|
||||
| **Typing** | `Backspace` `Delete` · `Ctrl-w` delete word · `Ctrl-u` delete line · arrows, Home, End · Enter (title: done, or after `o O a` the next idea; body: new line) · Tab Shift-Tab (a new idea: under the one above / out a level) · Esc to vim keys |
|
||||
| **Inside text** | `h j k l w b e 0 ^ $ gg G` (and arrows, Home, End) · `f t F T ; ,` · `d` `c` `y` with any motion and `iw aw i" a" i( a(` · `x X s S D C Y` · `r ~ J p P` · `u Ctrl-r .` · `i a I A` back to typing · `o O` (title: a new sibling; body: a new line) · Esc done |
|
||||
| **Move mode** | `hjkl` 1 cell · `HJKL` 5 cells · `M` branch ↔ node · Enter or Esc done |
|
||||
@@ -377,7 +432,7 @@ glyphs = "unicode" # "ascii" for fonts without box-drawing characters
|
||||
completion_popup = "as-you-type" # "on-tab": only after Tab, like vim's wildmenu
|
||||
|
||||
# keys.toml: sections motions, normal, canvas, outline, note, visual, move,
|
||||
# connect, list, text (vim keys inside text), insert (typing: one key each,
|
||||
# connect, list, card, 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>"
|
||||
@@ -437,16 +492,18 @@ cumulus/
|
||||
│ keymap (+ keys.toml), engine (counts, registers, operators), mouse
|
||||
├─ command/ registry, parse, complete, history
|
||||
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render,
|
||||
│ navigate, placement, view), outline, note, board
|
||||
│ navigate, placement, view), outline, note; later board
|
||||
├─ ui/ text_input (a line of text and its cursor), text_editor/ (vim inside text:
|
||||
│ motion, object, layout), which_key, command_line, theme, glyphs; later
|
||||
│ status_bar, document_bar, card, menu, picker, help
|
||||
│ motion, object, layout), list (the popup list help, menus and pickers
|
||||
│ share), card (the K card), status_bar, document_bar, which_key,
|
||||
│ command_line, theme, glyphs
|
||||
├─ config/ paths, settings, keys_file, theme_file (TOML)
|
||||
├─ storage/ document_file (RON), atomic save, backups, lock, state, watch (change polling)
|
||||
└─ app/ action, mode, buffer, commands (keys every view shares), calls (`:` commands),
|
||||
text_edit (a title being edited, rapid entry), registers; later App, panes
|
||||
(split tree), jumps, portals (cache, backlinks, renames), update, render,
|
||||
terminal (setup, teardown, panic hook)
|
||||
text_edit (a node's text being edited, rapid entry), registers, panes (the
|
||||
split tree), popup (help, menu, pickers, card); later App, 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
|
||||
@@ -459,11 +516,19 @@ document actions go to the document's `Buffer` (the only place a document
|
||||
changes: the edit, its undo step, the cursor and the marks), the rest change
|
||||
the mode or the app → the screen is drawn from the state.
|
||||
|
||||
While a title is edited, keys go to the edit (`app::text_edit`) instead: when
|
||||
While a text 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.
|
||||
edit answers with actions like everything else. While a popup is open
|
||||
(`app::popup`), keys and clicks go to it the same way: through the `list` or
|
||||
`card` table, or straight into its search. The help and the menu run what
|
||||
you pick as if its keys were typed (`AppAction::Run`).
|
||||
|
||||
The panes (`app::panes`) are a split tree, apart from what each pane shows:
|
||||
a view and one of the open documents. Only the focused pane is in the mode
|
||||
(the others are drawn as in normal mode), and its document is the one the
|
||||
actions change; a click focuses the pane under it first.
|
||||
|
||||
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, a
|
||||
@@ -478,6 +543,9 @@ trait View { // settled in stage 5
|
||||
fn render(&self, frame: &mut Frame, ctx: &ViewContext); // draws the layout, changes nothing
|
||||
fn command(&mut self, typed: &Typed, ctx: &ViewContext) -> Option<Vec<Action>>; // None: not mine
|
||||
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext) -> Vec<Action>;
|
||||
// Added in stage 7, with defaults:
|
||||
fn status(&self) -> String; // its name in the status bar
|
||||
fn cursor_area(&self, ctx: &ViewContext) -> Option<Rect>; // where the K card goes beside
|
||||
}
|
||||
// ViewContext: the document, the cursor, the mode, the theme and the view settings.
|
||||
```
|
||||
@@ -503,8 +571,8 @@ it.
|
||||
| 4 | Command line: registry, parser, completion (popup + ghost text), history | `:` in the example |
|
||||
| 5 | The shared `View` interface, `Action` and `Mode`, pulled out of stages 2–4 | the example runs through it |
|
||||
| 6 | Text editor: typing, plus vim normal mode inside text | editing titles in the example |
|
||||
| 7 | Outline and Note views; status bar, document bar, help, `K` card, right-click menu, Documents picker | the example with splits |
|
||||
| 8 | App core: main loop, terminal setup and panic hook, buffers, splits, modes, jumps, marks, registers, files (load, autosave, reload, backups, locks, where you left off), config/theme/keys, portals, backlinks and renames, startup rules | `cumulus`: v1 |
|
||||
| 7 | Outline and Note views; splits (the split tree); status bar, document bar, help, `K` card, right-click menu, pickers (documents, colors, borders, styles, tags) | the example with splits and two documents |
|
||||
| 8 | App core: main loop, terminal setup and panic hook, buffers and panes (from the example into the app), visual and connect modes, jumps, marks, registers, files (load, autosave, reload, backups, locks, where you left off), config/theme/keys, portals, backlinks and renames, startup rules | `cumulus`: v1 |
|
||||
| 9 | v1.1: Board, Search picker, `:tidy` and align, branch out, export/import | |
|
||||
|
||||
Each stage ends with `cargo build`, `cargo lint` and `cargo test` passing, and
|
||||
@@ -538,7 +606,13 @@ one commit.
|
||||
- **Canvas:** camera conversions, routing and junctions (small text snapshots),
|
||||
hit-testing, nearest-node moves.
|
||||
- **Text editing:** vim keys inside text driven key by key (motions, operators,
|
||||
text objects, `.`, undo), rapid entry, empty titles, registers.
|
||||
text objects, `.`, undo), rapid entry, empty titles, registers, bodies and
|
||||
tags.
|
||||
- **Views:** the outline and the Note view drawn on a test terminal (rows,
|
||||
parts, drafts being edited), and driven by keys and clicks.
|
||||
- **Panes and popups:** splitting, closing and moving the focus in the split
|
||||
tree; lists (moving, searching, clicks), the help, the menu, the pickers and
|
||||
the card, driven by their key tables.
|
||||
- **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.
|
||||
|
||||
@@ -41,12 +41,16 @@ The plan for the everyday keys (all of them are in DESIGN.md):
|
||||
| `u` `Ctrl-r` | undo / redo |
|
||||
| `za` `zf` `zi` | fold, auto-fold, focus |
|
||||
| `+` `-` | zoom in / out |
|
||||
| `K` | the body in a card beside the node (`i` edits it there) |
|
||||
| `C` `M` `v` | connect, move, select |
|
||||
| `<Space>` | the action menu |
|
||||
| `:` `?` | command line, help |
|
||||
| `<Space>` | the action menu: `c` color, `t` tags, `vo` outline, `vn` note… |
|
||||
| `Ctrl-w v` `Ctrl-w s` | split the pane side by side / stacked (`Ctrl-w hjkl` to move between them) |
|
||||
| `gt` `gT` | the next / previous document |
|
||||
| `:` `?` | command line, help (search it with `/`, Enter runs the key or command) |
|
||||
|
||||
After the first key of a sequence (`g`, `z`, `d`, `<Space>`…), a popup lists
|
||||
what can follow.
|
||||
what can follow. The right mouse button opens a menu of what you can do with
|
||||
a node.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -59,7 +63,7 @@ cargo fmt # format the code
|
||||
While the app is being built, the examples try out each part:
|
||||
|
||||
```sh
|
||||
cargo run --example canvas # the canvas, vim keys, which-key, :commands, editing titles
|
||||
cargo run --example canvas # everything so far: the views, splits, popups, editing
|
||||
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
|
||||
```
|
||||
+490
-569
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,621 @@
|
||||
//! The playground driven by typed keys and mouse gestures on a test
|
||||
//! terminal: what the stages say works, checked end to end.
|
||||
|
||||
use super::*;
|
||||
use cumulus::input::key::parse_keys;
|
||||
use cumulus::input::keymap::DEFAULT_LEADER;
|
||||
use cumulus::model::{Border, CanvasPos, Content, NodeId};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
struct Harness {
|
||||
playground: Playground,
|
||||
terminal: Terminal<TestBackend>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Harness {
|
||||
let terminal = Terminal::new(TestBackend::new(100, 32)).unwrap();
|
||||
let mut harness = Harness { playground: Playground::new(documents().unwrap()), terminal };
|
||||
harness.draw();
|
||||
harness
|
||||
}
|
||||
|
||||
fn draw(&mut self) {
|
||||
let Harness { playground, terminal } = self;
|
||||
terminal.draw(|frame| playground.draw(frame)).unwrap();
|
||||
}
|
||||
|
||||
/// Types keys written in vim notation, drawing after each one as the
|
||||
/// event loop does.
|
||||
fn type_keys(&mut self, keys: &str) {
|
||||
for key in parse_keys(keys, DEFAULT_LEADER).unwrap() {
|
||||
self.playground.key(key);
|
||||
self.draw();
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(&mut self, gesture: Gesture) {
|
||||
self.playground.gesture(gesture);
|
||||
self.draw();
|
||||
}
|
||||
|
||||
fn click(&mut self, (col, row): (u16, u16)) {
|
||||
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, row): (u16, u16)) {
|
||||
let at = Cell { col, row };
|
||||
self.click((col, row));
|
||||
self.mouse(Gesture::DoublePress { at });
|
||||
self.mouse(Gesture::Release { at, dragged: false });
|
||||
}
|
||||
|
||||
fn right_click(&mut self, (col, row): (u16, u16)) {
|
||||
self.mouse(Gesture::RightPress { at: Cell { col, row } });
|
||||
}
|
||||
|
||||
/// The focused pane's document.
|
||||
fn doc(&self) -> &Document {
|
||||
self.playground.state.buffers[self.playground.buffer()].doc()
|
||||
}
|
||||
|
||||
/// The title of the cursor's node.
|
||||
fn cursor(&self) -> String {
|
||||
let buffer = &self.playground.state.buffers[self.playground.buffer()];
|
||||
self.title(buffer.cursor())
|
||||
}
|
||||
|
||||
fn title(&self, id: NodeId) -> String {
|
||||
self.doc().node(id).unwrap().title().to_string()
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
self.playground.state.mode.label()
|
||||
}
|
||||
|
||||
/// The text being edited, as typed so far.
|
||||
fn draft(&self) -> &str {
|
||||
self.playground.state.mode.any_edit().map_or("", |edit| edit.editor.text())
|
||||
}
|
||||
|
||||
/// Where the text cursor is in the text being edited: a byte offset.
|
||||
fn draft_cursor(&self) -> Option<usize> {
|
||||
self.playground.state.mode.any_edit().map(|edit| edit.editor.cursor())
|
||||
}
|
||||
|
||||
fn message(&self) -> &str {
|
||||
self.playground.message.as_ref().map_or("", |message| &message.text)
|
||||
}
|
||||
|
||||
/// The node with this title.
|
||||
fn node(&self, title: &str) -> NodeId {
|
||||
let doc = self.doc();
|
||||
doc.outline().into_iter().find(|&id| doc.node(id).unwrap().title().as_str() == title).unwrap()
|
||||
}
|
||||
|
||||
fn children(&self, title: &str) -> Vec<String> {
|
||||
self.doc().children(self.node(title)).iter().map(|&id| self.title(id)).collect()
|
||||
}
|
||||
|
||||
fn parent(&self, title: &str) -> String {
|
||||
self.title(self.doc().parent(self.node(title)).unwrap())
|
||||
}
|
||||
|
||||
fn pos(&self, title: &str) -> CanvasPos {
|
||||
self.doc().node(self.node(title)).unwrap().pos
|
||||
}
|
||||
|
||||
fn body(&self, title: &str) -> String {
|
||||
match &self.doc().node(self.node(title)).unwrap().content {
|
||||
Content::Idea(idea) => idea.body.as_str().to_string(),
|
||||
Content::Portal(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(&self, title: &str) -> String {
|
||||
self.doc().node(self.node(title)).unwrap().tags.to_string()
|
||||
}
|
||||
|
||||
/// The terminal's cursor, when it shows.
|
||||
fn text_cursor(&self) -> Option<(u16, u16)> {
|
||||
let backend = self.terminal.backend();
|
||||
let at = backend.cursor_position();
|
||||
backend.cursor_visible().then_some((at.x, at.y))
|
||||
}
|
||||
|
||||
/// What's on screen, row by row.
|
||||
fn screen(&self) -> String {
|
||||
self.rows().join("\n")
|
||||
}
|
||||
|
||||
fn rows(&self) -> Vec<String> {
|
||||
let buffer = self.terminal.backend().buffer();
|
||||
let rows = buffer.content().chunks(usize::from(buffer.area.width));
|
||||
rows.map(|row| row.iter().map(|cell| cell.symbol()).collect()).collect()
|
||||
}
|
||||
|
||||
/// The cell where `text` first shows in the panes (between the document
|
||||
/// bar and the status bar).
|
||||
fn find(&self, text: &str) -> (u16, u16) {
|
||||
let rows = self.rows();
|
||||
let last = rows.len() - 3;
|
||||
self.find_in(text, 1..=last).unwrap_or_else(|| panic!("{text:?} isn't on screen:\n{}", self.screen()))
|
||||
}
|
||||
|
||||
fn find_in(&self, text: &str, rows: std::ops::RangeInclusive<usize>) -> Option<(u16, u16)> {
|
||||
let screen = self.rows();
|
||||
rows.into_iter().find_map(|row| {
|
||||
let at = screen[row].find(text)?;
|
||||
let col = screen[row][..at].chars().count();
|
||||
Some((u16::try_from(col).ok()?, u16::try_from(row).ok()?))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ----- The canvas and editing (stages 2 to 6) -----------------------------
|
||||
|
||||
#[test]
|
||||
fn marks_counts_and_undo() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glma");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
assert_eq!(h.message(), "mark a is on \"Soil\": 'a comes back here");
|
||||
h.type_keys("gg'a");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
h.type_keys("''");
|
||||
assert_eq!(h.cursor(), "Garden plans", "'' goes back to before the last jump");
|
||||
h.type_keys("'a2dd");
|
||||
assert_eq!(h.message(), "deleted 4 nodes · u to undo", "Soil, and Raised beds with its two");
|
||||
assert_eq!(h.cursor(), "Watering");
|
||||
assert!(!h.screen().contains("Raised beds"));
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
assert!(h.screen().contains("Raised beds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_mode_moves_the_branch_as_one_undo_step() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glgjM");
|
||||
assert_eq!(h.cursor(), "Raised beds");
|
||||
assert_eq!(h.label(), "MOVE");
|
||||
h.type_keys("3lJ<CR>");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
assert_eq!(h.pos("Raised beds"), CanvasPos::new(25, 12), "3 right, 5 down");
|
||||
assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(46, 10), "its branch came along");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7));
|
||||
assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(43, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_node_moves_its_branch() {
|
||||
let mut h = Harness::new();
|
||||
let (col, row) = h.find("Soil");
|
||||
let (start, end) = (Cell { col, row }, Cell { col: col + 4, row: row + 2 });
|
||||
h.mouse(Gesture::Press { at: start, ctrl: false });
|
||||
assert_eq!(h.cursor(), "Soil", "the press selects");
|
||||
h.mouse(Gesture::Drag { from: start, at: end, ctrl: false });
|
||||
h.mouse(Gesture::Release { at: end, dragged: true });
|
||||
assert_eq!(h.pos("Soil"), CanvasPos::new(26, 4));
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.pos("Soil"), CanvasPos::new(22, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_lines_run_through_the_same_actions() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys(":color teal<CR>");
|
||||
let root = h.doc().node(h.node("Garden plans")).unwrap();
|
||||
assert_eq!(root.look.color.as_ref().map(|color| color.as_str()), Some("teal"));
|
||||
h.type_keys(":in mulch<CR>");
|
||||
assert_eq!(h.message(), "captured \"mulch\" to the Inbox");
|
||||
assert_eq!(h.parent("mulch"), "Inbox");
|
||||
assert_eq!(h.cursor(), "Garden plans", "capturing leaves the cursor where it was");
|
||||
h.type_keys(":find drip<CR>");
|
||||
assert_eq!(h.cursor(), "Drip line");
|
||||
assert_eq!(h.message(), "\"drip\": match 1 of 1");
|
||||
h.type_keys(":set completion_popup=on-tab<CR>");
|
||||
assert_eq!(h.playground.completion, PopupMode::OnTab);
|
||||
h.type_keys(":frobnicate<CR>");
|
||||
assert_eq!(h.playground.message.as_ref().map(|message| message.tone), Some(Tone::Problem));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_folding_zooming_and_quitting() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("aMulch<Esc><Esc>");
|
||||
assert_eq!(h.cursor(), "Mulch");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.cursor(), "Garden plans", "undo goes back to where the change was");
|
||||
h.type_keys("glgjza");
|
||||
assert_eq!(h.cursor(), "Raised beds");
|
||||
assert!(h.screen().contains("+2"), "a folded branch shows how many nodes it hides");
|
||||
assert!(!h.screen().contains("40 cm high"));
|
||||
h.type_keys("-");
|
||||
assert!(h.screen().contains("canvas · far"), "the status bar shows the zoom");
|
||||
h.type_keys("q");
|
||||
assert!(h.playground.quit, "q closes the last pane: that quits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_title_in_its_box() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gli mix");
|
||||
assert_eq!(h.label(), "INSERT");
|
||||
let (col, row) = h.find("Soil mix");
|
||||
assert_eq!(h.text_cursor(), Some((col + 8, row)), "after the last letter");
|
||||
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBar);
|
||||
h.type_keys("<Esc>");
|
||||
assert_eq!(h.label(), "TEXT");
|
||||
let (col, row) = h.find("Soil mix");
|
||||
assert_eq!(h.text_cursor(), Some((col + 7, row)), "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(), "");
|
||||
}
|
||||
|
||||
#[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 (col, row) = h.find("Watering");
|
||||
h.double_click((col + 3, row));
|
||||
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.find("Wat-ering");
|
||||
h.click(at);
|
||||
h.type_keys("~");
|
||||
assert_eq!(h.draft(), "~Wat-ering");
|
||||
// A click anywhere else keeps the edit, finishes it, and selects.
|
||||
let soil = h.find("Soil");
|
||||
h.click(soil);
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
assert_eq!(h.title(watering), "~Wat-ering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indenting_and_outdenting_nodes() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glgj>>");
|
||||
assert_eq!(h.cursor(), "Raised beds");
|
||||
assert_eq!(h.parent("Raised beds"), "Soil", ">>: under the node above");
|
||||
h.type_keys("<<");
|
||||
assert_eq!(h.children("Garden plans"), ["Soil", "Raised beds", "Watering", "Kitchen"], "<<: back out");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.parent("Raised beds"), "Soil");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.parent("Raised beds"), "Garden plans");
|
||||
assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7), "back where it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_later_stages_say_so() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("yy");
|
||||
assert_eq!(h.message(), "\"yank (copy)…\" comes in a later stage of the build");
|
||||
h.type_keys("v");
|
||||
assert_eq!(h.message(), "\"select\" comes in a later stage of the build");
|
||||
// Kitchen is a portal: its title is the other document's name.
|
||||
h.type_keys("glgjgjgj");
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
h.type_keys("i");
|
||||
assert_eq!(h.message(), "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");
|
||||
}
|
||||
|
||||
// ----- Views, panes, popups and documents (stage 7) -----------------------
|
||||
|
||||
#[test]
|
||||
fn panes_split_and_share_the_cursor() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("<C-w>v<Space>vo");
|
||||
assert_eq!(h.playground.panes.count(), 2);
|
||||
assert!(h.screen().contains("outline · Garden plans"), "the new pane shows the outline:\n{}", h.screen());
|
||||
assert!(h.screen().contains("▾ Garden plans"));
|
||||
h.type_keys("j");
|
||||
assert_eq!(h.cursor(), "Soil", "j in the outline: the next row");
|
||||
h.type_keys("<C-w>l");
|
||||
assert_eq!(h.playground.panes.current().view.kind(), ViewKind::Canvas);
|
||||
assert_eq!(h.cursor(), "Soil", "panes on one document share its cursor");
|
||||
h.type_keys(":vsplit note<CR>");
|
||||
assert_eq!(h.playground.panes.count(), 3);
|
||||
assert!(h.screen().contains("note · Garden plans"));
|
||||
h.type_keys("<C-w>o");
|
||||
assert_eq!(h.playground.panes.count(), 1, "only this pane");
|
||||
h.type_keys("<C-w>s");
|
||||
assert_eq!(h.playground.panes.count(), 2);
|
||||
h.type_keys("<C-w>c");
|
||||
assert_eq!(h.playground.panes.count(), 1);
|
||||
h.type_keys("<C-w>j");
|
||||
assert_eq!(h.message(), "no pane that way");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outline_edits_reorders_and_reads() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("<Space>vo");
|
||||
assert!(h.screen().contains("▾ Garden plans"));
|
||||
h.type_keys("jA!<Esc><Esc>");
|
||||
assert_eq!(h.cursor(), "Soil!");
|
||||
assert!(h.screen().contains("├ Soil!"));
|
||||
// Move mode in the outline: j swaps the node with the sibling below.
|
||||
h.type_keys("Mj<CR>");
|
||||
assert_eq!(h.children("Garden plans")[..2], ["Raised beds", "Soil!"]);
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.children("Garden plans")[0], "Soil!");
|
||||
// zb: every body under its title.
|
||||
assert!(!h.screen().contains("The neighbor has a bin."));
|
||||
h.type_keys("zb");
|
||||
assert!(h.screen().contains("The neighbor has a bin."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_note_view_edits_its_parts() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("<Space>vngl");
|
||||
assert!(h.screen().contains("note · Garden plans"));
|
||||
assert!(h.screen().contains("#todo"), "Soil's tags");
|
||||
// j j: the body; Enter: write it.
|
||||
h.type_keys("jj<CR>Loamy, with compost.<CR>Test the pH.<Esc><Esc>");
|
||||
assert_eq!(h.body("Soil"), "Loamy, with compost.\nTest the pH.");
|
||||
assert!(h.screen().contains("Test the pH."));
|
||||
// k: the tags; Enter: add one.
|
||||
h.type_keys("k<CR> #spring<CR>");
|
||||
assert_eq!(h.tags("Soil"), "#spring #todo");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.tags("Soil"), "#todo");
|
||||
// On Raised beds, the children are parts too: Enter goes there.
|
||||
h.type_keys("gjjjj<CR>");
|
||||
assert_eq!(h.cursor(), "Cedar, untreated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_finds_and_runs_keys_and_commands() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("?");
|
||||
assert_eq!(h.label(), "HELP");
|
||||
assert!(h.screen().contains("help · canvas"));
|
||||
h.type_keys("/zoom out<CR>");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
assert!(h.screen().contains("canvas · far"), "the help ran it");
|
||||
h.type_keys("?/:color<CR>teal<CR>");
|
||||
let root = h.doc().node(h.node("Garden plans")).unwrap();
|
||||
assert_eq!(
|
||||
root.look.color.as_ref().map(|color| color.as_str()),
|
||||
Some("teal"),
|
||||
":color waited for the color"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_card_shows_and_edits_the_body() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys(":find compost<CR>K");
|
||||
assert_eq!(h.label(), "CARD");
|
||||
assert!(h.screen().contains("The neighbor has a bin."));
|
||||
h.type_keys("i");
|
||||
assert_eq!(h.label(), "INSERT");
|
||||
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBar);
|
||||
h.type_keys("<CR>Gloves too.<Esc><Esc>");
|
||||
assert_eq!(h.label(), "CARD", "done editing: the card stays");
|
||||
assert_eq!(h.body("ask about compost"), "The neighbor has a bin.\nBring a bucket.\nGloves too.");
|
||||
assert!(h.screen().contains("Gloves too."));
|
||||
h.type_keys("K");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.body("ask about compost"), "The neighbor has a bin.\nBring a bucket.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_on_a_node_and_on_the_empty_canvas() {
|
||||
let mut h = Harness::new();
|
||||
let soil = h.find("Soil");
|
||||
h.right_click(soil);
|
||||
assert_eq!((h.label(), h.cursor().as_str()), ("MENU", "Soil"));
|
||||
assert!(h.screen().contains("add a child"), "{}", h.screen());
|
||||
h.type_keys("/color<CR>");
|
||||
assert_eq!(h.label(), "PICK", "color… opens the color picker");
|
||||
h.type_keys("/teal<CR>");
|
||||
let node = h.doc().node(h.node("Soil")).unwrap();
|
||||
assert_eq!(node.look.color.as_ref().map(|color| color.as_str()), Some("teal"));
|
||||
// On the empty canvas: a new idea right there.
|
||||
h.right_click((95, 10));
|
||||
h.type_keys("<CR>Mulch<Esc><Esc>");
|
||||
let mulch = h.node("Mulch");
|
||||
assert!(h.doc().loose().contains(&mulch), "a loose idea");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pickers_and_documents() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gl<Space>B/dashed<CR>");
|
||||
assert_eq!(h.doc().node(h.node("Soil")).unwrap().look.border, Some(Border::Dashed));
|
||||
// #todo is the only tag: Enter takes it off; the picker stays open.
|
||||
h.type_keys("<Space>t<CR>");
|
||||
assert_eq!((h.label(), h.tags("Soil").as_str()), ("PICK", ""));
|
||||
h.type_keys("<Esc>");
|
||||
// The documents: gt, the picker, :b, and a click on the bar.
|
||||
h.type_keys("gt");
|
||||
assert!(h.screen().contains("canvas · Kitchen"));
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
h.type_keys("<Space>bk<CR>");
|
||||
assert_eq!(h.cursor(), "Soil", "back in the garden, where the cursor was");
|
||||
h.type_keys(":b kitchen<CR>");
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
let garden = h.find_in("Garden plans", 0..=0).unwrap();
|
||||
h.click(garden);
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_keys_across_views() {
|
||||
let mut h = Harness::new();
|
||||
// Rapid entry in the outline: o adds a row, Enter the next one.
|
||||
h.type_keys("<Space>vojoMulch<CR>Straw<Esc><Esc>");
|
||||
assert_eq!(h.children("Garden plans")[..3], ["Soil", "Mulch", "Straw"]);
|
||||
assert!(h.screen().contains("├ Straw"));
|
||||
// za folds in the outline as anywhere.
|
||||
h.type_keys("ggza");
|
||||
assert!(h.screen().contains("▸ Garden plans"), "{}", h.screen());
|
||||
h.type_keys("za");
|
||||
// The Note view: h and l turn the page, K opens the card.
|
||||
h.type_keys("<Space>vngll");
|
||||
assert_eq!(h.cursor(), "Mulch", "l: the sibling after Soil");
|
||||
h.type_keys("h");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
h.type_keys("K");
|
||||
assert_eq!(h.label(), "CARD");
|
||||
h.type_keys("<Esc>");
|
||||
// Ctrl-w w goes round the panes; Ctrl-w = evens them out.
|
||||
h.type_keys("<C-w>v");
|
||||
let first = h.playground.panes.focused();
|
||||
h.type_keys("<C-w>w");
|
||||
assert_ne!(h.playground.panes.focused(), first);
|
||||
h.type_keys("<C-w>=<C-w>o");
|
||||
// gT: the document before, round to the last.
|
||||
h.type_keys("gT");
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
h.type_keys("gT");
|
||||
// :help with a topic opens it searched.
|
||||
h.type_keys(":help fold<CR>");
|
||||
assert_eq!(h.label(), "HELP");
|
||||
assert!(h.screen().contains("/fold"));
|
||||
h.type_keys("<Esc><Esc>");
|
||||
// <Space>s: this theme has no named styles.
|
||||
h.type_keys("<Space>s");
|
||||
assert_eq!(h.message(), "this theme has no named styles yet: theme files come in stage 8");
|
||||
// <Space>w: no key hints: which-key and the hint line go.
|
||||
assert!(h.rows().last().unwrap().contains("? help"));
|
||||
h.type_keys("<Space>w");
|
||||
assert_eq!(h.message(), "key hints off");
|
||||
assert!(!h.screen().contains("? help"), "{}", h.screen());
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// cumulus document. Hand edits are fine: cumulus reloads it when it changes.
|
||||
(
|
||||
format: 1,
|
||||
uid: "c01d5e7aa0f3b912",
|
||||
root: 1,
|
||||
nodes: [
|
||||
(
|
||||
id: 1,
|
||||
title: "Kitchen",
|
||||
pos: (0, 6),
|
||||
color: "yellow",
|
||||
),
|
||||
(
|
||||
id: 2,
|
||||
parent: 1,
|
||||
title: "Herbs on the sill",
|
||||
pos: (16, 2),
|
||||
color: "green",
|
||||
),
|
||||
(
|
||||
id: 3,
|
||||
parent: 2,
|
||||
title: "Basil",
|
||||
pos: (40, 1),
|
||||
),
|
||||
(
|
||||
id: 4,
|
||||
parent: 2,
|
||||
title: "Mint, in its own pot",
|
||||
pos: (40, 4),
|
||||
body: "It spreads everywhere if it can.",
|
||||
),
|
||||
(
|
||||
id: 5,
|
||||
parent: 1,
|
||||
title: "Compost bin",
|
||||
pos: (16, 9),
|
||||
tags: "#todo",
|
||||
body: "Under the sink.
|
||||
Empty it on Sundays.",
|
||||
),
|
||||
],
|
||||
)
|
||||
+63
-3
@@ -5,9 +5,14 @@
|
||||
//!
|
||||
//! [`Buffer::apply`]: super::buffer::Buffer::apply
|
||||
|
||||
use super::panes::Arrange;
|
||||
use super::popup::{MenuFor, PickKind};
|
||||
use super::text_edit::StartEdit;
|
||||
use crate::command::parse::SetArg;
|
||||
use crate::model::{EditOp, Group, NodeId};
|
||||
use crate::input::command::Command;
|
||||
use crate::input::key::Key;
|
||||
use crate::model::{Direction, EditOp, Group, NodeId};
|
||||
use crate::view::ViewKind;
|
||||
|
||||
/// How a status-bar message is meant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -78,9 +83,12 @@ pub enum DocAction {
|
||||
/// What happens around the documents.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppAction {
|
||||
/// Start editing the cursor's title.
|
||||
/// Start editing the cursor's title, tags or body.
|
||||
StartEdit(StartEdit),
|
||||
/// Put the text cursor here, in the title being edited (a click).
|
||||
/// The text edit is done: back to normal mode, or to the `K` card the
|
||||
/// body was edited in.
|
||||
EndEdit,
|
||||
/// Put the text cursor here, in the text being edited (a click).
|
||||
TextCursor(usize),
|
||||
/// Move mode: hjkl moves the cursor's branch.
|
||||
EnterMoveMode,
|
||||
@@ -89,15 +97,67 @@ pub enum AppAction {
|
||||
/// Back to normal mode.
|
||||
LeaveMode,
|
||||
OpenCommandLine,
|
||||
/// The command line, with this typed already: a `:` command from the
|
||||
/// help that needs more.
|
||||
CommandLineWith(String),
|
||||
/// A command line, run as if typed (without its `:`).
|
||||
RunLine(String),
|
||||
/// A command, run as if its keys were typed: the help and the menu run
|
||||
/// commands this way.
|
||||
Run(Command),
|
||||
/// Keys, as if typed: for a key that waits for more (`m` waits for a
|
||||
/// mark's letter).
|
||||
Type(Vec<Key>),
|
||||
/// Focus mode: Open branches act like Auto ones.
|
||||
ToggleFocus,
|
||||
ToggleWhichKey,
|
||||
/// Change a setting.
|
||||
Set(SetArg),
|
||||
|
||||
// Popups.
|
||||
/// Every key and command, with this already searched for.
|
||||
OpenHelp(String),
|
||||
/// The cursor's body in a card beside it.
|
||||
OpenCard,
|
||||
OpenPicker(PickKind),
|
||||
/// The right-click menu, at a cell on the screen.
|
||||
OpenMenu {
|
||||
what: MenuFor,
|
||||
col: u16,
|
||||
row: u16,
|
||||
},
|
||||
|
||||
// Panes and documents.
|
||||
/// Split the focused pane: the new pane shows `view`, or the same view.
|
||||
Split {
|
||||
arrange: Arrange,
|
||||
view: Option<ViewKind>,
|
||||
},
|
||||
FocusPane(Direction),
|
||||
NextPane,
|
||||
/// Close the focused pane (the last one quits).
|
||||
ClosePane,
|
||||
OnlyPane,
|
||||
EqualizePanes,
|
||||
/// The focused pane shows another view.
|
||||
ShowView(ViewKind),
|
||||
SwitchDocument(DocumentChoice),
|
||||
|
||||
Quit,
|
||||
Say(Message),
|
||||
}
|
||||
|
||||
/// Which document the focused pane switches to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DocumentChoice {
|
||||
Next,
|
||||
Previous,
|
||||
/// The open document listed at this place (the documents picker).
|
||||
At(usize),
|
||||
/// The open document with this name (`:b`).
|
||||
Named(String),
|
||||
}
|
||||
|
||||
/// Anything views, keys and the command line ask for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
|
||||
+23
-14
@@ -1,12 +1,14 @@
|
||||
//! What `:` commands do: [`actions_for`] turns a parsed [`Call`] into
|
||||
//! actions, the way views turn keys into actions.
|
||||
|
||||
use super::action::{Action, AppAction, CursorAfter, Message};
|
||||
use super::action::{Action, AppAction, CursorAfter, DocumentChoice, Message};
|
||||
use super::commands::set_fold;
|
||||
use super::panes::Arrange;
|
||||
use super::popup::PickKind;
|
||||
use crate::command::parse::{Call, TagChange};
|
||||
use crate::model::{CanvasPos, CanvasRect, EditOp, Inbox, Link, LinkKey, Node, NodeId, Title};
|
||||
use crate::view::ViewContext;
|
||||
use crate::view::canvas::placement::{child_spot, first_child_spot, free_spot};
|
||||
use crate::view::{ViewContext, ViewKind};
|
||||
|
||||
/// The title of the loose node `:in` collects ideas under.
|
||||
const INBOX: &str = "Inbox";
|
||||
@@ -16,9 +18,18 @@ const INBOX: &str = "Inbox";
|
||||
pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let id = ctx.cursor;
|
||||
match call {
|
||||
Call::Quit { .. } | Call::QuitAll { .. } | Call::WriteQuit | Call::ClosePane => {
|
||||
vec![Action::App(AppAction::Quit)]
|
||||
}
|
||||
// :q closes the pane, as in vim: the last one quits.
|
||||
Call::Quit { .. } | Call::WriteQuit | Call::ClosePane => app(AppAction::ClosePane),
|
||||
Call::QuitAll { .. } => app(AppAction::Quit),
|
||||
Call::Help(topic) => app(AppAction::OpenHelp(topic.unwrap_or_default())),
|
||||
Call::Split(view) => app(AppAction::Split { arrange: Arrange::Stacked, view }),
|
||||
Call::VSplit(view) => app(AppAction::Split { arrange: Arrange::SideBySide, view }),
|
||||
Call::Only => app(AppAction::OnlyPane),
|
||||
Call::Show(ViewKind::Board) | Call::Board(_) => later("the board comes in v1.1"),
|
||||
Call::Show(view) => app(AppAction::ShowView(view)),
|
||||
Call::Buffers => app(AppAction::OpenPicker(PickKind::Document)),
|
||||
Call::Buffer(name) => app(AppAction::SwitchDocument(DocumentChoice::Named(name))),
|
||||
Call::Style(style) => vec![Action::edit(EditOp::SetStyle { id, style: Some(style) })],
|
||||
Call::Fold(fold) => set_fold(ctx, fold),
|
||||
Call::Focus => vec![Action::App(AppAction::ToggleFocus)],
|
||||
Call::Color(color) => vec![Action::edit(EditOp::SetColor { id, color })],
|
||||
@@ -44,29 +55,27 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
Call::Find(text) => find(ctx, &text),
|
||||
Call::Set(arg) => vec![Action::App(AppAction::Set(arg))],
|
||||
Call::Version => say(Message::info(crate::version())),
|
||||
Call::Help(_) => later("the help screen comes in stage 7; Tab completes any command"),
|
||||
Call::Split(_) | Call::VSplit(_) | Call::Only | Call::Show(_) => {
|
||||
later("splits and the other views come in stage 7")
|
||||
}
|
||||
Call::Edit { .. }
|
||||
| Call::New(_)
|
||||
| Call::Write
|
||||
| Call::WriteAll
|
||||
| Call::Buffers
|
||||
| Call::Buffer(_)
|
||||
| Call::CloseDocument { .. }
|
||||
| Call::Portal(_)
|
||||
| Call::Backlinks => later("documents, files and portals come in stage 8"),
|
||||
| Call::Backlinks => later("files and portals come in stage 8"),
|
||||
Call::Jumps | Call::Marks | Call::Registers => {
|
||||
later("the lists of jumps, marks and registers come in stage 8")
|
||||
}
|
||||
Call::Style(_) | Call::Theme(_) | Call::Config => later("theme and config files come in stage 8"),
|
||||
Call::Board(_) | Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
|
||||
Call::Theme(_) | Call::Config => later("theme and config files come in stage 8"),
|
||||
Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
|
||||
later("this one comes in v1.1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn app(action: AppAction) -> Vec<Action> {
|
||||
vec![Action::App(action)]
|
||||
}
|
||||
|
||||
fn say(message: Message) -> Vec<Action> {
|
||||
vec![Action::say(message)]
|
||||
}
|
||||
|
||||
+93
-11
@@ -7,15 +7,19 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message};
|
||||
use super::mode::Grab;
|
||||
use super::panes::Arrange;
|
||||
use super::popup::PickKind;
|
||||
use super::text_edit::{EditAt, StartEdit};
|
||||
use crate::input::command::{Command, Motion, Operator, Target};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::model::{
|
||||
CanvasPos, CanvasRect, CanvasSize, Document, EditOp, Fold, Group, Node, NodeId, Spot, Title, Visible,
|
||||
CanvasPos, CanvasRect, CanvasSize, Direction, Document, EditOp, Fold, Group, Node, NodeId, Spot, Title,
|
||||
Visible,
|
||||
};
|
||||
use crate::view::ViewContext;
|
||||
use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, spot_below};
|
||||
use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, free_spot, spot_below};
|
||||
use crate::view::{ViewContext, ViewKind};
|
||||
|
||||
/// The actions for a command every view shares, or `None` when it isn't
|
||||
/// one.
|
||||
@@ -52,7 +56,32 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
Command::CommandLine => app(AppAction::OpenCommandLine),
|
||||
Command::ToggleFocus => app(AppAction::ToggleFocus),
|
||||
Command::ToggleWhichKey => app(AppAction::ToggleWhichKey),
|
||||
Command::Close => app(AppAction::Quit),
|
||||
// Popups.
|
||||
Command::Help => app(AppAction::OpenHelp(String::new())),
|
||||
Command::ShowBody => app(AppAction::OpenCard),
|
||||
Command::PickColor => app(AppAction::OpenPicker(PickKind::Color)),
|
||||
Command::PickBorder => app(AppAction::OpenPicker(PickKind::Border)),
|
||||
Command::PickStyle => app(AppAction::OpenPicker(PickKind::Style)),
|
||||
Command::EditTags => app(AppAction::OpenPicker(PickKind::Tags)),
|
||||
Command::PickDocument => app(AppAction::OpenPicker(PickKind::Document)),
|
||||
// Panes and documents.
|
||||
Command::SplitVertical => app(AppAction::Split { arrange: Arrange::SideBySide, view: None }),
|
||||
Command::SplitHorizontal => app(AppAction::Split { arrange: Arrange::Stacked, view: None }),
|
||||
Command::FocusLeft => app(AppAction::FocusPane(Direction::Left)),
|
||||
Command::FocusDown => app(AppAction::FocusPane(Direction::Down)),
|
||||
Command::FocusUp => app(AppAction::FocusPane(Direction::Up)),
|
||||
Command::FocusRight => app(AppAction::FocusPane(Direction::Right)),
|
||||
Command::NextPane => app(AppAction::NextPane),
|
||||
Command::ClosePane => app(AppAction::ClosePane),
|
||||
Command::OnlyPane => app(AppAction::OnlyPane),
|
||||
Command::EqualizePanes => app(AppAction::EqualizePanes),
|
||||
Command::ShowCanvas => app(AppAction::ShowView(ViewKind::Canvas)),
|
||||
Command::ShowOutline => app(AppAction::ShowView(ViewKind::Outline)),
|
||||
Command::ShowNote => app(AppAction::ShowView(ViewKind::Note)),
|
||||
Command::NextDocument => app(AppAction::SwitchDocument(DocumentChoice::Next)),
|
||||
Command::PreviousDocument => app(AppAction::SwitchDocument(DocumentChoice::Previous)),
|
||||
// ZZ closes the pane, as in vim: the last one quits.
|
||||
Command::Close => app(AppAction::ClosePane),
|
||||
// Esc always gets back to normal mode.
|
||||
Command::Escape => app(AppAction::LeaveMode),
|
||||
_ => return None,
|
||||
@@ -76,20 +105,36 @@ pub fn set_fold(ctx: &ViewContext<'_>, fold: Fold) -> Vec<Action> {
|
||||
/// 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 Some(node) = new_idea() else { return Vec::new() };
|
||||
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),
|
||||
};
|
||||
start_new(node, pos, spot)
|
||||
}
|
||||
|
||||
/// "New idea here", from the menu on the empty canvas: a loose idea at that
|
||||
/// spot (or the nearest free one), and typing its title.
|
||||
pub fn add_loose(ctx: &ViewContext<'_>, at: CanvasPos) -> Vec<Action> {
|
||||
let Some(node) = new_idea() else { return Vec::new() };
|
||||
let pos = free_spot(at, ctx.box_size(&node), &ctx.box_rects());
|
||||
start_new(node, pos, Spot::Loose)
|
||||
}
|
||||
|
||||
/// A new idea. Until something is typed, it holds a placeholder (a title
|
||||
/// can't be empty).
|
||||
fn new_idea() -> Option<Node> {
|
||||
Title::parse("new idea").ok().map(|title| Node::idea(title, CanvasPos::default()))
|
||||
}
|
||||
|
||||
/// Adds a new node at `pos` and starts typing its title, as one undo step.
|
||||
fn start_new(node: Node, pos: CanvasPos, spot: Spot) -> Vec<Action> {
|
||||
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) })),
|
||||
Action::App(AppAction::StartEdit(StartEdit::Title { at: EditAt::End, new_node: Some(group) })),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -145,6 +190,43 @@ pub fn outdent(ctx: &ViewContext<'_>, ids: &[NodeId], group: Option<Group>) -> V
|
||||
actions
|
||||
}
|
||||
|
||||
/// Move mode in the outline and the Note view: the cursor's node moves
|
||||
/// through the tree rather than across the canvas, with its branch. `j` and
|
||||
/// `k` swap it with its siblings (`J` and `K` five places), `h` takes it out
|
||||
/// a level and `l` puts it under the one above. Every move until the mode
|
||||
/// ends is one undo step.
|
||||
pub fn reorder(typed: &Typed, grab: Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
let (doc, id) = (ctx.doc, ctx.cursor);
|
||||
let steps = match typed.command {
|
||||
Command::FarDown | Command::FarUp => FAR,
|
||||
_ => typed.times(),
|
||||
};
|
||||
// `steps` siblings that way, or as far as the row goes.
|
||||
let walk = |next: fn(&Document, NodeId) -> Option<NodeId>| {
|
||||
let steps = usize::try_from(steps).unwrap_or(1);
|
||||
std::iter::successors(Some(id), |&at| next(doc, at)).take(steps + 1).last().unwrap_or(id)
|
||||
};
|
||||
let actions = match typed.command {
|
||||
Command::Move(Motion::Down) | Command::FarDown => match walk(Document::next_sibling) {
|
||||
target if target == id => say("it's the last of its siblings already"),
|
||||
target => vec![Action::edit_in(EditOp::MoveTo { id, spot: Spot::After(target) }, grab.group)],
|
||||
},
|
||||
Command::Move(Motion::Up) | Command::FarUp => match walk(Document::previous_sibling) {
|
||||
target if target == id => say("it's the first of its siblings already"),
|
||||
target => vec![Action::edit_in(EditOp::MoveTo { id, spot: Spot::Before(target) }, grab.group)],
|
||||
},
|
||||
Command::Move(Motion::Left) | Command::FarLeft => outdent(ctx, &[id], Some(grab.group)),
|
||||
Command::Move(Motion::Right) | Command::FarRight => indent(ctx, &[id], Some(grab.group)),
|
||||
Command::ToggleWhole => say("here a node always moves with its branch"),
|
||||
Command::Confirm => app(AppAction::LeaveMode),
|
||||
_ => return None,
|
||||
};
|
||||
Some(actions)
|
||||
}
|
||||
|
||||
/// How many places `J` and `K` move a node in the outline's move mode.
|
||||
const FAR: u32 = 5;
|
||||
|
||||
/// 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(
|
||||
@@ -172,7 +254,7 @@ fn siblings(ctx: &ViewContext<'_>, count: u32) -> Vec<NodeId> {
|
||||
}
|
||||
|
||||
fn edit(at: EditAt) -> Vec<Action> {
|
||||
app(AppAction::StartEdit(StartEdit { at, new_node: None }))
|
||||
app(AppAction::StartEdit(StartEdit::title(at)))
|
||||
}
|
||||
|
||||
fn app(action: AppAction) -> Vec<Action> {
|
||||
|
||||
+9
-4
@@ -4,18 +4,23 @@
|
||||
//! 1. A key goes through the key engine and comes out as a command; the
|
||||
//! focused view answers it with [`action`]s, and [`commands::shared`]
|
||||
//! answers what the view leaves. A `:` command line is answered by
|
||||
//! [`calls::actions_for`] instead, and keys while a title is edited by
|
||||
//! its [`text_edit`].
|
||||
//! [`calls::actions_for`] instead, keys while a text is edited by its
|
||||
//! [`text_edit`], and keys while a popup is open by the [`popup`].
|
||||
//! 2. Document actions go to the [`buffer`] holding the document: the one
|
||||
//! place a document changes. The rest change the [`mode`] or the app.
|
||||
//!
|
||||
//! So far this is what stages 2 to 6 needed; stage 8 adds the main loop,
|
||||
//! panes, files and settings.
|
||||
//! The screen is split into [`panes`], each showing a document through a
|
||||
//! view.
|
||||
//!
|
||||
//! So far this is what stages 2 to 7 needed; stage 8 adds the main loop,
|
||||
//! files and settings.
|
||||
|
||||
pub mod action;
|
||||
pub mod buffer;
|
||||
pub mod calls;
|
||||
pub mod commands;
|
||||
pub mod mode;
|
||||
pub mod panes;
|
||||
pub mod popup;
|
||||
pub mod registers;
|
||||
pub mod text_edit;
|
||||
+38
-3
@@ -2,7 +2,8 @@
|
||||
//! what it needs, so a mode's state can't outlive it (there's no "move mode
|
||||
//! flag" to forget to clear).
|
||||
|
||||
use super::text_edit::TextEdit;
|
||||
use super::popup::Popup;
|
||||
use super::text_edit::{Field, TextEdit};
|
||||
use crate::model::Group;
|
||||
use crate::ui::command_line::CommandLine;
|
||||
|
||||
@@ -12,13 +13,16 @@ pub enum Mode {
|
||||
/// Moving around and acting on nodes.
|
||||
#[default]
|
||||
Normal,
|
||||
/// Editing a title: typing, or vim keys inside the text (the editor
|
||||
/// knows which, so the two can't disagree).
|
||||
/// Editing a node's title, tags or body in a view: typing, or vim keys
|
||||
/// inside the text (the editor knows which, so the two can't disagree).
|
||||
Text(TextEdit),
|
||||
/// Moving the cursor's node (or its branch) with hjkl.
|
||||
Move(Grab),
|
||||
/// Typing a `:` command.
|
||||
Command(CommandLine),
|
||||
/// A popup has the keys: the help, the menu, a picker or the `K` card
|
||||
/// (which can be editing the body, in itself).
|
||||
Popup(Popup),
|
||||
}
|
||||
|
||||
/// Move mode's state.
|
||||
@@ -44,6 +48,36 @@ impl Default for Grab {
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The text being edited in a view, if any (the card draws its own).
|
||||
pub fn edit(&self) -> Option<&TextEdit> {
|
||||
match self {
|
||||
Mode::Text(edit) => Some(edit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The title being edited, if any: views draw it in place, as typed.
|
||||
pub fn title_edit(&self) -> Option<&TextEdit> {
|
||||
self.edit().filter(|edit| edit.field == Field::Title)
|
||||
}
|
||||
|
||||
/// The text being edited anywhere: in a view, or in the `K` card.
|
||||
pub fn any_edit(&self) -> Option<&TextEdit> {
|
||||
match self {
|
||||
Mode::Text(edit) => Some(edit),
|
||||
Mode::Popup(popup) => popup.edit(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn edit_mut(&mut self) -> Option<&mut TextEdit> {
|
||||
match self {
|
||||
Mode::Text(edit) => Some(edit),
|
||||
Mode::Popup(popup) => popup.edit_mut(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode's name for the status bar.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -52,6 +86,7 @@ impl Mode {
|
||||
Mode::Text(_) => "TEXT",
|
||||
Mode::Move(_) => "MOVE",
|
||||
Mode::Command(_) => "COMMAND",
|
||||
Mode::Popup(popup) => popup.label(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Panes: the screen split into rectangles, the way vim splits windows.
|
||||
//!
|
||||
//! The layout is a tree. A leaf is a pane; a split lays its parts side by
|
||||
//! side or one above the other, each with a share of the room. Splitting a
|
||||
//! pane halves its room, closing one gives its room to a neighbor, and
|
||||
//! `Ctrl-w =` evens the shares out.
|
||||
//!
|
||||
//! [`Panes`] holds what each pane shows (`T`: a view and a document, in the
|
||||
//! app) apart from the layout, so the layout logic stays plain and testable.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ratatui::layout::{Position, Rect};
|
||||
|
||||
use crate::model::Direction;
|
||||
|
||||
/// A pane, while it's open. Ids aren't reused.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct PaneId(u32);
|
||||
|
||||
/// How a split arranges its parts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Arrange {
|
||||
/// Next to each other, left to right (`Ctrl-w v`, `:vsplit`).
|
||||
SideBySide,
|
||||
/// One above the other (`Ctrl-w s`, `:split`).
|
||||
Stacked,
|
||||
}
|
||||
|
||||
/// A part's room when a split is made or evened out. Splitting halves a
|
||||
/// share, so it starts big enough to be halved many times over.
|
||||
const FULL_SHARE: u32 = 1 << 20;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Layout {
|
||||
Pane(PaneId),
|
||||
Split { arrange: Arrange, parts: Vec<Part> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Part {
|
||||
layout: Layout,
|
||||
/// This part's room, relative to the other parts of its split.
|
||||
share: u32,
|
||||
}
|
||||
|
||||
/// The panes, their layout, and which one has the focus.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Panes<T> {
|
||||
layout: Layout,
|
||||
/// What each pane shows. Every pane in the layout is here and the other
|
||||
/// way round, and `focused` is always one of them: every method keeps it
|
||||
/// so.
|
||||
contents: BTreeMap<PaneId, T>,
|
||||
focused: PaneId,
|
||||
next_id: u32,
|
||||
}
|
||||
|
||||
impl<T> Panes<T> {
|
||||
/// One pane, showing `first`.
|
||||
pub fn new(first: T) -> Panes<T> {
|
||||
let id = PaneId(0);
|
||||
Panes { layout: Layout::Pane(id), contents: BTreeMap::from([(id, first)]), focused: id, next_id: 1 }
|
||||
}
|
||||
|
||||
/// The pane with the focus.
|
||||
pub fn focused(&self) -> PaneId {
|
||||
self.focused
|
||||
}
|
||||
|
||||
/// What the focused pane shows.
|
||||
pub fn current(&self) -> &T {
|
||||
self.contents.get(&self.focused).expect("the focused pane is always open")
|
||||
}
|
||||
|
||||
pub fn current_mut(&mut self) -> &mut T {
|
||||
self.contents.get_mut(&self.focused).expect("the focused pane is always open")
|
||||
}
|
||||
|
||||
pub fn get(&self, id: PaneId) -> Option<&T> {
|
||||
self.contents.get(&id)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, id: PaneId) -> Option<&mut T> {
|
||||
self.contents.get_mut(&id)
|
||||
}
|
||||
|
||||
/// How many panes there are.
|
||||
pub fn count(&self) -> usize {
|
||||
self.contents.len()
|
||||
}
|
||||
|
||||
/// Every pane and what it shows, in no particular order.
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (PaneId, &mut T)> {
|
||||
self.contents.iter_mut().map(|(&id, content)| (id, content))
|
||||
}
|
||||
|
||||
/// Splits the focused pane in two. The new pane, showing `content`,
|
||||
/// comes first (left or above, as in vim) and takes the focus.
|
||||
pub fn split(&mut self, arrange: Arrange, content: T) -> PaneId {
|
||||
let id = PaneId(self.next_id);
|
||||
self.next_id += 1;
|
||||
self.contents.insert(id, content);
|
||||
let focused = self.focused;
|
||||
split_in(&mut self.layout, focused, id, arrange);
|
||||
self.focused = id;
|
||||
id
|
||||
}
|
||||
|
||||
/// Closes the focused pane and gives its room to a neighbor, which takes
|
||||
/// the focus. The last pane can't close: `None`.
|
||||
pub fn close(&mut self) -> Option<T> {
|
||||
if self.contents.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let closing = self.focused;
|
||||
let next = close_in(&mut self.layout, closing)?;
|
||||
self.focused = next;
|
||||
self.contents.remove(&closing)
|
||||
}
|
||||
|
||||
/// Closes every pane but the focused one, and returns what they showed.
|
||||
pub fn only(&mut self) -> Vec<T> {
|
||||
self.layout = Layout::Pane(self.focused);
|
||||
let others: Vec<PaneId> = self.contents.keys().copied().filter(|&id| id != self.focused).collect();
|
||||
others.into_iter().filter_map(|id| self.contents.remove(&id)).collect()
|
||||
}
|
||||
|
||||
/// Gives every part of every split the same room (`Ctrl-w =`).
|
||||
pub fn equalize(&mut self) {
|
||||
equalize(&mut self.layout);
|
||||
}
|
||||
|
||||
/// Where each pane is in `area`, in reading order: left to right, top to
|
||||
/// bottom, the way the layout nests.
|
||||
pub fn areas(&self, area: Rect) -> Vec<(PaneId, Rect)> {
|
||||
let mut found = Vec::new();
|
||||
areas(&self.layout, area, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
/// The pane at a position on screen, if there's one.
|
||||
pub fn at(&self, area: Rect, position: Position) -> Option<PaneId> {
|
||||
self.areas(area).into_iter().find(|(_, rect)| rect.contains(position)).map(|(id, _)| id)
|
||||
}
|
||||
|
||||
/// Gives the focus to a pane. Returns false if there's no such pane.
|
||||
pub fn focus(&mut self, id: PaneId) -> bool {
|
||||
let open = self.contents.contains_key(&id);
|
||||
if open {
|
||||
self.focused = id;
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// `Ctrl-w h j k l`: the focus goes to the nearest pane that way, the
|
||||
/// one beside the focused pane's top-left corner first. Returns false if
|
||||
/// there's none.
|
||||
pub fn focus_toward(&mut self, direction: Direction, area: Rect) -> bool {
|
||||
let areas = self.areas(area);
|
||||
let Some(&(_, from)) = areas.iter().find(|(id, _)| *id == self.focused) else { return false };
|
||||
let best = areas
|
||||
.iter()
|
||||
.filter(|(id, _)| *id != self.focused)
|
||||
.filter_map(|&(id, rect)| Some((distance(from, rect, direction)?, id)))
|
||||
.min_by_key(|&(key, _)| key);
|
||||
match best {
|
||||
Some((_, id)) => self.focus(id),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ctrl-w w`: the next pane in reading order, back to the first after
|
||||
/// the last.
|
||||
pub fn focus_next(&mut self) {
|
||||
let mut order = Vec::new();
|
||||
panes_in(&self.layout, &mut order);
|
||||
let here = order.iter().position(|&id| id == self.focused).unwrap_or(0);
|
||||
if let Some(&next) = order.get((here + 1) % order.len().max(1)) {
|
||||
self.focused = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far `to` is from `from` going `direction`, for picking the pane to
|
||||
/// focus: `None` unless it's that way and level with `from`. Nearer wins,
|
||||
/// then the one beside `from`'s top-left corner, then the top-most or
|
||||
/// left-most.
|
||||
fn distance(from: Rect, to: Rect, direction: Direction) -> Option<(u16, bool, u16)> {
|
||||
let level_rows = to.top() < from.bottom() && from.top() < to.bottom();
|
||||
let level_cols = to.left() < from.right() && from.left() < to.right();
|
||||
let (gap, level, beside, along) = match direction {
|
||||
Direction::Left => {
|
||||
(from.left().checked_sub(to.right())?, level_rows, row_in(from.top(), to), to.top())
|
||||
}
|
||||
Direction::Right => {
|
||||
(to.left().checked_sub(from.right())?, level_rows, row_in(from.top(), to), to.top())
|
||||
}
|
||||
Direction::Up => {
|
||||
(from.top().checked_sub(to.bottom())?, level_cols, col_in(from.left(), to), to.left())
|
||||
}
|
||||
Direction::Down => {
|
||||
(to.top().checked_sub(from.bottom())?, level_cols, col_in(from.left(), to), to.left())
|
||||
}
|
||||
};
|
||||
level.then_some((gap, !beside, along))
|
||||
}
|
||||
|
||||
fn row_in(row: u16, rect: Rect) -> bool {
|
||||
rect.top() <= row && row < rect.bottom()
|
||||
}
|
||||
|
||||
fn col_in(col: u16, rect: Rect) -> bool {
|
||||
rect.left() <= col && col < rect.right()
|
||||
}
|
||||
|
||||
/// Splits pane `target` in `layout`, putting `new` before it.
|
||||
fn split_in(layout: &mut Layout, target: PaneId, new: PaneId, arrange: Arrange) -> bool {
|
||||
match layout {
|
||||
Layout::Pane(id) if *id == target => {
|
||||
let old = Part { layout: Layout::Pane(target), share: FULL_SHARE };
|
||||
let part = Part { layout: Layout::Pane(new), share: FULL_SHARE };
|
||||
*layout = Layout::Split { arrange, parts: vec![part, old] };
|
||||
true
|
||||
}
|
||||
Layout::Pane(_) => false,
|
||||
Layout::Split { arrange: own, parts } => {
|
||||
let same_way = *own == arrange;
|
||||
for index in 0..parts.len() {
|
||||
let is_target = parts[index].layout == Layout::Pane(target);
|
||||
if is_target && same_way {
|
||||
// Same way as this split: the new pane joins it, and the
|
||||
// two share the old pane's room.
|
||||
let share = parts[index].share;
|
||||
parts[index].share = share - share / 2;
|
||||
let part = Part { layout: Layout::Pane(new), share: (share / 2).max(1) };
|
||||
parts.insert(index, part);
|
||||
return true;
|
||||
}
|
||||
if split_in(&mut parts[index].layout, target, new, arrange) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes pane `target` out of `layout`, giving its room to the part before
|
||||
/// it (or after it, for a first part). Returns the pane that gets the
|
||||
/// focus.
|
||||
fn close_in(layout: &mut Layout, target: PaneId) -> Option<PaneId> {
|
||||
let Layout::Split { parts, .. } = layout else { return None };
|
||||
let index = parts.iter().position(|part| part.layout == Layout::Pane(target));
|
||||
let next = match index {
|
||||
Some(index) => {
|
||||
let removed = parts.remove(index);
|
||||
let neighbor = index.saturating_sub(1).min(parts.len().saturating_sub(1));
|
||||
let part = parts.get_mut(neighbor)?;
|
||||
part.share = part.share.saturating_add(removed.share);
|
||||
first_pane(&part.layout)
|
||||
}
|
||||
None => parts.iter_mut().find_map(|part| close_in(&mut part.layout, target))?,
|
||||
};
|
||||
// A split left with one part is just that part.
|
||||
if parts.len() == 1
|
||||
&& let Some(only) = parts.pop()
|
||||
{
|
||||
*layout = only.layout;
|
||||
}
|
||||
flatten(layout);
|
||||
Some(next)
|
||||
}
|
||||
|
||||
/// A split inside a split that arranges the same way joins its parent, so
|
||||
/// `Ctrl-w v` twice gives three panes side by side, not a nest.
|
||||
fn flatten(layout: &mut Layout) {
|
||||
let Layout::Split { arrange, parts } = layout else { return };
|
||||
let mut flat = Vec::with_capacity(parts.len());
|
||||
for mut part in std::mem::take(parts) {
|
||||
flatten(&mut part.layout);
|
||||
match part.layout {
|
||||
Layout::Split { arrange: inner, parts: inner_parts } if inner == *arrange => {
|
||||
let total: u64 = inner_parts.iter().map(|inner| u64::from(inner.share)).sum::<u64>().max(1);
|
||||
for mut inner_part in inner_parts {
|
||||
let share = u64::from(part.share) * u64::from(inner_part.share) / total;
|
||||
inner_part.share = u32::try_from(share).unwrap_or(u32::MAX).max(1);
|
||||
flat.push(inner_part);
|
||||
}
|
||||
}
|
||||
layout => flat.push(Part { layout, ..part }),
|
||||
}
|
||||
}
|
||||
*parts = flat;
|
||||
}
|
||||
|
||||
fn equalize(layout: &mut Layout) {
|
||||
if let Layout::Split { parts, .. } = layout {
|
||||
for part in parts {
|
||||
part.share = FULL_SHARE;
|
||||
equalize(&mut part.layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_pane(layout: &Layout) -> PaneId {
|
||||
match layout {
|
||||
Layout::Pane(id) => *id,
|
||||
Layout::Split { parts, .. } => parts.first().map_or(PaneId(0), |part| first_pane(&part.layout)),
|
||||
}
|
||||
}
|
||||
|
||||
fn panes_in(layout: &Layout, found: &mut Vec<PaneId>) {
|
||||
match layout {
|
||||
Layout::Pane(id) => found.push(*id),
|
||||
Layout::Split { parts, .. } => parts.iter().for_each(|part| panes_in(&part.layout, found)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays `layout` out in `area`: each part gets room in proportion to its
|
||||
/// share, and the last one takes what rounding leaves.
|
||||
fn areas(layout: &Layout, area: Rect, found: &mut Vec<(PaneId, Rect)>) {
|
||||
let (arrange, parts) = match layout {
|
||||
Layout::Pane(id) => return found.push((*id, area)),
|
||||
Layout::Split { arrange, parts } => (*arrange, parts),
|
||||
};
|
||||
let length = match arrange {
|
||||
Arrange::SideBySide => area.width,
|
||||
Arrange::Stacked => area.height,
|
||||
};
|
||||
let total: u64 = parts.iter().map(|part| u64::from(part.share)).sum::<u64>().max(1);
|
||||
let mut start = 0u16;
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let size = if index + 1 == parts.len() {
|
||||
length - start
|
||||
} else {
|
||||
let size = u64::from(length) * u64::from(part.share) / total;
|
||||
u16::try_from(size).unwrap_or(length).min(length - start)
|
||||
};
|
||||
let rect = match arrange {
|
||||
Arrange::SideBySide => Rect::new(area.x + start, area.y, size, area.height),
|
||||
Arrange::Stacked => Rect::new(area.x, area.y + start, area.width, size),
|
||||
};
|
||||
areas(&part.layout, rect, found);
|
||||
start += size;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SCREEN: Rect = Rect { x: 0, y: 0, width: 80, height: 20 };
|
||||
|
||||
fn rects<'a>(panes: &Panes<&'a str>) -> Vec<(&'a str, Rect)> {
|
||||
panes.areas(SCREEN).into_iter().map(|(id, rect)| (*panes.get(id).unwrap(), rect)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_halve_the_pane_and_the_new_one_comes_first() {
|
||||
let mut panes = Panes::new("canvas");
|
||||
panes.split(Arrange::SideBySide, "outline");
|
||||
assert_eq!(
|
||||
rects(&panes),
|
||||
[("outline", Rect::new(0, 0, 40, 20)), ("canvas", Rect::new(40, 0, 40, 20))]
|
||||
);
|
||||
assert_eq!(*panes.current(), "outline", "the new pane has the focus");
|
||||
panes.split(Arrange::Stacked, "note");
|
||||
assert_eq!(
|
||||
rects(&panes),
|
||||
[
|
||||
("note", Rect::new(0, 0, 40, 10)),
|
||||
("outline", Rect::new(0, 10, 40, 10)),
|
||||
("canvas", Rect::new(40, 0, 40, 20)),
|
||||
]
|
||||
);
|
||||
// Side by side again, from the canvas: it joins the top split.
|
||||
panes.focus_toward(Direction::Right, SCREEN);
|
||||
panes.split(Arrange::SideBySide, "board");
|
||||
let widths: Vec<(&str, u16)> =
|
||||
rects(&panes).into_iter().map(|(name, rect)| (name, rect.width)).collect();
|
||||
assert_eq!(widths, [("note", 40), ("outline", 40), ("board", 20), ("canvas", 20)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_moves_to_the_pane_that_way() {
|
||||
let mut panes = Panes::new("right");
|
||||
panes.split(Arrange::SideBySide, "top left");
|
||||
panes.split(Arrange::Stacked, "top left 2");
|
||||
// top left 2 above top left, both left of right.
|
||||
assert_eq!(*panes.current(), "top left 2");
|
||||
assert!(panes.focus_toward(Direction::Down, SCREEN));
|
||||
assert_eq!(*panes.current(), "top left");
|
||||
assert!(panes.focus_toward(Direction::Right, SCREEN));
|
||||
assert_eq!(*panes.current(), "right");
|
||||
assert!(panes.focus_toward(Direction::Left, SCREEN));
|
||||
assert_eq!(*panes.current(), "top left 2", "level with the top: the top one");
|
||||
assert!(!panes.focus_toward(Direction::Up, SCREEN), "nothing above");
|
||||
panes.focus_next();
|
||||
assert_eq!(*panes.current(), "top left");
|
||||
panes.focus_next();
|
||||
panes.focus_next();
|
||||
assert_eq!(*panes.current(), "top left 2", "after the last, the first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_gives_the_room_to_a_neighbor() {
|
||||
let mut panes = Panes::new("canvas");
|
||||
panes.split(Arrange::SideBySide, "outline");
|
||||
panes.split(Arrange::Stacked, "note");
|
||||
assert_eq!(panes.close(), Some("note"));
|
||||
assert_eq!(*panes.current(), "outline", "the neighbor takes the focus");
|
||||
assert_eq!(
|
||||
rects(&panes),
|
||||
[("outline", Rect::new(0, 0, 40, 20)), ("canvas", Rect::new(40, 0, 40, 20))]
|
||||
);
|
||||
assert_eq!(panes.close(), Some("outline"));
|
||||
assert_eq!(panes.close(), None, "the last pane stays");
|
||||
assert_eq!(rects(&panes), [("canvas", SCREEN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_and_even_sizes() {
|
||||
let mut panes = Panes::new("a");
|
||||
panes.split(Arrange::SideBySide, "b");
|
||||
panes.split(Arrange::SideBySide, "c");
|
||||
let widths =
|
||||
|panes: &Panes<&str>| rects(panes).into_iter().map(|(_, rect)| rect.width).collect::<Vec<_>>();
|
||||
assert_eq!(widths(&panes), [20, 20, 40]);
|
||||
panes.equalize();
|
||||
assert_eq!(widths(&panes), [26, 26, 28]);
|
||||
let mut closed = panes.only();
|
||||
closed.sort_unstable();
|
||||
assert_eq!(closed, ["a", "b"]);
|
||||
assert_eq!(rects(&panes), [("c", SCREEN)]);
|
||||
assert_eq!(panes.at(SCREEN, Position::new(70, 3)), Some(panes.focused()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! Popups that take the keys for a while: the help (`?`), the right-click
|
||||
//! menu, the pickers (`<Space>c` and the others) and the `K` card. An open
|
||||
//! popup is a mode ([`Mode::Popup`](super::mode::Mode::Popup)): keys and
|
||||
//! clicks go to it, and what it does comes out as actions, like everything
|
||||
//! else.
|
||||
//!
|
||||
//! The help and the menu are lists of things to run: running one closes the
|
||||
//! popup and does it as if its keys were typed. A picker sets something on
|
||||
//! the node it opened on. The card shows the cursor's body, and edits it in
|
||||
//! place.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::action::{Action, AppAction, DocumentChoice, Message};
|
||||
use super::commands;
|
||||
use super::registers::Registers;
|
||||
use super::text_edit::{EditAt, StartEdit, TextEdit};
|
||||
use crate::command::parse::border;
|
||||
use crate::command::registry::{BORDERS, SPECS};
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::command::{Bound, Command, Operator, Target, TargetBound, help_for};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::key::{Key, notation};
|
||||
use crate::input::keymap::{Keymap, Tables};
|
||||
use crate::input::mouse::{Cell, Gesture};
|
||||
use crate::model::{Border, CanvasPos, ColorName, Content, Direction, EditOp, NodeId, StyleName, Tag, Tags};
|
||||
use crate::ui::card::{CardText, CardView};
|
||||
use crate::ui::list::{ListOutcome, ListPopup, ListRow, Place};
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::view::ViewContext;
|
||||
|
||||
/// How wide the help is, at most.
|
||||
const HELP_WIDTH: u16 = 76;
|
||||
/// How wide a picker is, at most.
|
||||
const PICKER_WIDTH: u16 = 44;
|
||||
/// The widest the keys column of the help gets.
|
||||
const KEYS_WIDTH: usize = 12;
|
||||
|
||||
/// A popup, with the keys.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Popup {
|
||||
/// Every key and command, to search and run.
|
||||
Help(Runs),
|
||||
/// The right-click menu.
|
||||
Menu(Runs),
|
||||
Pick(Picker),
|
||||
Card(Card),
|
||||
}
|
||||
|
||||
/// What the menu is for: a node, or a spot on the empty canvas.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MenuFor {
|
||||
Node(NodeId),
|
||||
Canvas(CanvasPos),
|
||||
}
|
||||
|
||||
/// What a picker picks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PickKind {
|
||||
Color,
|
||||
Border,
|
||||
Style,
|
||||
Tags,
|
||||
/// The document the pane shows.
|
||||
Document,
|
||||
}
|
||||
|
||||
/// A list whose rows run something: the help and the menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Runs {
|
||||
pub list: ListPopup,
|
||||
/// What each row runs.
|
||||
runs: Vec<Run>,
|
||||
}
|
||||
|
||||
/// What running a row of the help or the menu does.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Run {
|
||||
/// Nothing: a heading.
|
||||
Nothing,
|
||||
/// A command, as if its keys were typed.
|
||||
Command(Command),
|
||||
/// Keys, as if typed, for a key that waits for more.
|
||||
Keys(Vec<Key>),
|
||||
/// A `:` command, run at once.
|
||||
Line(String),
|
||||
/// The command line with this typed: a `:` command that needs more.
|
||||
Ask(String),
|
||||
/// A new loose idea, here on the canvas.
|
||||
NewIdeaAt(CanvasPos),
|
||||
}
|
||||
|
||||
/// A picker: the node it sets something on, and each row's value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Picker {
|
||||
pub list: ListPopup,
|
||||
node: NodeId,
|
||||
values: Vec<Pick>,
|
||||
/// The node's tags as they are now, in the tags picker.
|
||||
tags: Tags,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Pick {
|
||||
Color(Option<ColorName>),
|
||||
Border(Option<Border>),
|
||||
Style(Option<StyleName>),
|
||||
Tag(Tag),
|
||||
Document(usize),
|
||||
}
|
||||
|
||||
/// The `K` card: the node it shows, where it is, and its body while it's
|
||||
/// edited there.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Card {
|
||||
pub node: NodeId,
|
||||
pub edit: Option<TextEdit>,
|
||||
pub view: CardView,
|
||||
}
|
||||
|
||||
impl Popup {
|
||||
/// The help for the keys of `contexts` (a view's, in normal mode), then
|
||||
/// every `:` command, with `search` already typed.
|
||||
pub fn help(
|
||||
keymap: &Keymap,
|
||||
contexts: &'static [Context],
|
||||
view: &str,
|
||||
search: &str,
|
||||
theme: &Theme,
|
||||
) -> Popup {
|
||||
let mut entries: Vec<Entry> = Vec::new();
|
||||
let mut seen: Vec<Vec<Key>> = Vec::new();
|
||||
for context in contexts {
|
||||
let tables = keymap.tables(std::slice::from_ref(context));
|
||||
for binding in tables.bindings() {
|
||||
if seen.contains(&binding.keys) {
|
||||
continue;
|
||||
}
|
||||
seen.push(binding.keys.clone());
|
||||
let shown = notation(&binding.keys);
|
||||
let section = section(&tables, *context, &binding.keys);
|
||||
match binding.bound {
|
||||
Bound::Command(command) => {
|
||||
entries.push(Entry::new(section, shown, binding.help.clone(), Run::Command(command)));
|
||||
}
|
||||
Bound::WithChar(_) => {
|
||||
let help = format!("{} (then a letter)", binding.help);
|
||||
entries.push(Entry::new(
|
||||
section,
|
||||
format!("{shown}…"),
|
||||
help,
|
||||
Run::Keys(binding.keys.clone()),
|
||||
));
|
||||
}
|
||||
Bound::Operator(operator) => {
|
||||
// The operator with each target: its own key again,
|
||||
// then the others.
|
||||
let mut targets = vec![(binding.keys.clone(), Target::Current)];
|
||||
for target in tables.targets_starting_with(&[]) {
|
||||
if let TargetBound::Target(kind) = target.bound {
|
||||
targets.push((target.keys, kind));
|
||||
}
|
||||
}
|
||||
for (keys, target) in targets {
|
||||
let keys = notation(&[binding.keys.clone(), keys].concat());
|
||||
let run = Run::Command(Command::Operate(operator, target));
|
||||
entries.push(Entry::new(
|
||||
"operators".into(),
|
||||
keys,
|
||||
operator_help(operator, target),
|
||||
run,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for spec in SPECS {
|
||||
let needs_more = spec.args.iter().any(|arg| arg.required);
|
||||
let run = if needs_more {
|
||||
Run::Ask(format!("{} ", spec.name))
|
||||
} else {
|
||||
Run::Line(spec.name.to_string())
|
||||
};
|
||||
entries.push(Entry::new(
|
||||
"commands".into(),
|
||||
format!(":{}", spec.name),
|
||||
spec.help.to_string(),
|
||||
run,
|
||||
));
|
||||
}
|
||||
|
||||
// The entries under their headings, the headings in the order they
|
||||
// come.
|
||||
let key_style = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD);
|
||||
let heading = Style::new().fg(theme.muted).add_modifier(Modifier::BOLD);
|
||||
let width = entries.iter().map(|entry| entry.keys.width()).max().unwrap_or(0).min(KEYS_WIDTH);
|
||||
let mut sections: Vec<&str> = Vec::new();
|
||||
for entry in &entries {
|
||||
if !sections.contains(&entry.section.as_str()) {
|
||||
sections.push(&entry.section);
|
||||
}
|
||||
}
|
||||
let (mut rows, mut runs) = (Vec::new(), Vec::new());
|
||||
for section in sections {
|
||||
rows.push(ListRow::heading(section, heading));
|
||||
runs.push(Run::Nothing);
|
||||
for entry in entries.iter().filter(|entry| entry.section == section) {
|
||||
let keys = Span::styled(format!("{:<width$} ", entry.keys), key_style);
|
||||
let search = format!("{} {}", entry.keys, entry.help);
|
||||
rows.push(ListRow::new(vec![keys, Span::raw(entry.help.clone())], search));
|
||||
runs.push(entry.run.clone());
|
||||
}
|
||||
}
|
||||
let footer = "j/k move · / search · Enter run · Esc close";
|
||||
let list =
|
||||
ListPopup::new(format!("help · {view}"), footer, rows, Place::Center { width: HELP_WIDTH });
|
||||
Popup::Help(Runs { list: list.with_search(search), runs })
|
||||
}
|
||||
|
||||
/// The right-click menu at a cell, for a node or a spot on the canvas,
|
||||
/// with the keys that do the same (from `contexts`).
|
||||
pub fn menu(
|
||||
what: MenuFor,
|
||||
title: &str,
|
||||
at: Position,
|
||||
keymap: &Keymap,
|
||||
contexts: &'static [Context],
|
||||
theme: &Theme,
|
||||
) -> Popup {
|
||||
let tables = keymap.tables(contexts);
|
||||
let items: Vec<(&str, Run)> = match what {
|
||||
MenuFor::Node(_) => {
|
||||
NODE_MENU.iter().map(|&(label, command)| (label, Run::Command(command))).collect()
|
||||
}
|
||||
MenuFor::Canvas(spot) => vec![
|
||||
("new idea here", Run::NewIdeaAt(spot)),
|
||||
("zoom in", Run::Command(Command::ZoomIn)),
|
||||
("zoom out", Run::Command(Command::ZoomOut)),
|
||||
],
|
||||
};
|
||||
let key_style = Style::new().fg(theme.muted);
|
||||
let (mut rows, mut runs) = (Vec::new(), Vec::new());
|
||||
for (label, run) in items {
|
||||
let mut row = ListRow::new(vec![Span::raw(label)], label);
|
||||
if let Run::Command(command) = run
|
||||
&& let Some(keys) = keys_for(&tables, command)
|
||||
{
|
||||
row = row.with_note(Span::styled(keys, key_style));
|
||||
}
|
||||
rows.push(row);
|
||||
runs.push(run);
|
||||
}
|
||||
let list = ListPopup::new(title, "Enter or a click: do it", rows, Place::At(at));
|
||||
Popup::Menu(Runs { list, runs })
|
||||
}
|
||||
|
||||
/// A picker for the cursor's node (or, for documents, for the pane).
|
||||
/// `documents` are the open documents' names, and `current` the one the
|
||||
/// pane shows.
|
||||
pub fn picker(
|
||||
kind: PickKind,
|
||||
ctx: &ViewContext<'_>,
|
||||
documents: &[String],
|
||||
current: usize,
|
||||
) -> Result<Popup, Message> {
|
||||
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
|
||||
let node = doc.node(ctx.cursor).ok_or_else(|| Message::problem("that node is gone"))?;
|
||||
let check = |on: bool| on.then(|| Span::styled("✓", Style::new().fg(theme.accent)));
|
||||
let row = |spans: Vec<Span<'static>>, search: &str, on: bool| {
|
||||
let row = ListRow::new(spans, search);
|
||||
match check(on) {
|
||||
Some(mark) => row.with_note(mark),
|
||||
None => row,
|
||||
}
|
||||
};
|
||||
let muted = Style::new().fg(theme.muted);
|
||||
let mut tags = Tags::default();
|
||||
let (title, values, rows): (String, Vec<Pick>, Vec<ListRow>) = match kind {
|
||||
PickKind::Color => {
|
||||
let mut values = vec![Pick::Color(None)];
|
||||
values.extend(theme.palette.keys().cloned().map(|name| Pick::Color(Some(name))));
|
||||
let rows = values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let Pick::Color(color) = value else { return ListRow::new(Vec::new(), "") };
|
||||
let on = node.look.color == *color;
|
||||
match color {
|
||||
None => row(vec![Span::styled("theme default", muted)], "theme default none", on),
|
||||
Some(name) => {
|
||||
let swatch = Span::styled(
|
||||
format!("{} ", glyphs.bullet()),
|
||||
Style::new().fg(theme.palette.get(name).copied().unwrap_or_default()),
|
||||
);
|
||||
row(vec![swatch, Span::raw(name.to_string())], name.as_str(), on)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(format!("color · {}", node.title()), values, rows)
|
||||
}
|
||||
PickKind::Border => {
|
||||
let mut values = vec![Pick::Border(None)];
|
||||
let mut rows = vec![row(
|
||||
vec![Span::styled("theme default", muted)],
|
||||
"theme default",
|
||||
node.look.border.is_none(),
|
||||
)];
|
||||
for choice in BORDERS {
|
||||
let Ok(kind) = border(choice.word) else { continue };
|
||||
let on = node.look.border == Some(kind);
|
||||
let spans =
|
||||
vec![Span::raw(format!("{:<8}", choice.word)), Span::styled(choice.help, muted)];
|
||||
rows.push(row(spans, choice.word, on));
|
||||
values.push(Pick::Border(Some(kind)));
|
||||
}
|
||||
(format!("border · {}", node.title()), values, rows)
|
||||
}
|
||||
PickKind::Style => {
|
||||
if theme.node_styles.is_empty() {
|
||||
return Err(Message::info(
|
||||
"this theme has no named styles yet: theme files come in stage 8",
|
||||
));
|
||||
}
|
||||
let mut values = vec![Pick::Style(None)];
|
||||
values.extend(theme.node_styles.keys().cloned().map(|name| Pick::Style(Some(name))));
|
||||
let rows = values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let Pick::Style(style) = value else { return ListRow::new(Vec::new(), "") };
|
||||
let on = node.look.style == *style;
|
||||
match style {
|
||||
None => row(vec![Span::styled("no style", muted)], "no style none", on),
|
||||
Some(name) => row(vec![Span::raw(name.to_string())], name.as_str(), on),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(format!("style · {}", node.title()), values, rows)
|
||||
}
|
||||
PickKind::Tags => {
|
||||
tags = node.tags.clone();
|
||||
// Every tag in the document, and the theme's.
|
||||
let mut all: Vec<Tag> = doc
|
||||
.outline()
|
||||
.into_iter()
|
||||
.filter_map(|id| doc.node(id))
|
||||
.flat_map(|node| node.tags.iter().cloned().collect::<Vec<_>>())
|
||||
.collect();
|
||||
all.extend(theme.tag_styles.iter().map(|(tag, _)| tag.clone()));
|
||||
all.sort();
|
||||
all.dedup();
|
||||
let rows = all
|
||||
.iter()
|
||||
.map(|tag| row(vec![Span::raw(tag.to_string())], tag.name(), tags.contains(tag)))
|
||||
.collect();
|
||||
(format!("tags · {}", node.title()), all.into_iter().map(Pick::Tag).collect(), rows)
|
||||
}
|
||||
PickKind::Document => {
|
||||
let rows = documents
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, name)| row(vec![Span::raw(name.clone())], name, index == current))
|
||||
.collect();
|
||||
("documents".to_string(), (0..documents.len()).map(Pick::Document).collect(), rows)
|
||||
}
|
||||
};
|
||||
let footer = match kind {
|
||||
PickKind::Tags => "Enter: on or off · / then a new one: add it",
|
||||
PickKind::Document => "Enter: show it here · Esc: close",
|
||||
_ => "Enter: pick · Esc: close",
|
||||
};
|
||||
let mut list = ListPopup::new(title, footer, rows, Place::Center { width: PICKER_WIDTH });
|
||||
// Start on the value the node has now.
|
||||
if let Some(index) = values.iter().position(|value| picked(value, node, &tags, current)) {
|
||||
list.select(index);
|
||||
}
|
||||
Ok(Popup::Pick(Picker { list, node: ctx.cursor, values, tags }))
|
||||
}
|
||||
|
||||
/// The `K` card for a node.
|
||||
pub fn card(node: NodeId) -> Popup {
|
||||
Popup::Card(Card { node, edit: None, view: CardView::default() })
|
||||
}
|
||||
|
||||
/// The mode's name for the status bar.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Popup::Help(_) => "HELP",
|
||||
Popup::Menu(_) => "MENU",
|
||||
Popup::Pick(_) => "PICK",
|
||||
Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => "INSERT",
|
||||
Popup::Card(Card { edit: Some(_), .. }) => "TEXT",
|
||||
Popup::Card(_) => "CARD",
|
||||
}
|
||||
}
|
||||
|
||||
/// The key tables while it's open.
|
||||
pub fn contexts(&self) -> &'static [Context] {
|
||||
match self {
|
||||
Popup::Help(_) | Popup::Menu(_) | Popup::Pick(_) => &[Context::List],
|
||||
Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => &[Context::Insert],
|
||||
Popup::Card(Card { edit: Some(_), .. }) => &[Context::Text],
|
||||
Popup::Card(_) => &[Context::Card],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether keys go straight to it rather than through the key engine:
|
||||
/// while a search or a body is typed.
|
||||
pub fn takes_typing(&self) -> bool {
|
||||
match self {
|
||||
Popup::Help(runs) | Popup::Menu(runs) => runs.list.is_searching(),
|
||||
Popup::Pick(picker) => picker.list.is_searching(),
|
||||
Popup::Card(card) => card.edit.as_ref().is_some_and(|edit| edit.editor.is_typing()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The body being edited in the card.
|
||||
pub fn edit(&self) -> Option<&TextEdit> {
|
||||
match self {
|
||||
Popup::Card(card) => card.edit.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn edit_mut(&mut self) -> Option<&mut TextEdit> {
|
||||
match self {
|
||||
Popup::Card(card) => card.edit.as_mut(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A key typed straight in (see [`Popup::takes_typing`]).
|
||||
pub fn typing(&mut self, key: Key, keymap: &Keymap, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let outcome = match self {
|
||||
Popup::Help(runs) | Popup::Menu(runs) => runs.list.search_key(key),
|
||||
Popup::Pick(picker) => picker.list.search_key(key),
|
||||
Popup::Card(card) => {
|
||||
let bound = keymap.tables(&[Context::Insert]).bound(&[key]);
|
||||
return card.edit.as_mut().map(|edit| edit.typing(key, bound, ctx)).unwrap_or_default();
|
||||
}
|
||||
};
|
||||
self.outcome(outcome, ctx)
|
||||
}
|
||||
|
||||
/// A command from its key tables.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
typed: &Typed,
|
||||
registers: &mut Registers,
|
||||
ctx: &ViewContext<'_>,
|
||||
) -> Vec<Action> {
|
||||
let outcome = match self {
|
||||
Popup::Help(runs) | Popup::Menu(runs) => runs.list.command(typed),
|
||||
Popup::Pick(picker) => picker.list.command(typed),
|
||||
Popup::Card(card) => return card.command(typed, registers, ctx),
|
||||
};
|
||||
self.outcome(outcome, ctx)
|
||||
}
|
||||
|
||||
/// A mouse gesture, anywhere on the screen.
|
||||
pub fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let list = match self {
|
||||
Popup::Help(runs) | Popup::Menu(runs) => &mut runs.list,
|
||||
Popup::Pick(picker) => &mut picker.list,
|
||||
Popup::Card(card) => return card.mouse(gesture, ctx),
|
||||
};
|
||||
let outcome = match gesture {
|
||||
Gesture::Press { at, .. } => list.click(position(at)),
|
||||
Gesture::Scroll { direction, .. } => {
|
||||
list.wheel(direction == Direction::Down);
|
||||
ListOutcome::Stay
|
||||
}
|
||||
Gesture::RightPress { at } if list.click(position(at)) == ListOutcome::Close => {
|
||||
ListOutcome::Close
|
||||
}
|
||||
_ => ListOutcome::Stay,
|
||||
};
|
||||
self.outcome(outcome, ctx)
|
||||
}
|
||||
|
||||
/// Works out where it goes on `screen`. `anchor` is where the cursor's
|
||||
/// node is on screen, for the card.
|
||||
pub fn layout(&mut self, screen: Rect, anchor: Option<Rect>, ctx: &ViewContext<'_>) {
|
||||
match self {
|
||||
Popup::Help(runs) | Popup::Menu(runs) => runs.list.layout(screen),
|
||||
Popup::Pick(picker) => picker.list.layout(screen),
|
||||
Popup::Card(card) => {
|
||||
if let Some(text) = card_text(card.node, card.edit.as_ref(), ctx) {
|
||||
card.view.layout(screen, anchor, &text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) {
|
||||
match self {
|
||||
Popup::Help(runs) | Popup::Menu(runs) => runs.list.render(frame, ctx.theme),
|
||||
Popup::Pick(picker) => picker.list.render(frame, ctx.theme),
|
||||
Popup::Card(card) => {
|
||||
if let Some(text) = card_text(card.node, card.edit.as_ref(), ctx) {
|
||||
card.view.render(frame, &text, ctx.theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a list's outcome means for this popup.
|
||||
fn outcome(&mut self, outcome: ListOutcome, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
match (outcome, self) {
|
||||
(ListOutcome::Stay, _) => Vec::new(),
|
||||
(ListOutcome::Close, _) => leave(),
|
||||
(ListOutcome::Picked(index), Popup::Help(runs) | Popup::Menu(runs)) => runs.run(index, ctx),
|
||||
(ListOutcome::Picked(index), Popup::Pick(picker)) => picker.pick(index, ctx),
|
||||
(ListOutcome::NoMatch(text), Popup::Pick(picker)) => picker.add_tag(&text, ctx),
|
||||
(ListOutcome::Picked(_) | ListOutcome::NoMatch(_), _) => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The node menu: what you can do with a node, each with its keys.
|
||||
const NODE_MENU: &[(&str, Command)] = &[
|
||||
("edit the title", Command::EditTitle),
|
||||
("add a child", Command::AddChild),
|
||||
("add a sibling below", Command::AddBelow),
|
||||
("show the body", Command::ShowBody),
|
||||
("fold or unfold", Command::ToggleFold),
|
||||
("color…", Command::PickColor),
|
||||
("border…", Command::PickBorder),
|
||||
("style…", Command::PickStyle),
|
||||
("tags…", Command::EditTags),
|
||||
("move it", Command::MoveMode),
|
||||
("open it in the Note view", Command::ShowNote),
|
||||
("delete just this node", Command::DeleteNode),
|
||||
("delete it with its branch", Command::Operate(Operator::Delete, Target::Current)),
|
||||
];
|
||||
|
||||
/// A row of the help, before it's laid out.
|
||||
struct Entry {
|
||||
section: String,
|
||||
keys: String,
|
||||
help: String,
|
||||
run: Run,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn new(section: String, keys: String, help: String, run: Run) -> Entry {
|
||||
Entry { section, keys, help, run }
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading a key goes under in the help: its group (`g`: go to) when it
|
||||
/// has more than one key, or else where it works.
|
||||
fn section(tables: &Tables<'_>, context: Context, keys: &[Key]) -> String {
|
||||
if keys.len() > 1
|
||||
&& let Some(group) = tables.group_name(&keys[..1])
|
||||
{
|
||||
return group.to_string();
|
||||
}
|
||||
match context {
|
||||
Context::Canvas => "on the canvas",
|
||||
Context::Outline => "in the outline",
|
||||
Context::Note => "in the Note view",
|
||||
Context::Normal => "nodes",
|
||||
Context::Motions => "moving around",
|
||||
other => other.name(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What an operator with a target does, in words.
|
||||
fn operator_help(operator: Operator, target: Target) -> String {
|
||||
let named = help_for(Bound::Command(Command::Operate(operator, target)));
|
||||
if !named.is_empty() {
|
||||
return named.to_string();
|
||||
}
|
||||
let verb = help_for(Bound::Operator(operator)).trim_end_matches('…');
|
||||
format!("{verb} this node, with its branch")
|
||||
}
|
||||
|
||||
/// The keys that run `command` in these tables, for the menu.
|
||||
fn keys_for(tables: &Tables<'_>, command: Command) -> Option<String> {
|
||||
let bindings = tables.bindings();
|
||||
if let Some(binding) = bindings.iter().find(|binding| binding.bound == Bound::Command(command)) {
|
||||
return Some(notation(&binding.keys));
|
||||
}
|
||||
let Command::Operate(operator, target) = command else { return None };
|
||||
let keys = &bindings.iter().find(|binding| binding.bound == Bound::Operator(operator))?.keys;
|
||||
let target_keys = match target {
|
||||
Target::Current => keys.clone(),
|
||||
_ => {
|
||||
tables
|
||||
.targets_starting_with(&[])
|
||||
.into_iter()
|
||||
.find(|bound| bound.bound == TargetBound::Target(target))?
|
||||
.keys
|
||||
}
|
||||
};
|
||||
Some(notation(&[keys.clone(), target_keys].concat()))
|
||||
}
|
||||
|
||||
/// Whether a picker's value is the one the node has now.
|
||||
fn picked(value: &Pick, node: &crate::model::Node, tags: &Tags, current: usize) -> bool {
|
||||
match value {
|
||||
Pick::Color(color) => node.look.color == *color,
|
||||
Pick::Border(border) => node.look.border == *border,
|
||||
Pick::Style(style) => node.look.style == *style,
|
||||
Pick::Tag(tag) => tags.contains(tag),
|
||||
Pick::Document(index) => *index == current,
|
||||
}
|
||||
}
|
||||
|
||||
fn leave() -> Vec<Action> {
|
||||
vec![Action::App(AppAction::LeaveMode)]
|
||||
}
|
||||
|
||||
fn position(cell: Cell) -> Position {
|
||||
Position::new(cell.col, cell.row)
|
||||
}
|
||||
|
||||
impl Runs {
|
||||
/// Runs a row: the popup closes, and it's done as if its keys were
|
||||
/// typed.
|
||||
fn run(&self, index: usize, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let Some(run) = self.runs.get(index) else { return Vec::new() };
|
||||
let then = match run {
|
||||
Run::Nothing => return Vec::new(),
|
||||
Run::Command(command) => vec![Action::App(AppAction::Run(*command))],
|
||||
Run::Keys(keys) => vec![Action::App(AppAction::Type(keys.clone()))],
|
||||
Run::Line(line) => vec![Action::App(AppAction::RunLine(line.clone()))],
|
||||
Run::Ask(text) => vec![Action::App(AppAction::CommandLineWith(text.clone()))],
|
||||
Run::NewIdeaAt(spot) => commands::add_loose(ctx, *spot),
|
||||
};
|
||||
let mut actions = leave();
|
||||
actions.extend(then);
|
||||
actions
|
||||
}
|
||||
}
|
||||
|
||||
impl Picker {
|
||||
/// Picks a row. A tag goes on or off and the picker stays open; the
|
||||
/// rest close it.
|
||||
fn pick(&mut self, index: usize, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let id = self.node;
|
||||
let op = match self.values.get(index) {
|
||||
None => return Vec::new(),
|
||||
Some(Pick::Color(color)) => EditOp::SetColor { id, color: color.clone() },
|
||||
Some(Pick::Border(border)) => EditOp::SetBorder { id, border: *border },
|
||||
Some(Pick::Style(style)) => EditOp::SetStyle { id, style: style.clone() },
|
||||
Some(Pick::Document(at)) => {
|
||||
let mut actions = leave();
|
||||
actions.push(Action::App(AppAction::SwitchDocument(DocumentChoice::At(*at))));
|
||||
return actions;
|
||||
}
|
||||
Some(Pick::Tag(tag)) => {
|
||||
let tag = tag.clone();
|
||||
let on = !self.tags.contains(&tag);
|
||||
if on {
|
||||
self.tags.insert(tag.clone());
|
||||
} else {
|
||||
self.tags.remove(&tag);
|
||||
}
|
||||
self.list.set_row(index, tag_row(&tag, on, ctx.theme));
|
||||
let (add, remove) = if on { (vec![tag], Vec::new()) } else { (Vec::new(), vec![tag]) };
|
||||
return vec![Action::edit(EditOp::Tag { id, add, remove })];
|
||||
}
|
||||
};
|
||||
let mut actions = leave();
|
||||
actions.push(Action::edit(op));
|
||||
actions
|
||||
}
|
||||
|
||||
/// Enter on a search that matches no tag: a new tag, put on the node.
|
||||
fn add_tag(&mut self, text: &str, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
if !self.values.iter().all(|value| matches!(value, Pick::Tag(_))) {
|
||||
return Vec::new();
|
||||
}
|
||||
match Tag::parse(text) {
|
||||
Ok(tag) if self.tags.contains(&tag) => Vec::new(),
|
||||
Ok(tag) => {
|
||||
self.tags.insert(tag.clone());
|
||||
self.list.push(tag_row(&tag, true, ctx.theme));
|
||||
self.values.push(Pick::Tag(tag.clone()));
|
||||
vec![Action::edit(EditOp::Tag { id: self.node, add: vec![tag], remove: Vec::new() })]
|
||||
}
|
||||
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the tags picker, checked when the node has the tag.
|
||||
fn tag_row(tag: &Tag, on: bool, theme: &Theme) -> ListRow {
|
||||
let row = ListRow::new(vec![Span::raw(tag.to_string())], tag.name());
|
||||
if on { row.with_note(Span::styled("✓", Style::new().fg(theme.accent))) } else { row }
|
||||
}
|
||||
|
||||
impl Card {
|
||||
fn command(&mut self, typed: &Typed, registers: &mut Registers, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
if let Some(edit) = &mut self.edit {
|
||||
return edit.vim(typed, registers, ctx);
|
||||
}
|
||||
let times = isize::try_from(typed.times()).unwrap_or(1);
|
||||
let start = |at| vec![Action::App(AppAction::StartEdit(StartEdit::Body { at, in_card: true }))];
|
||||
match typed.command {
|
||||
Command::ScrollDown => self.view.scroll_by(times),
|
||||
Command::ScrollUp => self.view.scroll_by(-times),
|
||||
Command::HalfPageDown => self.view.scroll_by(self.view.half() * times),
|
||||
Command::HalfPageUp => self.view.scroll_by(-self.view.half() * times),
|
||||
Command::EditBody => return start(EditAt::End),
|
||||
Command::EditBodyAtStart => return start(EditAt::Start),
|
||||
Command::Escape => return leave(),
|
||||
_ => {}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// While the body is edited, a click in it moves the text cursor and a
|
||||
/// click outside is done editing; otherwise a click outside closes the
|
||||
/// card, and a double-click in the body edits it from there.
|
||||
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let offset_at = |card: &Card, at: Position| {
|
||||
card_text(card.node, card.edit.as_ref(), ctx).and_then(|text| card.view.offset_at(at, &text))
|
||||
};
|
||||
match gesture {
|
||||
Gesture::Press { at, .. } => {
|
||||
let at = position(at);
|
||||
match (&self.edit, offset_at(self, at)) {
|
||||
(Some(_), Some(offset)) => vec![Action::App(AppAction::TextCursor(offset))],
|
||||
(_, _) if self.view.contains(at) => Vec::new(),
|
||||
(Some(edit), None) => edit.finish(ctx),
|
||||
(None, _) => leave(),
|
||||
}
|
||||
}
|
||||
Gesture::DoublePress { at } if self.edit.is_none() => {
|
||||
let at = match offset_at(self, position(at)) {
|
||||
Some(offset) => EditAt::Offset(offset),
|
||||
None if self.view.contains(position(at)) => EditAt::End,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
vec![Action::App(AppAction::StartEdit(StartEdit::Body { at, in_card: true }))]
|
||||
}
|
||||
Gesture::Scroll { direction, .. } => {
|
||||
self.view.scroll_by(if direction == Direction::Down { 3 } else { -3 });
|
||||
Vec::new()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the card shows for a node: its body, or the body as typed.
|
||||
fn card_text<'a>(node: NodeId, edit: Option<&'a TextEdit>, ctx: &ViewContext<'a>) -> Option<CardText<'a>> {
|
||||
let found = ctx.doc.node(node)?;
|
||||
let body = match (edit, &found.content) {
|
||||
(Some(edit), _) => edit.editor.text(),
|
||||
(None, Content::Idea(idea)) => idea.body.as_str(),
|
||||
(None, Content::Portal(_)) => "",
|
||||
};
|
||||
Some(CardText {
|
||||
title: found.title().as_str(),
|
||||
color: ctx.theme.node_style(found, node == ctx.doc.root()).color,
|
||||
tags: found.tags.to_string(),
|
||||
body,
|
||||
cursor: edit.map(|edit| edit.editor.cursor()),
|
||||
typing: edit.is_some_and(|edit| edit.editor.is_typing()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,148 @@
|
||||
//! The popups driven by their key tables' commands, as the app drives them.
|
||||
|
||||
use super::*;
|
||||
use crate::input::key::KeyName;
|
||||
use crate::input::keymap::DEFAULT_LEADER;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::model::{Spot, Tags};
|
||||
use crate::view::test_support::Setup;
|
||||
|
||||
const CANVAS: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions];
|
||||
|
||||
fn typed(command: Command) -> Typed {
|
||||
Typed { command, count: None, register: None }
|
||||
}
|
||||
|
||||
/// Types a search into a list popup, the way `/` and the keys after it do.
|
||||
fn search(popup: &mut Popup, text: &str, ctx: &ViewContext<'_>) {
|
||||
let keymap = Keymap::new(DEFAULT_LEADER);
|
||||
popup.command(&typed(Command::Search), &mut Registers::default(), ctx);
|
||||
for c in text.chars() {
|
||||
popup.typing(Key::char(c), &keymap, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter, while a search is typed.
|
||||
fn enter(popup: &mut Popup, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
popup.typing(Key::plain(KeyName::Enter), &Keymap::new(DEFAULT_LEADER), ctx)
|
||||
}
|
||||
|
||||
fn app(action: AppAction) -> Action {
|
||||
Action::App(action)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_finds_keys_and_commands_and_runs_them() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let ctx = setup.ctx(&doc, g.root);
|
||||
let keymap = Keymap::new(DEFAULT_LEADER);
|
||||
let help = || Popup::help(&keymap, CANVAS, "canvas", "", &setup.theme);
|
||||
|
||||
let mut popup = help();
|
||||
search(&mut popup, "dd", &ctx);
|
||||
let dd = Command::Operate(Operator::Delete, Target::Current);
|
||||
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Run(dd))]);
|
||||
|
||||
let mut popup = help();
|
||||
search(&mut popup, "zoom in", &ctx);
|
||||
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Run(Command::ZoomIn))]);
|
||||
|
||||
// A key that waits for more is typed, so the rest can follow.
|
||||
let mut popup = help();
|
||||
search(&mut popup, "mark this", &ctx);
|
||||
let m = vec![Key::char('m')];
|
||||
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Type(m))]);
|
||||
|
||||
// A command that needs more opens the command line with it typed.
|
||||
let mut popup = Popup::help(&keymap, CANVAS, "canvas", ":color", &setup.theme);
|
||||
let confirm = popup.command(&typed(Command::Confirm), &mut Registers::default(), &ctx);
|
||||
assert_eq!(confirm, [app(AppAction::LeaveMode), app(AppAction::CommandLineWith("color ".into()))]);
|
||||
assert_eq!(popup.label(), "HELP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_shows_the_keys_that_do_the_same() {
|
||||
let keymap = Keymap::new(DEFAULT_LEADER);
|
||||
let tables = keymap.tables(CANVAS);
|
||||
assert_eq!(keys_for(&tables, Command::AddChild).as_deref(), Some("a"));
|
||||
assert_eq!(keys_for(&tables, Command::PickColor).as_deref(), Some("<Space>c"));
|
||||
assert_eq!(keys_for(&tables, Command::Operate(Operator::Delete, Target::Current)).as_deref(), Some("dd"));
|
||||
|
||||
// On the empty canvas, the first thing is a new idea right there.
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let ctx = setup.ctx(&doc, g.root);
|
||||
let spot = CanvasPos::new(60, 30);
|
||||
let mut menu =
|
||||
Popup::menu(MenuFor::Canvas(spot), "canvas", Position::new(5, 5), &keymap, CANVAS, &setup.theme);
|
||||
let actions = menu.command(&typed(Command::Confirm), &mut Registers::default(), &ctx);
|
||||
assert_eq!(actions[0], app(AppAction::LeaveMode));
|
||||
let Action::Doc(crate::app::action::DocAction::Edit { op, .. }) = &actions[1] else { panic!("an add") };
|
||||
let EditOp::Add { node, spot: Spot::Loose } = op.as_ref() else { panic!("a loose node") };
|
||||
assert_eq!(node.pos, spot, "where the click was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pickers_set_what_is_picked() {
|
||||
let ((mut doc, g), setup) = (garden(), Setup::default());
|
||||
doc.apply(&EditOp::SetTags { id: g.beds, tags: Tags::parse("#wood").unwrap() }).unwrap();
|
||||
let ctx = setup.ctx(&doc, g.soil);
|
||||
let pick = |kind| Popup::picker(kind, &ctx, &[], 0).unwrap();
|
||||
|
||||
let mut colors = pick(PickKind::Color);
|
||||
search(&mut colors, "teal", &ctx);
|
||||
let teal = ColorName::parse("teal").ok();
|
||||
assert_eq!(
|
||||
enter(&mut colors, &ctx),
|
||||
[app(AppAction::LeaveMode), Action::edit(EditOp::SetColor { id: g.soil, color: teal })]
|
||||
);
|
||||
|
||||
let mut borders = pick(PickKind::Border);
|
||||
search(&mut borders, "dashed", &ctx);
|
||||
let dashed = Some(Border::Dashed);
|
||||
assert_eq!(
|
||||
enter(&mut borders, &ctx),
|
||||
[app(AppAction::LeaveMode), Action::edit(EditOp::SetBorder { id: g.soil, border: dashed })]
|
||||
);
|
||||
|
||||
let no_styles = Popup::picker(PickKind::Style, &ctx, &[], 0).unwrap_err();
|
||||
assert_eq!(no_styles.text, "this theme has no named styles yet: theme files come in stage 8");
|
||||
|
||||
// Tags go on and off, and the picker stays open; a new one is added.
|
||||
let mut tags = pick(PickKind::Tags);
|
||||
let wood = Tag::parse("wood").unwrap();
|
||||
let on = Action::edit(EditOp::Tag { id: g.soil, add: vec![wood.clone()], remove: Vec::new() });
|
||||
let off = Action::edit(EditOp::Tag { id: g.soil, add: Vec::new(), remove: vec![wood] });
|
||||
let mut registers = Registers::default();
|
||||
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [on]);
|
||||
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [off]);
|
||||
search(&mut tags, "spring", &ctx);
|
||||
let spring = Tag::parse("spring").unwrap();
|
||||
assert_eq!(
|
||||
enter(&mut tags, &ctx),
|
||||
[Action::edit(EditOp::Tag { id: g.soil, add: vec![spring], remove: Vec::new() })]
|
||||
);
|
||||
|
||||
// Documents: it starts on the one shown; the next one is a j away.
|
||||
let documents = ["Garden plans".to_string(), "Kitchen".to_string()];
|
||||
let mut picker = Popup::picker(PickKind::Document, &ctx, &documents, 0).unwrap();
|
||||
picker.command(&typed(Command::Move(crate::input::command::Motion::Down)), &mut registers, &ctx);
|
||||
assert_eq!(
|
||||
picker.command(&typed(Command::Confirm), &mut registers, &ctx),
|
||||
[app(AppAction::LeaveMode), app(AppAction::SwitchDocument(DocumentChoice::At(1)))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_card_edits_the_body_and_closes() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let ctx = setup.ctx(&doc, g.beds);
|
||||
let mut card = Popup::card(g.beds);
|
||||
assert_eq!((card.label(), card.contexts()), ("CARD", &[Context::Card][..]));
|
||||
let mut registers = Registers::default();
|
||||
let start = StartEdit::Body { at: EditAt::End, in_card: true };
|
||||
assert_eq!(
|
||||
card.command(&typed(Command::EditBody), &mut registers, &ctx),
|
||||
[app(AppAction::StartEdit(start))]
|
||||
);
|
||||
assert_eq!(card.command(&typed(Command::Escape), &mut registers, &ctx), [app(AppAction::LeaveMode)]);
|
||||
}
|
||||
+180
-70
@@ -1,5 +1,6 @@
|
||||
//! Editing a node's title: the [`TextEditor`] does the typing and the vim
|
||||
//! keys, and this is what the edit means for the document.
|
||||
//! Editing a node's text: its title, its tags or its body. 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
|
||||
@@ -13,10 +14,13 @@ 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::model::{Body, Content, EditOp, Group, NodeId, Spot, Tags, Title};
|
||||
use crate::ui::text_editor::{Reply, TextEditor, Typing};
|
||||
use crate::view::ViewContext;
|
||||
|
||||
/// How many spaces Tab types in a body (a body keeps no tab characters).
|
||||
const TAB_WIDTH: usize = 4;
|
||||
|
||||
/// Where the text cursor starts when an edit begins.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EditAt {
|
||||
@@ -24,64 +28,128 @@ pub enum EditAt {
|
||||
End,
|
||||
/// Before the first one (`I`).
|
||||
Start,
|
||||
/// With the title cleared, to type it again (`cc`, `S`).
|
||||
/// With the text 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.
|
||||
/// Which of a node's texts is being edited.
|
||||
#[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>,
|
||||
pub enum Field {
|
||||
Title,
|
||||
/// The tags, as text: `#todo #idea`.
|
||||
Tags,
|
||||
Body,
|
||||
}
|
||||
|
||||
/// A title being edited.
|
||||
/// How an edit of the cursor's node starts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StartEdit {
|
||||
/// Its title. `new_node`: the node was just added by `o`, `O` or `a`,
|
||||
/// and this is the add's undo step, which the edit joins (the node goes
|
||||
/// away if it's left empty).
|
||||
Title {
|
||||
at: EditAt,
|
||||
new_node: Option<Group>,
|
||||
},
|
||||
Tags(EditAt),
|
||||
/// Its body. `in_card`: in the `K` card, which stays open around it.
|
||||
Body {
|
||||
at: EditAt,
|
||||
in_card: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl StartEdit {
|
||||
/// Editing an existing title.
|
||||
pub fn title(at: EditAt) -> StartEdit {
|
||||
StartEdit::Title { at, new_node: None }
|
||||
}
|
||||
|
||||
pub fn field(self) -> Field {
|
||||
match self {
|
||||
StartEdit::Title { .. } => Field::Title,
|
||||
StartEdit::Tags(_) => Field::Tags,
|
||||
StartEdit::Body { .. } => Field::Body,
|
||||
}
|
||||
}
|
||||
|
||||
fn at(self) -> EditAt {
|
||||
match self {
|
||||
StartEdit::Title { at, .. } | StartEdit::Tags(at) | StartEdit::Body { at, .. } => at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's text being edited.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextEdit {
|
||||
/// The node whose title it is.
|
||||
/// The node whose text it is.
|
||||
pub node: NodeId,
|
||||
pub field: Field,
|
||||
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.
|
||||
/// The text before the edit, to put back if what's typed won't do.
|
||||
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.
|
||||
/// Starts editing the cursor's node. A portal's title is its
|
||||
/// document's name and its body is that document's, so neither is
|
||||
/// 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"));
|
||||
let new_node = match start {
|
||||
StartEdit::Title { new_node, .. } => new_node,
|
||||
StartEdit::Tags(_) | StartEdit::Body { .. } => None,
|
||||
};
|
||||
// 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
|
||||
let field = start.field();
|
||||
let original = match (field, &node.content) {
|
||||
(Field::Tags, _) => node.tags.to_string(),
|
||||
// A new node's title is a placeholder until something is typed.
|
||||
(Field::Title, Content::Idea(_)) if new_node.is_some() => String::new(),
|
||||
(Field::Title, Content::Idea(idea)) => idea.title.to_string(),
|
||||
(Field::Body, Content::Idea(idea)) => idea.body.as_str().to_string(),
|
||||
(Field::Title, Content::Portal(_)) => {
|
||||
return Err(Message::info(
|
||||
"a portal shows its document's name: rename that document instead",
|
||||
));
|
||||
}
|
||||
(Field::Body, Content::Portal(_)) => {
|
||||
return Err(Message::info("a portal has no body of its own: its document's root has one"));
|
||||
}
|
||||
};
|
||||
editor.command(&Typed { command, count: None, register: None }, None);
|
||||
let new_node = start.new_node.is_some();
|
||||
let mut editor = TextEditor::new(&original, field == Field::Body);
|
||||
// Typing starts the way vim's commands start it.
|
||||
let (command, count) = match start.at() {
|
||||
EditAt::Start => (Command::InsertAtStart, None),
|
||||
EditAt::Retype => {
|
||||
// `cc`, with a count in a body: all of its lines.
|
||||
let lines = original.lines().count().max(1);
|
||||
let count = (field == Field::Body).then(|| u32::try_from(lines).unwrap_or(u32::MAX));
|
||||
(Command::Operate(Operator::Change, Target::Current), count)
|
||||
}
|
||||
EditAt::Offset(at) if at < original.len() => {
|
||||
editor.set_cursor(at);
|
||||
(Command::InsertBefore, None)
|
||||
}
|
||||
EditAt::End | EditAt::Offset(_) => {
|
||||
// On the last line, in a body.
|
||||
editor.set_cursor(original.len());
|
||||
(Command::InsertAtEnd, None)
|
||||
}
|
||||
};
|
||||
editor.command(&Typed { command, count, register: None }, None);
|
||||
Ok(TextEdit {
|
||||
node: ctx.cursor,
|
||||
field,
|
||||
editor,
|
||||
new_node,
|
||||
group: start.new_node.unwrap_or_default(),
|
||||
new_node: new_node.is_some(),
|
||||
group: new_node.unwrap_or_default(),
|
||||
original,
|
||||
})
|
||||
}
|
||||
@@ -89,32 +157,43 @@ impl TextEdit {
|
||||
/// 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 {
|
||||
let typed: Vec<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::Confirm)) => match self.field {
|
||||
Field::Title => return self.enter(ctx),
|
||||
Field::Tags => return self.finish(ctx),
|
||||
// In a body, Enter starts a new line.
|
||||
Field::Body => vec![Typing::Char('\n')],
|
||||
},
|
||||
Some(Bound::Command(Command::Operate(operator @ (Operator::Indent | Operator::Outdent), _))) => {
|
||||
return self.change_level(ctx, operator == Operator::Indent);
|
||||
match (self.field, operator) {
|
||||
(Field::Title, _) => return self.change_level(ctx, operator == Operator::Indent),
|
||||
(Field::Body, Operator::Indent) => vec![Typing::Char(' '); TAB_WIDTH],
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
}
|
||||
Some(Bound::Command(command)) => Typing::Command(command),
|
||||
Some(Bound::Command(command)) => vec![Typing::Command(command)],
|
||||
Some(_) => return Vec::new(),
|
||||
None => match key.as_char() {
|
||||
Some(c) => Typing::Char(c),
|
||||
Some(c) => vec![Typing::Char(c)],
|
||||
None => return Vec::new(),
|
||||
},
|
||||
};
|
||||
self.editor.type_in(typing);
|
||||
for typing in typed {
|
||||
self.editor.type_in(typing);
|
||||
}
|
||||
self.follow(ctx)
|
||||
}
|
||||
|
||||
/// A command typed with vim keys inside the title.
|
||||
/// A command typed with vim keys inside the text.
|
||||
pub fn vim(&mut self, typed: &Typed, registers: &mut Registers, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
match typed.command {
|
||||
Command::Escape => return self.finish(ctx),
|
||||
match (typed.command, self.field) {
|
||||
(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 => {
|
||||
(Command::AddBelow | Command::AddAbove, Field::Title) => {
|
||||
let spot = if typed.command == Command::AddBelow {
|
||||
Spot::After(self.node)
|
||||
} else {
|
||||
@@ -127,6 +206,9 @@ impl TextEdit {
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
(Command::AddBelow | Command::AddAbove, Field::Tags) => {
|
||||
return vec![Action::say(Message::info("tags are one line: o and O add lines in a body"))];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let paste = registers.get(typed.register).cloned();
|
||||
@@ -140,30 +222,43 @@ impl TextEdit {
|
||||
actions
|
||||
}
|
||||
|
||||
/// The edit is done. The title stays as typed; left empty, the old one
|
||||
/// comes back (and a new node goes away instead).
|
||||
/// The edit is done. The text stays as typed, unless it won't do: an
|
||||
/// empty title or tags that aren't tags come back as they were (and a
|
||||
/// new node left empty 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));
|
||||
match self.field {
|
||||
Field::Title 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.edit(EditOp::SetTitle { id: self.node, title }));
|
||||
}
|
||||
actions.push(Action::say(Message::info("a title can't be empty: it's back as it was")));
|
||||
}
|
||||
actions.push(Action::say(Message::info("a title can't be empty: it's back as it was")));
|
||||
}
|
||||
Field::Tags => {
|
||||
if let Err(error) = Tags::parse(self.editor.text()) {
|
||||
if let Ok(tags) = Tags::parse(&self.original) {
|
||||
actions.push(self.edit(EditOp::SetTags { id: self.node, tags }));
|
||||
}
|
||||
let text = format!("{error}: the tags are back as they were");
|
||||
actions.push(Action::say(Message::problem(text)));
|
||||
}
|
||||
}
|
||||
Field::Title | Field::Body => {}
|
||||
}
|
||||
actions.push(Action::App(AppAction::LeaveMode));
|
||||
actions.push(Action::App(AppAction::EndEdit));
|
||||
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.
|
||||
/// Enter in a title: done. 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);
|
||||
@@ -173,8 +268,8 @@ impl TextEdit {
|
||||
actions
|
||||
}
|
||||
|
||||
/// Tab and Shift-Tab: the new idea goes under the one above, or out a
|
||||
/// level.
|
||||
/// Tab and Shift-Tab in a title: 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(
|
||||
@@ -188,18 +283,33 @@ impl TextEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document follows the text: the title becomes what's typed (once
|
||||
/// it's a title at all: not empty).
|
||||
/// The document follows the text, once it's something the node can
|
||||
/// hold: a title that isn't empty, tags that are all tags.
|
||||
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)]
|
||||
let Some(node) = ctx.doc.node(self.node) else { return Vec::new() };
|
||||
let text = self.editor.text();
|
||||
let op = match self.field {
|
||||
Field::Title => match Title::parse(text) {
|
||||
Ok(title) if *node.title() != title => EditOp::SetTitle { id: self.node, title },
|
||||
_ => return Vec::new(),
|
||||
},
|
||||
Field::Tags => match Tags::parse(text) {
|
||||
Ok(tags) if node.tags != tags => EditOp::SetTags { id: self.node, tags },
|
||||
_ => return Vec::new(),
|
||||
},
|
||||
Field::Body => {
|
||||
let body = Body::new(text);
|
||||
match &node.content {
|
||||
Content::Idea(idea) if idea.body != body => EditOp::SetBody { id: self.node, body },
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
}
|
||||
};
|
||||
vec![self.edit(op)]
|
||||
}
|
||||
|
||||
fn set_title(&self, title: Title) -> Action {
|
||||
Action::edit_in(EditOp::SetTitle { id: self.node, title }, self.group)
|
||||
fn edit(&self, op: EditOp) -> Action {
|
||||
Action::edit_in(op, self.group)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Editing titles: rapid entry, empty titles, `o` inside a title, and the
|
||||
//! document following the text.
|
||||
//! Editing a node's text: rapid entry, empty titles, `o` inside a title,
|
||||
//! bodies and tags, and the document following the text.
|
||||
|
||||
use super::*;
|
||||
use crate::app::buffer::Buffer;
|
||||
@@ -91,7 +91,7 @@ impl App {
|
||||
Err(message) => self.said.push(message.text),
|
||||
}
|
||||
}
|
||||
Action::App(AppAction::LeaveMode) => self.mode = Mode::Normal,
|
||||
Action::App(AppAction::LeaveMode | AppAction::EndEdit) => self.mode = Mode::Normal,
|
||||
Action::App(AppAction::Say(message)) => self.said.push(message.text),
|
||||
Action::App(other) => panic!("unexpected here: {other:?}"),
|
||||
}
|
||||
@@ -204,3 +204,76 @@ fn shift_tab_takes_a_new_idea_out_a_level() {
|
||||
app.press("oMore<S-Tab>");
|
||||
assert_eq!(app.said.last().map(String::as_str), Some("this node is right under the root already"));
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The body of a node.
|
||||
fn body(&self, id: NodeId) -> &str {
|
||||
match &self.doc().node(id).unwrap().content {
|
||||
Content::Idea(idea) => idea.body.as_str(),
|
||||
Content::Portal(_) => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(&self, id: NodeId) -> String {
|
||||
self.doc().node(id).unwrap().tags.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bodies_take_lines_and_tab() {
|
||||
let (doc, g) = garden();
|
||||
let mut app = App::new(doc, g.beds);
|
||||
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Body { at: EditAt::End, in_card: false }))]);
|
||||
app.press("Cedar.<CR><Tab>Three of them.");
|
||||
assert_eq!(app.body(g.beds), "Cedar.\n Three of them.", "Enter starts a line, Tab types spaces");
|
||||
// Vim keys work across the lines: k goes up a line, o opens one below.
|
||||
app.press("<Esc>kA!<Esc>oFrom the mill.<Esc><Esc>");
|
||||
assert_eq!(app.body(g.beds), "Cedar.!\nFrom the mill.\n Three of them.");
|
||||
assert_eq!(app.mode.label(), "NORMAL");
|
||||
app.buffer.apply(DocAction::Undo(1));
|
||||
assert_eq!(app.body(g.beds), "", "the whole edit is one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retyping_a_body_clears_every_line() {
|
||||
let (doc, g) = garden();
|
||||
let mut app = App::new(doc, g.compost);
|
||||
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Body { at: EditAt::Retype, in_card: false }))]);
|
||||
app.press("Ask on Sunday.<Esc><Esc>");
|
||||
assert_eq!(app.body(g.compost), "Ask on Sunday.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_follow_the_text_and_bad_ones_come_back() {
|
||||
let (doc, g) = garden();
|
||||
let mut app = App::new(doc, g.soil);
|
||||
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Tags(EditAt::End)))]);
|
||||
app.press(" #Spring");
|
||||
assert_eq!(app.tags(g.soil), "#spring", "tags are lowercase");
|
||||
app.press("<CR>");
|
||||
assert_eq!(app.mode.label(), "NORMAL", "Enter is done, in tags");
|
||||
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Tags(EditAt::End)))]);
|
||||
app.press(" #<Esc><Esc>");
|
||||
assert_eq!(app.tags(g.soil), "#spring");
|
||||
assert_eq!(
|
||||
app.said.last().map(String::as_str),
|
||||
Some("a tag can't be empty: the tags are back as they were")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_portal_keeps_its_title_and_body() {
|
||||
let (mut doc, g) = garden();
|
||||
let portal = crate::model::test_support::portal(&mut doc, g.root);
|
||||
let mut app = App::new(doc, portal);
|
||||
app.press("i");
|
||||
assert_eq!(app.mode.label(), "NORMAL");
|
||||
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Body { at: EditAt::End, in_card: false }))]);
|
||||
assert_eq!(
|
||||
app.said,
|
||||
[
|
||||
"a portal shows its document's name: rename that document instead",
|
||||
"a portal has no body of its own: its document's root has one"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -396,7 +396,8 @@ fn board_columns(word: &str) -> Result<BoardColumns, CallError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn border(word: &str) -> Result<Border, CallError> {
|
||||
/// A border by name, as `:border` takes it.
|
||||
pub fn border(word: &str) -> Result<Border, CallError> {
|
||||
match word.to_ascii_lowercase().as_str() {
|
||||
"rounded" => Ok(Border::Rounded),
|
||||
"square" => Ok(Border::Square),
|
||||
|
||||
+59
-2
@@ -27,6 +27,8 @@ pub enum Context {
|
||||
Connect,
|
||||
/// Lists in popups: pickers, menus, the help.
|
||||
List,
|
||||
/// The `K` card: scrolling it, and editing the body in it.
|
||||
Card,
|
||||
/// Vim keys inside a title or body being edited (after Esc).
|
||||
Text,
|
||||
/// Typing a title or body. Keys these tables don't name type
|
||||
@@ -35,7 +37,7 @@ pub enum Context {
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub const ALL: [Context; 11] = [
|
||||
pub const ALL: [Context; 12] = [
|
||||
Context::Motions,
|
||||
Context::Normal,
|
||||
Context::Canvas,
|
||||
@@ -45,6 +47,7 @@ impl Context {
|
||||
Context::Move,
|
||||
Context::Connect,
|
||||
Context::List,
|
||||
Context::Card,
|
||||
Context::Text,
|
||||
Context::Insert,
|
||||
];
|
||||
@@ -61,6 +64,7 @@ impl Context {
|
||||
Context::Move => "move",
|
||||
Context::Connect => "connect",
|
||||
Context::List => "list",
|
||||
Context::Card => "card",
|
||||
Context::Text => "text",
|
||||
Context::Insert => "insert",
|
||||
}
|
||||
@@ -77,11 +81,13 @@ impl Context {
|
||||
Context::Motions => MOTIONS,
|
||||
Context::Normal => NORMAL,
|
||||
Context::Canvas => CANVAS,
|
||||
Context::Outline | Context::Note => &[],
|
||||
Context::Outline => OUTLINE,
|
||||
Context::Note => NOTE,
|
||||
Context::Visual => VISUAL,
|
||||
Context::Move => MOVE,
|
||||
Context::Connect => CONNECT,
|
||||
Context::List => LIST,
|
||||
Context::Card => CARD,
|
||||
Context::Text => TEXT,
|
||||
Context::Insert => INSERT,
|
||||
}
|
||||
@@ -113,6 +119,7 @@ pub const SITUATIONS: &[(&str, &[Context])] = &[
|
||||
("move mode", &[Context::Move]),
|
||||
("connect mode", &[Context::Connect, Context::Canvas, Context::Motions]),
|
||||
("lists", &[Context::List]),
|
||||
("the K card", &[Context::Card]),
|
||||
("vim keys inside text", &[Context::Text]),
|
||||
("typing", &[Context::Insert]),
|
||||
];
|
||||
@@ -256,6 +263,56 @@ const NORMAL: &[DefaultBinding] = &[
|
||||
/// Normal mode on the canvas.
|
||||
const CANVAS: &[DefaultBinding] = &[bind("+", C::ZoomIn), bind("-", C::ZoomOut)];
|
||||
|
||||
/// Normal mode in the outline, on top of `Normal`: hjkl follow the rows.
|
||||
const OUTLINE: &[DefaultBinding] = &[
|
||||
bind_help("j", C::Move(M::Down), "the next row"),
|
||||
bind_help("<Down>", C::Move(M::Down), "the next row"),
|
||||
bind_help("k", C::Move(M::Up), "the row above"),
|
||||
bind_help("<Up>", C::Move(M::Up), "the row above"),
|
||||
bind_help("h", C::Move(M::Left), "the parent"),
|
||||
bind_help("<Left>", C::Move(M::Left), "the parent"),
|
||||
bind_help("l", C::Move(M::Right), "the first child"),
|
||||
bind_help("<Right>", C::Move(M::Right), "the first child"),
|
||||
bind("zb", C::ToggleBodies),
|
||||
];
|
||||
|
||||
/// Normal mode in the Note view, on top of `Normal`: j and k go from part
|
||||
/// to part of the note, and the editing keys edit the part.
|
||||
const NOTE: &[DefaultBinding] = &[
|
||||
bind_help("j", C::Move(M::Down), "the next part"),
|
||||
bind_help("<Down>", C::Move(M::Down), "the next part"),
|
||||
bind_help("k", C::Move(M::Up), "the part above"),
|
||||
bind_help("<Up>", C::Move(M::Up), "the part above"),
|
||||
bind_help("h", C::Move(M::Left), "the sibling before"),
|
||||
bind_help("<Left>", C::Move(M::Left), "the sibling before"),
|
||||
bind_help("l", C::Move(M::Right), "the sibling after"),
|
||||
bind_help("<Right>", C::Move(M::Right), "the sibling after"),
|
||||
bind_help("<CR>", C::Activate, "edit this part, or go to it"),
|
||||
bind_help("i", C::EditTitle, "edit this part"),
|
||||
bind_help("A", C::EditTitle, "edit this part"),
|
||||
bind_help("I", C::EditTitleAtStart, "edit this part from its start"),
|
||||
bind_help("cc", C::RetypeTitle, "retype this part"),
|
||||
bind_help("S", C::RetypeTitle, "retype this part"),
|
||||
];
|
||||
|
||||
/// The `K` card.
|
||||
const CARD: &[DefaultBinding] = &[
|
||||
bind("j", C::ScrollDown),
|
||||
bind("<Down>", C::ScrollDown),
|
||||
bind("k", C::ScrollUp),
|
||||
bind("<Up>", C::ScrollUp),
|
||||
bind("<C-d>", C::HalfPageDown),
|
||||
bind("<C-u>", C::HalfPageUp),
|
||||
bind("i", C::EditBody),
|
||||
bind("A", C::EditBody),
|
||||
bind("<CR>", C::EditBody),
|
||||
bind("I", C::EditBodyAtStart),
|
||||
bind_help("K", C::Escape, "close"),
|
||||
bind_help("q", C::Escape, "close"),
|
||||
bind_help("<Esc>", C::Escape, "close"),
|
||||
bind_help("<C-c>", C::Escape, "close"),
|
||||
];
|
||||
|
||||
/// Visual mode: operators act on the selection right away.
|
||||
const VISUAL: &[DefaultBinding] = &[
|
||||
bind("o", C::SwapEnds),
|
||||
|
||||
@@ -238,6 +238,8 @@ pub enum Command {
|
||||
CloseAllFolds,
|
||||
ToggleFocus,
|
||||
ShowBody,
|
||||
/// The outline: every body under its title, or none.
|
||||
ToggleBodies,
|
||||
|
||||
// Creating and editing.
|
||||
AddBelow,
|
||||
@@ -246,6 +248,10 @@ pub enum Command {
|
||||
EditTitle,
|
||||
EditTitleAtStart,
|
||||
RetypeTitle,
|
||||
/// In the K card: type in the body, at its end.
|
||||
EditBody,
|
||||
/// In the K card: type in the body, at its start.
|
||||
EditBodyAtStart,
|
||||
/// `x`: delete just this node; its children move up.
|
||||
DeleteNode,
|
||||
PasteAfter,
|
||||
@@ -436,6 +442,7 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
info("fold-close-all", C::CloseAllFolds, "fold everything"),
|
||||
info("focus", C::ToggleFocus, "focus: fold the other branches"),
|
||||
info("show-body", C::ShowBody, "show the body"),
|
||||
info("toggle-bodies", C::ToggleBodies, "show every body under its title, or none"),
|
||||
// Creating and editing.
|
||||
info("add-below", C::AddBelow, "new sibling below"),
|
||||
info("add-above", C::AddAbove, "new sibling above"),
|
||||
@@ -443,6 +450,8 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
info("edit-title", C::EditTitle, "edit the title"),
|
||||
info("edit-title-start", C::EditTitleAtStart, "edit the title from its start"),
|
||||
info("retype-title", C::RetypeTitle, "retype the title"),
|
||||
info("edit-body", C::EditBody, "edit the body"),
|
||||
info("edit-body-start", C::EditBodyAtStart, "edit the body from its start"),
|
||||
info("delete-node", C::DeleteNode, "delete just this node (children move up)"),
|
||||
operator("delete", Op::Delete, "delete…"),
|
||||
operator("yank", Op::Yank, "yank (copy)…"),
|
||||
|
||||
+1
-1
@@ -373,7 +373,7 @@ mod tests {
|
||||
"[normal] \"<Spcae>\": there's no key called <Spcae>",
|
||||
"[normal] \"X\": there's no command \"split-vert\"; did you mean \"split-vertical\"?",
|
||||
"[normal] \"Y\": the command should be a name in quotes, like \"delete-branch\"",
|
||||
"[nromal] isn't a section: use one of motions, normal, canvas, outline, note, visual, move, connect, list, text, insert",
|
||||
"[nromal] isn't a section: use one of motions, normal, canvas, outline, note, visual, move, connect, list, card, text, insert",
|
||||
]
|
||||
);
|
||||
let hidden = "in normal mode on the canvas, g hides gt: typing g runs \"help: every key\" first";
|
||||
|
||||
@@ -84,4 +84,15 @@ pub(crate) mod test_support {
|
||||
.expect("the garden builds");
|
||||
(doc, ids)
|
||||
}
|
||||
|
||||
/// Adds a portal to a "Kitchen" document under `parent`.
|
||||
pub fn portal(doc: &mut Document, parent: NodeId) -> NodeId {
|
||||
let portal = Portal {
|
||||
path: LinkPath::parse("kitchen.cum").expect("a path"),
|
||||
uid: DocUid::parse("c01d5e7aa0f3b912").expect("a uid"),
|
||||
title: title("Kitchen"),
|
||||
};
|
||||
let node = Node::portal(portal, CanvasPos::new(22, 12));
|
||||
doc.edit(|e| e.add(node, Spot::LastChildOf(parent))).expect("the portal goes in").0
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -9,7 +9,7 @@ use super::ids::NodeId;
|
||||
use super::link::{Link, LinkKey};
|
||||
use super::look::Border;
|
||||
use super::node::{Content, Fold, Node};
|
||||
use super::text::{ColorName, Tag, Title};
|
||||
use super::text::{Body, ColorName, StyleName, Tag, Tags, Title};
|
||||
|
||||
/// One change to a document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -53,17 +53,32 @@ pub enum EditOp {
|
||||
id: NodeId,
|
||||
border: Option<Border>,
|
||||
},
|
||||
/// A node's named style from the theme (`None`: no style).
|
||||
SetStyle {
|
||||
id: NodeId,
|
||||
style: Option<StyleName>,
|
||||
},
|
||||
/// An idea's title (a portal's title comes from its document).
|
||||
SetTitle {
|
||||
id: NodeId,
|
||||
title: Title,
|
||||
},
|
||||
/// An idea's body (a portal has none of its own).
|
||||
SetBody {
|
||||
id: NodeId,
|
||||
body: Body,
|
||||
},
|
||||
/// Tags added to and removed from a node.
|
||||
Tag {
|
||||
id: NodeId,
|
||||
add: Vec<Tag>,
|
||||
remove: Vec<Tag>,
|
||||
},
|
||||
/// All of a node's tags at once, as typed in the Note view.
|
||||
SetTags {
|
||||
id: NodeId,
|
||||
tags: Tags,
|
||||
},
|
||||
Link(Link),
|
||||
Unlink(Vec<LinkKey>),
|
||||
}
|
||||
@@ -134,6 +149,9 @@ fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result<Applied, EditError> {
|
||||
EditOp::SetBorder { id, border } => {
|
||||
edit.update(*id, |node| node.look.border = *border).map(|()| Applied::Changed)
|
||||
}
|
||||
EditOp::SetStyle { id, style } => {
|
||||
edit.update(*id, |node| node.look.style = style.clone()).map(|()| Applied::Changed)
|
||||
}
|
||||
EditOp::SetTitle { id, title } => edit
|
||||
.update(*id, |node| {
|
||||
if let Content::Idea(idea) = &mut node.content {
|
||||
@@ -141,6 +159,16 @@ fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result<Applied, EditError> {
|
||||
}
|
||||
})
|
||||
.map(|()| Applied::Changed),
|
||||
EditOp::SetBody { id, body } => edit
|
||||
.update(*id, |node| {
|
||||
if let Content::Idea(idea) = &mut node.content {
|
||||
idea.body = body.clone();
|
||||
}
|
||||
})
|
||||
.map(|()| Applied::Changed),
|
||||
EditOp::SetTags { id, tags } => {
|
||||
edit.update(*id, |node| node.tags = tags.clone()).map(|()| Applied::Changed)
|
||||
}
|
||||
EditOp::Tag { id, add, remove } => edit
|
||||
.update(*id, |node| {
|
||||
for tag in add {
|
||||
@@ -202,4 +230,18 @@ mod tests {
|
||||
assert_eq!(doc.apply(&op), Err(EditError::NoSuchNode(NodeId::new(99))));
|
||||
assert_eq!(doc, start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bodies_tags_and_styles() {
|
||||
let (mut doc, g) = garden();
|
||||
let body = Body::new("Cedar.\nThree of them.");
|
||||
doc.apply(&EditOp::SetBody { id: g.beds, body: body.clone() }).unwrap();
|
||||
let tags = Tags::parse("#wood #todo").unwrap();
|
||||
doc.apply(&EditOp::SetTags { id: g.beds, tags: tags.clone() }).unwrap();
|
||||
let style = StyleName::parse("question").ok();
|
||||
doc.apply(&EditOp::SetStyle { id: g.beds, style: style.clone() }).unwrap();
|
||||
let node = doc.node(g.beds).unwrap();
|
||||
let Content::Idea(idea) = &node.content else { panic!("an idea") };
|
||||
assert_eq!((&idea.body, &node.tags, &node.look.style), (&body, &tags, &style));
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
//! The `K` card: a node's body in a box beside it, to read (and edit)
|
||||
//! without leaving the view you're in.
|
||||
//!
|
||||
//! ```text
|
||||
//! ╭ Raised beds ──────────────────╮
|
||||
//! ┏━━━━━━━━━━━┓ │ #todo │
|
||||
//! ┃ Raised b… ┃ │ Cedar, three of them. │
|
||||
//! ┗━━━━━━━━━━━┛ │ 40 cm high. │
|
||||
//! ╰ j/k scroll · i edit · K close ─╯
|
||||
//! ```
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Clear};
|
||||
|
||||
use super::text_editor::layout;
|
||||
use super::theme::Theme;
|
||||
|
||||
/// How wide the card is, at most.
|
||||
const WIDTH: u16 = 48;
|
||||
|
||||
/// What a card shows.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CardText<'a> {
|
||||
pub title: &'a str,
|
||||
/// The node's color, for the title (`Reset` when it has none).
|
||||
pub color: Color,
|
||||
pub tags: String,
|
||||
/// The body, as typed while it's edited.
|
||||
pub body: &'a str,
|
||||
/// While the body is edited: where the text cursor is (a byte offset).
|
||||
pub cursor: Option<usize>,
|
||||
/// Typing, rather than using vim keys, while it's edited.
|
||||
pub typing: bool,
|
||||
}
|
||||
|
||||
/// Where the card is and how far it's scrolled.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CardView {
|
||||
area: Rect,
|
||||
scroll: usize,
|
||||
/// The rows the body wraps into, as laid out last.
|
||||
rows: Vec<Range<usize>>,
|
||||
/// Where the body starts on screen (after the tags).
|
||||
body_top: u16,
|
||||
}
|
||||
|
||||
impl CardView {
|
||||
/// Works out where the card goes: beside `anchor` (the node it's about,
|
||||
/// on screen) when there's room, else in the middle of `screen`. While
|
||||
/// the body is edited, it scrolls to keep the text cursor in view.
|
||||
pub fn layout(&mut self, screen: Rect, anchor: Option<Rect>, text: &CardText<'_>) {
|
||||
let width = WIDTH.min(screen.width.saturating_sub(2)).max(12);
|
||||
let text_width = usize::from(width.saturating_sub(4));
|
||||
self.rows = layout::rows(text.body, text_width);
|
||||
let tags = u16::from(!text.tags.is_empty());
|
||||
let wanted = u16::try_from(self.rows.len()).unwrap_or(u16::MAX).saturating_add(tags + 2);
|
||||
let height = wanted.min((screen.height * 2 / 3).max(5)).min(screen.height);
|
||||
let (x, y) = match anchor {
|
||||
Some(anchor) => {
|
||||
let right = anchor.right() + 1;
|
||||
let x = if right + width <= screen.right() {
|
||||
right
|
||||
} else if anchor.x > screen.x + width {
|
||||
anchor.x - width - 1
|
||||
} else {
|
||||
anchor.x.min(screen.right().saturating_sub(width))
|
||||
};
|
||||
(x, anchor.y.min(screen.bottom().saturating_sub(height)).max(screen.y))
|
||||
}
|
||||
None => {
|
||||
(screen.x + (screen.width - width) / 2, screen.y + (screen.height.saturating_sub(height)) / 2)
|
||||
}
|
||||
};
|
||||
self.area = Rect { x, y, width, height };
|
||||
self.body_top = y + 1 + tags;
|
||||
let rows_shown = usize::from(height.saturating_sub(2 + tags)).max(1);
|
||||
if let Some(cursor) = text.cursor {
|
||||
let (row, _) = layout::cell(text.body, &self.rows, cursor);
|
||||
if row < self.scroll {
|
||||
self.scroll = row;
|
||||
} else if row >= self.scroll + rows_shown {
|
||||
self.scroll = row + 1 - rows_shown;
|
||||
}
|
||||
}
|
||||
self.scroll = self.scroll.min(self.rows.len().saturating_sub(rows_shown));
|
||||
}
|
||||
|
||||
/// Scrolls the body, up when negative.
|
||||
pub fn scroll_by(&mut self, rows: isize) {
|
||||
self.scroll = self.scroll.saturating_add_signed(rows).min(self.rows.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
/// How many rows half the card is, for `Ctrl-d` and `Ctrl-u`.
|
||||
pub fn half(&self) -> isize {
|
||||
isize::try_from(self.area.height / 2).unwrap_or(1).max(1)
|
||||
}
|
||||
|
||||
pub fn contains(&self, at: Position) -> bool {
|
||||
self.area.contains(at)
|
||||
}
|
||||
|
||||
/// Where a click lands in the body, if it's on it.
|
||||
pub fn offset_at(&self, at: Position, text: &CardText<'_>) -> Option<usize> {
|
||||
let inside = self.area.contains(at) && at.y >= self.body_top && at.y + 1 < self.area.bottom();
|
||||
if !inside || text.body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let row = self.scroll + usize::from(at.y - self.body_top);
|
||||
let column = usize::from(at.x.saturating_sub(self.area.x + 2));
|
||||
Some(layout::offset(text.body, &self.rows, row, column))
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, text: &CardText<'_>, theme: &Theme) {
|
||||
let area = self.area;
|
||||
frame.render_widget(Clear, area);
|
||||
let title_color = if text.color == Color::Reset { theme.accent } else { text.color };
|
||||
let footer = match (text.cursor, text.typing) {
|
||||
(None, _) => " j/k scroll · i edit · K close ",
|
||||
(Some(_), true) => " typing · Esc: vim keys ",
|
||||
(Some(_), false) => " vim keys · Esc: done ",
|
||||
};
|
||||
let border = if text.cursor.is_some() && text.typing { theme.insert } else { theme.muted };
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::new().fg(border))
|
||||
.title(Line::styled(
|
||||
format!(" {} ", text.title),
|
||||
Style::new().fg(title_color).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.title_bottom(Line::styled(footer, Style::new().fg(theme.muted)));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let buf = frame.buffer_mut();
|
||||
let width = inner.width.saturating_sub(2);
|
||||
if !text.tags.is_empty() {
|
||||
buf.set_line(
|
||||
inner.x + 1,
|
||||
inner.y,
|
||||
&Line::styled(text.tags.clone(), Style::new().fg(theme.muted)),
|
||||
width,
|
||||
);
|
||||
}
|
||||
if text.body.is_empty() && text.cursor.is_none() {
|
||||
let line = Line::styled("no body yet: i to write one", Style::new().fg(theme.muted));
|
||||
buf.set_line(inner.x + 1, self.body_top, &line, width);
|
||||
}
|
||||
let shown = usize::from(inner.bottom().saturating_sub(self.body_top));
|
||||
for (range, y) in self.rows.iter().skip(self.scroll).take(shown).zip(self.body_top..) {
|
||||
let row = text.body[range.clone()].trim_end_matches('\n');
|
||||
buf.set_line(inner.x + 1, y, &Line::from(Span::raw(row.to_string())), width);
|
||||
}
|
||||
if let Some(cursor) = text.cursor {
|
||||
let (row, col) = layout::cell(text.body, &self.rows, cursor);
|
||||
let on_screen = row.checked_sub(self.scroll).and_then(|row| u16::try_from(row).ok());
|
||||
if let (Some(row), Ok(col)) = (on_screen, u16::try_from(col))
|
||||
&& self.body_top + row < inner.bottom()
|
||||
{
|
||||
frame.set_cursor_position(Position::new(inner.x + 1 + col.min(width), self.body_top + row));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(body: &str) -> CardText<'_> {
|
||||
CardText {
|
||||
title: "Raised beds",
|
||||
color: Color::Reset,
|
||||
tags: "#todo".into(),
|
||||
body,
|
||||
cursor: None,
|
||||
typing: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_card_sits_beside_its_node() {
|
||||
let screen = Rect::new(0, 0, 100, 30);
|
||||
let mut card = CardView::default();
|
||||
card.layout(screen, Some(Rect::new(10, 5, 15, 3)), &text("Cedar.\n40 cm high."));
|
||||
assert_eq!(card.area, Rect::new(26, 5, 48, 5), "to the right, as tall as tags and body");
|
||||
// No room on the right: on the left.
|
||||
card.layout(screen, Some(Rect::new(80, 5, 15, 3)), &text("Cedar."));
|
||||
assert_eq!(card.area.right(), 79);
|
||||
card.layout(screen, None, &text("Cedar."));
|
||||
assert_eq!((card.area.x, card.area.y), (26, 13), "no node on screen: in the middle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_land_in_the_body() {
|
||||
let screen = Rect::new(0, 0, 100, 30);
|
||||
let mut card = CardView::default();
|
||||
let body = "Cedar.\n40 cm high.";
|
||||
card.layout(screen, Some(Rect::new(10, 5, 15, 3)), &text(body));
|
||||
// Border, tags row, then the body: its second line, third letter.
|
||||
assert_eq!(card.offset_at(Position::new(30, 8), &text(body)), Some(9));
|
||||
assert_eq!(card.offset_at(Position::new(30, 6), &text(body)), None, "the tags");
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,15 @@ impl CommandLine {
|
||||
line
|
||||
}
|
||||
|
||||
/// A command line with `text` typed already: a `:` command picked in the
|
||||
/// help, waiting for what it needs.
|
||||
pub fn with_text(text: &str, sources: &Sources, history: &History, popup: PopupMode) -> CommandLine {
|
||||
let mut line = CommandLine { popup, ..CommandLine::default() };
|
||||
line.input.set(text);
|
||||
line.changed(sources, history);
|
||||
line
|
||||
}
|
||||
|
||||
/// What's typed so far.
|
||||
pub fn text(&self) -> &str {
|
||||
self.input.text()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The document bar at the top: the open documents, the focused pane's
|
||||
//! one standing out. A click on a name shows that document.
|
||||
//!
|
||||
//! ```text
|
||||
//! cumulus Garden plans │ Kitchen
|
||||
//! ```
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::theme::Theme;
|
||||
|
||||
/// The bar, for some open documents.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DocumentBar<'a> {
|
||||
pub names: &'a [String],
|
||||
/// The focused pane's document.
|
||||
pub current: usize,
|
||||
pub theme: &'a Theme,
|
||||
}
|
||||
|
||||
impl DocumentBar<'_> {
|
||||
/// Where each name is, in columns from the bar's left edge.
|
||||
fn places(&self) -> Vec<Range<u16>> {
|
||||
let mut at = u16::try_from(" cumulus ".width()).unwrap_or(0);
|
||||
let mut places = Vec::new();
|
||||
for name in self.names {
|
||||
let width = u16::try_from(name.width() + 2).unwrap_or(u16::MAX);
|
||||
places.push(at..at.saturating_add(width));
|
||||
// The separator: " │ " minus the spaces the names carry.
|
||||
at = at.saturating_add(width + 1);
|
||||
}
|
||||
places
|
||||
}
|
||||
|
||||
/// The document whose name is at a column of the bar.
|
||||
pub fn at(&self, col: u16) -> Option<usize> {
|
||||
self.places().iter().position(|place| place.contains(&col))
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for DocumentBar<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let accent = Style::new().fg(self.theme.accent).add_modifier(Modifier::BOLD);
|
||||
buf.set_span(area.x, area.y, &Span::styled(" cumulus ", accent), area.width);
|
||||
let muted = Style::new().fg(self.theme.muted);
|
||||
for (index, (name, place)) in self.names.iter().zip(self.places()).enumerate() {
|
||||
if place.start >= area.width {
|
||||
break;
|
||||
}
|
||||
if index > 0 {
|
||||
buf.set_span(area.x + place.start - 1, area.y, &Span::styled("│", muted), 1);
|
||||
}
|
||||
let style = if index == self.current {
|
||||
Style::new().fg(Color::Black).bg(self.theme.accent).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new()
|
||||
};
|
||||
let room = area.width - place.start;
|
||||
buf.set_span(area.x + place.start, area.y, &Span::styled(format!(" {name} "), style), room);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_in_a_row_and_clicks_on_them() {
|
||||
let names = ["Garden plans".to_string(), "Kitchen".to_string()];
|
||||
let theme = Theme::default();
|
||||
let bar = DocumentBar { names: &names, current: 1, theme: &theme };
|
||||
let area = Rect::new(0, 0, 40, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
bar.render(area, &mut buf);
|
||||
let text: String = (0..area.width).map(|x| buf[(x, 0)].symbol().to_string()).collect();
|
||||
assert_eq!(text.trim_end(), " cumulus Garden plans │ Kitchen");
|
||||
assert_eq!(bar.at(12), Some(0));
|
||||
assert_eq!(bar.at(27), Some(1));
|
||||
assert_eq!(bar.at(3), None, "the app's name");
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,78 @@ impl Glyphs {
|
||||
Glyphs::Ascii => '~',
|
||||
}
|
||||
}
|
||||
|
||||
/// In the outline: before a node whose children show (`folded` false),
|
||||
/// or whose children are folded away.
|
||||
pub fn fold(self, folded: bool) -> char {
|
||||
match (self, folded) {
|
||||
(Glyphs::Unicode, false) => '▾',
|
||||
(Glyphs::Unicode, true) => '▸',
|
||||
(Glyphs::Ascii, false) => '-',
|
||||
(Glyphs::Ascii, true) => '+',
|
||||
}
|
||||
}
|
||||
|
||||
/// In the outline: before a top-level node without children.
|
||||
pub fn loose(self) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => '◦',
|
||||
Glyphs::Ascii => 'o',
|
||||
}
|
||||
}
|
||||
|
||||
/// In the outline: the tree line joining a child to its parent's line,
|
||||
/// for the last child or another.
|
||||
pub fn tree_joint(self, last: bool) -> char {
|
||||
match (self, last) {
|
||||
(Glyphs::Unicode, false) => '├',
|
||||
(Glyphs::Unicode, true) => '└',
|
||||
(Glyphs::Ascii, false) => '|',
|
||||
(Glyphs::Ascii, true) => '`',
|
||||
}
|
||||
}
|
||||
|
||||
/// In the outline: a parent's line, going on down past a row.
|
||||
pub fn tree_line(self) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => '│',
|
||||
Glyphs::Ascii => '|',
|
||||
}
|
||||
}
|
||||
|
||||
/// In the outline: after a title whose node has a body.
|
||||
pub fn body_mark(self) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => '¶',
|
||||
Glyphs::Ascii => '*',
|
||||
}
|
||||
}
|
||||
|
||||
/// In the Note view: the bar beside the part you're on.
|
||||
pub fn part_mark(self) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => '▌',
|
||||
Glyphs::Ascii => '>',
|
||||
}
|
||||
}
|
||||
|
||||
/// In the Note view: before a link going out, or coming in.
|
||||
pub fn link_mark(self, outgoing: bool) -> char {
|
||||
match (self, outgoing) {
|
||||
(Glyphs::Unicode, true) => '→',
|
||||
(Glyphs::Unicode, false) => '←',
|
||||
(Glyphs::Ascii, true) => '>',
|
||||
(Glyphs::Ascii, false) => '<',
|
||||
}
|
||||
}
|
||||
|
||||
/// In the Note view: before each child.
|
||||
pub fn child_mark(self) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => '·',
|
||||
Glyphs::Ascii => '-',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unicode_line(arms: Arms, dash: Option<Dash>, rounded: bool) -> char {
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
//! A list in a popup: the help, the right-click menu and the pickers all
|
||||
//! use one. `j` and `k` (or the mouse) pick a row, `/` searches the way the
|
||||
//! command line completes (fuzzily: `gpl` finds "Garden plans"), Enter
|
||||
//! takes the row, Esc closes.
|
||||
//!
|
||||
//! ```text
|
||||
//! ╭ help · canvas ─────────────────────╮
|
||||
//! │ /dele │
|
||||
//! │▌dd delete this node, with its… │
|
||||
//! │ x delete just this node │
|
||||
//! ╰ j/k move · / search · Enter run ───╯
|
||||
//! ```
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Clear};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::text_input::TextInput;
|
||||
use super::theme::Theme;
|
||||
use crate::command::complete::fuzzy_score;
|
||||
use crate::input::command::{Command, Motion};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::key::{Key, KeyName};
|
||||
|
||||
/// One row of a list.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ListRow {
|
||||
/// What's shown, left to right.
|
||||
pub spans: Vec<Span<'static>>,
|
||||
/// Shown at the right edge: a key, a check mark.
|
||||
pub note: Option<Span<'static>>,
|
||||
/// What a search matches.
|
||||
pub search: String,
|
||||
/// A heading over the rows after it: never picked, and hidden while
|
||||
/// searching.
|
||||
pub heading: bool,
|
||||
}
|
||||
|
||||
impl ListRow {
|
||||
pub fn new(spans: Vec<Span<'static>>, search: impl Into<String>) -> ListRow {
|
||||
ListRow { spans, note: None, search: search.into(), heading: false }
|
||||
}
|
||||
|
||||
pub fn heading(text: &str, style: Style) -> ListRow {
|
||||
ListRow {
|
||||
spans: vec![Span::styled(text.to_string(), style)],
|
||||
note: None,
|
||||
search: String::new(),
|
||||
heading: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_note(self, note: Span<'static>) -> ListRow {
|
||||
ListRow { note: Some(note), ..self }
|
||||
}
|
||||
|
||||
fn width(&self) -> usize {
|
||||
let note = self.note.as_ref().map_or(0, |note| note.content.width() + 2);
|
||||
self.spans.iter().map(|span| span.content.width()).sum::<usize>() + note
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a list pops up.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Place {
|
||||
/// In the middle of the screen, at most this wide.
|
||||
Center { width: u16 },
|
||||
/// With its top-left corner here, moved in to fit (the menu, at the
|
||||
/// mouse).
|
||||
At(Position),
|
||||
}
|
||||
|
||||
/// What a key or a click did to a list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ListOutcome {
|
||||
/// Nothing more to do: it moved, or searched.
|
||||
Stay,
|
||||
/// This row was taken (its index among all the rows).
|
||||
Picked(usize),
|
||||
/// Enter while the search matches nothing: what was searched (a picker
|
||||
/// can make it, as a new tag).
|
||||
NoMatch(String),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// A list popup and where you are in it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListPopup {
|
||||
title: String,
|
||||
/// Hints for the bottom border.
|
||||
footer: String,
|
||||
rows: Vec<ListRow>,
|
||||
/// The rows on show, by index: all of them, or the ones the search
|
||||
/// matches, best first.
|
||||
shown: Vec<usize>,
|
||||
/// The row you're on, among `shown`.
|
||||
selected: usize,
|
||||
/// The first of `shown` on screen.
|
||||
scroll: usize,
|
||||
/// The search, once `/` is typed: it stays until Esc.
|
||||
search: Option<TextInput>,
|
||||
/// Typing goes into the search.
|
||||
searching: bool,
|
||||
place: Place,
|
||||
/// Where the list was drawn last, for clicks.
|
||||
area: Rect,
|
||||
}
|
||||
|
||||
impl ListPopup {
|
||||
pub fn new(
|
||||
title: impl Into<String>,
|
||||
footer: impl Into<String>,
|
||||
rows: Vec<ListRow>,
|
||||
place: Place,
|
||||
) -> ListPopup {
|
||||
let mut list = ListPopup {
|
||||
title: title.into(),
|
||||
footer: footer.into(),
|
||||
shown: (0..rows.len()).collect(),
|
||||
rows,
|
||||
selected: 0,
|
||||
scroll: 0,
|
||||
search: None,
|
||||
searching: false,
|
||||
place,
|
||||
area: Rect::default(),
|
||||
};
|
||||
list.selected = list.first_pickable(0, 1).unwrap_or(0);
|
||||
list
|
||||
}
|
||||
|
||||
/// Starts with a search already typed (`:help fold`).
|
||||
pub fn with_search(mut self, text: &str) -> ListPopup {
|
||||
if !text.is_empty() {
|
||||
self.search = Some(TextInput::new(text));
|
||||
self.refilter();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether typing goes into the search.
|
||||
pub fn is_searching(&self) -> bool {
|
||||
self.searching
|
||||
}
|
||||
|
||||
/// The row you're on (its index among all the rows), if there's one.
|
||||
pub fn selected(&self) -> Option<usize> {
|
||||
self.shown.get(self.selected).copied().filter(|&index| !self.rows[index].heading)
|
||||
}
|
||||
|
||||
/// Replaces a row (a picker's check mark after a toggle).
|
||||
pub fn set_row(&mut self, index: usize, row: ListRow) {
|
||||
if let Some(slot) = self.rows.get_mut(index) {
|
||||
*slot = row;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a row at the end, and puts you on it: the search is done.
|
||||
pub fn push(&mut self, row: ListRow) {
|
||||
self.rows.push(row);
|
||||
(self.search, self.searching) = (None, false);
|
||||
self.refilter();
|
||||
self.select(self.rows.len() - 1);
|
||||
}
|
||||
|
||||
/// Puts you on a row (by its index among all the rows), if it shows.
|
||||
pub fn select(&mut self, index: usize) {
|
||||
if let Some(at) = self.shown.iter().position(|&shown| shown == index) {
|
||||
self.selected = at;
|
||||
}
|
||||
}
|
||||
|
||||
/// A command from the `list` key table.
|
||||
pub fn command(&mut self, typed: &Typed) -> ListOutcome {
|
||||
let times = isize::try_from(typed.times()).unwrap_or(1);
|
||||
let page = isize::try_from(self.list_height() / 2).unwrap_or(1).max(1);
|
||||
match typed.command {
|
||||
Command::Move(Motion::Down) => self.move_by(times),
|
||||
Command::Move(Motion::Up) => self.move_by(-times),
|
||||
Command::Move(Motion::First) => self.selected = self.first_pickable(0, 1).unwrap_or(0),
|
||||
Command::Move(Motion::Last) => {
|
||||
let last = self.shown.len().saturating_sub(1);
|
||||
self.selected = self.first_pickable(last, -1).unwrap_or(last);
|
||||
}
|
||||
Command::HalfPageDown => self.move_by(page * times),
|
||||
Command::HalfPageUp => self.move_by(-page * times),
|
||||
Command::Search => {
|
||||
self.searching = true;
|
||||
self.search.get_or_insert_with(|| TextInput::new(""));
|
||||
}
|
||||
Command::Confirm => return self.pick(),
|
||||
Command::Escape if self.search.is_some() => {
|
||||
self.search = None;
|
||||
self.refilter();
|
||||
}
|
||||
Command::Escape => return ListOutcome::Close,
|
||||
_ => {}
|
||||
}
|
||||
ListOutcome::Stay
|
||||
}
|
||||
|
||||
/// A key while typing the search.
|
||||
pub fn search_key(&mut self, key: Key) -> ListOutcome {
|
||||
if key.is_escape() {
|
||||
(self.search, self.searching) = (None, false);
|
||||
self.refilter();
|
||||
return ListOutcome::Stay;
|
||||
}
|
||||
match key.name {
|
||||
KeyName::Enter => {
|
||||
self.searching = false;
|
||||
return self.pick();
|
||||
}
|
||||
KeyName::Down => self.move_by(1),
|
||||
KeyName::Up => self.move_by(-1),
|
||||
KeyName::Char('n') if key.ctrl => self.move_by(1),
|
||||
KeyName::Char('p') if key.ctrl => self.move_by(-1),
|
||||
_ => {
|
||||
if let Some(search) = &mut self.search
|
||||
&& search.edit(key)
|
||||
{
|
||||
self.refilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
ListOutcome::Stay
|
||||
}
|
||||
|
||||
/// A left click: on a row, it's taken; outside the list, it closes.
|
||||
pub fn click(&mut self, at: Position) -> ListOutcome {
|
||||
if !self.area.contains(at) {
|
||||
return ListOutcome::Close;
|
||||
}
|
||||
let top = self.area.y + 1 + u16::from(self.search.is_some());
|
||||
let Some(row) = at.y.checked_sub(top).map(|row| self.scroll + usize::from(row)) else {
|
||||
return ListOutcome::Stay;
|
||||
};
|
||||
match self.shown.get(row) {
|
||||
Some(&index) if !self.rows[index].heading && at.y < self.area.bottom() - 1 => {
|
||||
self.selected = row;
|
||||
ListOutcome::Picked(index)
|
||||
}
|
||||
_ => ListOutcome::Stay,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouse wheel: the selection moves a few rows.
|
||||
pub fn wheel(&mut self, down: bool) {
|
||||
self.move_by(if down { 3 } else { -3 });
|
||||
}
|
||||
|
||||
/// Enter: the row you're on, or what was searched when nothing matches.
|
||||
fn pick(&mut self) -> ListOutcome {
|
||||
match (self.selected(), &self.search) {
|
||||
(Some(index), _) => ListOutcome::Picked(index),
|
||||
(None, Some(search)) if !search.text().is_empty() => {
|
||||
ListOutcome::NoMatch(search.text().to_string())
|
||||
}
|
||||
(None, _) => ListOutcome::Stay,
|
||||
}
|
||||
}
|
||||
|
||||
fn move_by(&mut self, delta: isize) {
|
||||
if self.shown.is_empty() {
|
||||
return;
|
||||
}
|
||||
let last = self.shown.len() - 1;
|
||||
let target = self.selected.saturating_add_signed(delta).min(last);
|
||||
let step = if delta < 0 { -1 } else { 1 };
|
||||
// Headings are skipped, in the direction of the move.
|
||||
self.selected =
|
||||
self.first_pickable(target, step).or_else(|| self.first_pickable(target, -step)).unwrap_or(0);
|
||||
}
|
||||
|
||||
/// The first row from `from` (going `step`) that can be picked.
|
||||
fn first_pickable(&self, from: usize, step: isize) -> Option<usize> {
|
||||
let mut at = from;
|
||||
loop {
|
||||
let index = *self.shown.get(at)?;
|
||||
if !self.rows[index].heading {
|
||||
return Some(at);
|
||||
}
|
||||
at = at.checked_add_signed(step)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Works out the rows on show for the search.
|
||||
fn refilter(&mut self) {
|
||||
let query = self.search.as_ref().map_or("", |search| search.text());
|
||||
self.shown = if query.is_empty() {
|
||||
(0..self.rows.len()).collect()
|
||||
} else {
|
||||
// Rows that start with what's typed come first (`dd` finds the
|
||||
// key dd before <Down>), then the closest fuzzy matches.
|
||||
let lower = query.to_lowercase();
|
||||
let mut scored: Vec<(bool, i32, usize)> = (0..self.rows.len())
|
||||
.filter(|&index| !self.rows[index].heading)
|
||||
.filter_map(|index| {
|
||||
let search = &self.rows[index].search;
|
||||
let starts = search.to_lowercase().starts_with(&lower);
|
||||
Some((starts, fuzzy_score(query, search)?, index))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by_key(|&(starts, score, index)| (!starts, -score, index));
|
||||
scored.into_iter().map(|(_, _, index)| index).collect()
|
||||
};
|
||||
self.scroll = 0;
|
||||
self.selected = self.first_pickable(0, 1).unwrap_or(0);
|
||||
}
|
||||
|
||||
/// How many rows of the list fit inside the border (and the search).
|
||||
fn list_height(&self) -> usize {
|
||||
usize::from(self.area.height.saturating_sub(2 + u16::from(self.search.is_some()))).max(1)
|
||||
}
|
||||
|
||||
/// Works out where the list goes on `screen`, and scrolls to keep the
|
||||
/// row you're on in view.
|
||||
pub fn layout(&mut self, screen: Rect) {
|
||||
let widest = self.rows.iter().map(ListRow::width).max().unwrap_or(0);
|
||||
let wanted_width = (widest + 4).max(self.title.width() + 6).max(self.footer.width() + 4);
|
||||
let height =
|
||||
u16::try_from(self.rows.len() + 2 + usize::from(self.search.is_some())).unwrap_or(u16::MAX);
|
||||
self.area = match self.place {
|
||||
Place::Center { width } => {
|
||||
let width = width.min(screen.width.saturating_sub(4)).max(10);
|
||||
let height = height.min(screen.height.saturating_sub(2)).max(3);
|
||||
Rect {
|
||||
x: screen.x + (screen.width.saturating_sub(width)) / 2,
|
||||
y: screen.y + (screen.height.saturating_sub(height)) / 2,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
Place::At(at) => {
|
||||
let width = u16::try_from(wanted_width).unwrap_or(u16::MAX).min(screen.width);
|
||||
let height = height.min(screen.height);
|
||||
// Opens down and right from the mouse, or up and left where
|
||||
// there's no room.
|
||||
let x = at.x.min(screen.right().saturating_sub(width)).max(screen.x);
|
||||
let y = if at.y + height <= screen.bottom() {
|
||||
at.y
|
||||
} else {
|
||||
at.y.saturating_sub(height).max(screen.y)
|
||||
};
|
||||
Rect { x, y, width, height }
|
||||
}
|
||||
};
|
||||
let rows = self.list_height();
|
||||
if self.selected < self.scroll {
|
||||
self.scroll = self.selected;
|
||||
} else if self.selected >= self.scroll + rows {
|
||||
self.scroll = self.selected + 1 - rows;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the list where [`ListPopup::layout`] put it.
|
||||
pub fn render(&self, frame: &mut Frame, theme: &Theme) {
|
||||
let area = self.area;
|
||||
frame.render_widget(Clear, area);
|
||||
let accent = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD);
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::new().fg(theme.muted))
|
||||
.title(Line::styled(format!(" {} ", self.title), accent))
|
||||
.title_bottom(Line::styled(format!(" {} ", self.footer), Style::new().fg(theme.muted)));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let buf = frame.buffer_mut();
|
||||
let mut y = inner.y;
|
||||
if let Some(search) = &self.search {
|
||||
let line = Line::from(vec![Span::styled("/", accent), Span::raw(search.text().to_string())]);
|
||||
buf.set_line(inner.x + 1, y, &line, inner.width.saturating_sub(1));
|
||||
y += 1;
|
||||
}
|
||||
let bar = theme.glyphs.part_mark().to_string();
|
||||
for (at, &index) in self.shown.iter().enumerate().skip(self.scroll) {
|
||||
if y >= inner.bottom() {
|
||||
break;
|
||||
}
|
||||
let row = &self.rows[index];
|
||||
if at == self.selected && !row.heading {
|
||||
buf.set_string(inner.x, y, &bar, Style::new().fg(theme.accent));
|
||||
}
|
||||
let note_width = row.note.as_ref().map_or(0, |note| note.content.width());
|
||||
let room = inner.width.saturating_sub(1 + u16::try_from(note_width).unwrap_or(0));
|
||||
buf.set_line(inner.x + 1, y, &Line::from(row.spans.clone()), room);
|
||||
if let Some(note) = &row.note {
|
||||
let x = inner.right().saturating_sub(u16::try_from(note_width + 1).unwrap_or(0));
|
||||
buf.set_span(x, y, note, u16::try_from(note_width).unwrap_or(0));
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
if self.searching
|
||||
&& let Some(search) = &self.search
|
||||
{
|
||||
let col = u16::try_from(2 + search.cursor_column()).unwrap_or(u16::MAX);
|
||||
frame.set_cursor_position(Position::new(
|
||||
(inner.x + col).min(inner.right().saturating_sub(1)),
|
||||
inner.y,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::input::key::parse_keys;
|
||||
use crate::input::keymap::DEFAULT_LEADER;
|
||||
|
||||
fn rows() -> Vec<ListRow> {
|
||||
vec![
|
||||
ListRow::heading("colors", Style::new()),
|
||||
ListRow::new(vec![Span::raw("orange")], "orange"),
|
||||
ListRow::new(vec![Span::raw("green")], "green"),
|
||||
ListRow::heading("more", Style::new()),
|
||||
ListRow::new(vec![Span::raw("gray")], "gray"),
|
||||
]
|
||||
}
|
||||
|
||||
fn typed(command: Command) -> Typed {
|
||||
Typed { command, count: None, register: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_skips_headings() {
|
||||
let mut list = ListPopup::new("colors", "", rows(), Place::Center { width: 30 });
|
||||
assert_eq!(list.selected(), Some(1), "the first row that isn't a heading");
|
||||
list.command(&typed(Command::Move(Motion::Down)));
|
||||
list.command(&typed(Command::Move(Motion::Down)));
|
||||
assert_eq!(list.selected(), Some(4), "past the heading");
|
||||
list.command(&typed(Command::Move(Motion::Down)));
|
||||
assert_eq!(list.selected(), Some(4), "the last stays the last");
|
||||
assert_eq!(list.command(&typed(Command::Confirm)), ListOutcome::Picked(4));
|
||||
assert_eq!(list.command(&typed(Command::Escape)), ListOutcome::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn searching_filters_and_esc_clears() {
|
||||
let mut list = ListPopup::new("colors", "", rows(), Place::Center { width: 30 });
|
||||
list.command(&typed(Command::Search));
|
||||
assert!(list.is_searching());
|
||||
for key in parse_keys("gr", DEFAULT_LEADER).unwrap() {
|
||||
list.search_key(key);
|
||||
}
|
||||
assert_eq!(list.shown, [4, 2], "gray and green, the closer match first, and no headings");
|
||||
list.search_key(Key::char('a'));
|
||||
assert_eq!(list.shown, [4], "gra: gray");
|
||||
let enter = parse_keys("<CR>", DEFAULT_LEADER).unwrap()[0];
|
||||
assert_eq!(list.search_key(enter), ListOutcome::Picked(4));
|
||||
list.command(&typed(Command::Search));
|
||||
list.search_key(Key::char('z'));
|
||||
assert_eq!(
|
||||
list.search_key(enter),
|
||||
ListOutcome::NoMatch("graz".into()),
|
||||
"nothing matches: what was typed"
|
||||
);
|
||||
// Esc: the search goes, then the list.
|
||||
assert_eq!(list.command(&typed(Command::Escape)), ListOutcome::Stay);
|
||||
assert_eq!(list.shown.len(), 5);
|
||||
assert_eq!(list.command(&typed(Command::Escape)), ListOutcome::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_pick_rows_and_close_outside() {
|
||||
let mut list = ListPopup::new("menu", "", rows(), Place::At(Position::new(10, 5)));
|
||||
list.layout(Rect::new(0, 0, 80, 24));
|
||||
assert_eq!((list.area.x, list.area.y), (10, 5), "under the mouse");
|
||||
assert_eq!(list.click(Position::new(12, 7)), ListOutcome::Picked(1), "the row under it");
|
||||
assert_eq!(list.click(Position::new(12, 6)), ListOutcome::Stay, "a heading");
|
||||
assert_eq!(list.click(Position::new(2, 2)), ListOutcome::Close);
|
||||
// Near the bottom right, it opens up and to the left.
|
||||
let mut list = ListPopup::new("menu", "", rows(), Place::At(Position::new(78, 22)));
|
||||
list.layout(Rect::new(0, 0, 80, 24));
|
||||
assert!(list.area.right() <= 80 && list.area.bottom() <= 22, "{:?}", list.area);
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -1,11 +1,16 @@
|
||||
//! What the views share: the characters they draw with ([`glyphs`]), the
|
||||
//! colors and looks ([`theme`]), the which-key popup ([`which_key`]), the
|
||||
//! command line ([`command_line`]), and text being typed ([`text_input`])
|
||||
//! or edited with vim keys ([`text_editor`]). The bars and the other shared
|
||||
//! widgets come in later stages.
|
||||
//! command line ([`command_line`]), text being typed ([`text_input`]) or
|
||||
//! edited with vim keys ([`text_editor`]), the bars ([`status_bar`],
|
||||
//! [`document_bar`]), and the popups' pieces: the [`list`] the help, the
|
||||
//! menu and the pickers share, and the `K` [`card`].
|
||||
|
||||
pub mod card;
|
||||
pub mod command_line;
|
||||
pub mod document_bar;
|
||||
pub mod glyphs;
|
||||
pub mod list;
|
||||
pub mod status_bar;
|
||||
pub mod text_editor;
|
||||
pub mod text_input;
|
||||
pub mod theme;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The status bar at the bottom: the mode, the view, where the cursor is in
|
||||
//! the tree (or a message in its place), and the keys typed so far; then a
|
||||
//! line of key hints for what you're doing, which `<Space>w` hides along
|
||||
//! with which-key.
|
||||
//!
|
||||
//! ```text
|
||||
//! NORMAL │ canvas │ Garden plans › Raised beds 2d
|
||||
//! i edit · a/o add · dd delete · >> in · K body · ? help
|
||||
//! ```
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Widget};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::theme::Theme;
|
||||
use crate::model::{Document, NodeId};
|
||||
|
||||
/// What the status bar shows.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StatusBar<'a> {
|
||||
/// The mode's name, and its color (the insert color while typing).
|
||||
pub mode: &'a str,
|
||||
pub mode_color: Color,
|
||||
/// The focused pane's view, and anything else worth knowing about it
|
||||
/// (its zoom, focus mode).
|
||||
pub view: String,
|
||||
/// The breadcrumb, or a message in its place.
|
||||
pub place: Span<'a>,
|
||||
/// The keys typed so far.
|
||||
pub keys: &'a str,
|
||||
/// The hint line, unless hints are off.
|
||||
pub hints: Option<&'a str>,
|
||||
pub theme: &'a Theme,
|
||||
}
|
||||
|
||||
impl StatusBar<'_> {
|
||||
/// How many rows it takes.
|
||||
pub fn height(&self) -> u16 {
|
||||
if self.hints.is_some() { 2 } else { 1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for StatusBar<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let label = Span::styled(
|
||||
format!(" {} ", self.mode),
|
||||
Style::new().fg(Color::Black).bg(self.mode_color).add_modifier(Modifier::BOLD),
|
||||
);
|
||||
let line = Line::from(vec![label, Span::raw(format!(" {} │ ", self.view)), self.place]);
|
||||
let status = Rect { height: 1, ..area };
|
||||
Paragraph::new(line).render(status, buf);
|
||||
let keys = Line::styled(
|
||||
format!("{} ", self.keys),
|
||||
Style::new().fg(self.theme.accent).add_modifier(Modifier::BOLD),
|
||||
);
|
||||
Paragraph::new(keys).right_aligned().render(status, buf);
|
||||
if let Some(hints) = self.hints
|
||||
&& area.height > 1
|
||||
{
|
||||
let hint_row = Rect { y: area.y + 1, height: 1, ..area };
|
||||
Paragraph::new(Line::styled(format!(" {hints}"), Style::new().fg(self.theme.muted)))
|
||||
.render(hint_row, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the cursor is in the tree, from the top: `Garden plans › Watering
|
||||
/// › Drip line`. When it's wider than `width`, the top levels give way to
|
||||
/// `…`.
|
||||
pub fn breadcrumb(doc: &Document, cursor: NodeId, width: usize) -> String {
|
||||
let mut path: Vec<String> =
|
||||
doc.ancestors(cursor).filter_map(|id| Some(doc.node(id)?.title().to_string())).collect();
|
||||
path.reverse();
|
||||
if let Some(node) = doc.node(cursor) {
|
||||
path.push(node.title().to_string());
|
||||
}
|
||||
let mut crumbs = path.join(" › ");
|
||||
let mut dropped = 0;
|
||||
while crumbs.width() > width && dropped + 1 < path.len() {
|
||||
dropped += 1;
|
||||
crumbs = format!("… › {}", path[dropped..].join(" › "));
|
||||
}
|
||||
crumbs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::garden;
|
||||
|
||||
#[test]
|
||||
fn the_breadcrumb_gives_way_at_the_top() {
|
||||
let (doc, g) = garden();
|
||||
assert_eq!(breadcrumb(&doc, g.cedar, 80), "Garden plans › Raised beds › Cedar");
|
||||
assert_eq!(breadcrumb(&doc, g.cedar, 23), "… › Raised beds › Cedar");
|
||||
assert_eq!(breadcrumb(&doc, g.cedar, 22), "… › Cedar");
|
||||
assert_eq!(breadcrumb(&doc, g.cedar, 5), "… › Cedar", "the node itself always shows");
|
||||
assert_eq!(breadcrumb(&doc, g.compost, 80), "ask about compost");
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
//! 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(`),
|
||||
//! - `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;
|
||||
@@ -418,7 +418,8 @@ impl TextEditor {
|
||||
|
||||
// ----- Operators ------------------------------------------------------
|
||||
|
||||
/// What an operator acts on, from the cursor.
|
||||
/// What an operator acts on, from the cursor (`None` when that's
|
||||
/// nothing).
|
||||
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();
|
||||
@@ -489,9 +490,17 @@ impl TextEditor {
|
||||
/// `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;
|
||||
let Some(span) = self.span(operator, target, count) else {
|
||||
// On an empty line there's nothing to take, but `cc`, `C` and
|
||||
// `s` still start typing there, as in vim.
|
||||
let (text, at) = (self.text(), self.cursor());
|
||||
if change && line_start(text, at) == line_end(text, at) {
|
||||
self.typing = true;
|
||||
}
|
||||
return Reply::Done;
|
||||
};
|
||||
let yank = yank_of(self.text(), &span);
|
||||
match &span.lines {
|
||||
// `cc`: the lines become one empty line, to type on.
|
||||
Some(lines) if change => self.input.replace(lines.start, lines.end, ""),
|
||||
|
||||
@@ -176,3 +176,16 @@ fn in_a_title_o_is_for_the_caller() {
|
||||
keys.press("yyP");
|
||||
assert_eq!(keys.shown(), "Soi|lSoil", "a whole line goes in like any text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_an_empty_line_still_types() {
|
||||
let mut keys = Keys::new("|", true);
|
||||
keys.press("ccfirst<Esc>");
|
||||
assert_eq!(keys.shown(), "firs|t", "cc on an empty text");
|
||||
keys.press("o<Esc>Ssecond<Esc>");
|
||||
assert_eq!(keys.shown(), "first\nsecon|d", "S on an empty line");
|
||||
keys.press("o<Esc>sthird<Esc>");
|
||||
assert_eq!(keys.shown(), "first\nsecond\nthir|d", "s on an empty line");
|
||||
keys.press("0ch");
|
||||
assert!(!keys.editor.is_typing(), "ch at the start of a line that isn't empty does nothing");
|
||||
}
|
||||
@@ -13,7 +13,6 @@ use super::boxes::{NodeBox, draft_box, truncate};
|
||||
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||
use super::lines::{LineLayer, LineOwner, Paint};
|
||||
use super::route::{Bend, route};
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::text_edit::TextEdit;
|
||||
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
|
||||
use crate::ui::glyphs::Glyphs;
|
||||
@@ -96,10 +95,7 @@ impl Scene {
|
||||
/// shows as typed, with the text cursor in it.
|
||||
pub fn build(ctx: &ViewContext<'_>, camera: Camera, size: Size) -> Scene {
|
||||
let (doc, cursor, theme, settings) = (ctx.doc, ctx.cursor, ctx.theme, ctx.settings);
|
||||
let edit = match ctx.mode {
|
||||
Mode::Text(edit) => Some(edit),
|
||||
_ => None,
|
||||
};
|
||||
let edit = ctx.mode.title_edit();
|
||||
let typing = edit.is_some_and(|edit| edit.editor.is_typing());
|
||||
let visible = Visible::new(doc, cursor, settings.focus);
|
||||
let mut scene = Scene {
|
||||
|
||||
+43
-14
@@ -10,6 +10,7 @@ use super::navigate::nearest;
|
||||
use super::scene::Scene;
|
||||
use crate::app::action::{Action, AppAction, Message};
|
||||
use crate::app::mode::{Grab, Mode};
|
||||
use crate::app::popup::MenuFor;
|
||||
use crate::app::text_edit::{EditAt, StartEdit};
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::command::{Command, Motion};
|
||||
@@ -17,7 +18,7 @@ use crate::input::engine::Typed;
|
||||
use crate::input::mouse::{Cell, Gesture};
|
||||
use crate::model::{CanvasOffset, Direction, EditOp, Group, NodeId, Visible};
|
||||
use crate::ui::text_editor::layout;
|
||||
use crate::view::{View, ViewContext, ViewKind};
|
||||
use crate::view::{View, ViewContext, ViewKind, contexts_for};
|
||||
|
||||
/// How close (in cells) the cursor's node may come to an edge before the
|
||||
/// view follows it, like vim's `scrolloff`.
|
||||
@@ -189,6 +190,15 @@ impl CanvasView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a box (in the view's cells) that's on screen, in terminal
|
||||
/// cells: `None` when none of it is.
|
||||
fn clip(rect: ScreenRect, area: Rect) -> Option<Rect> {
|
||||
let cell = |value: i32, size: u16| u16::try_from(value.clamp(0, i32::from(size))).unwrap_or(0);
|
||||
let (left, right) = (cell(rect.left(), area.width), cell(rect.right(), area.width));
|
||||
let (top, bottom) = (cell(rect.top(), area.height), cell(rect.bottom(), area.height));
|
||||
(right > left && bottom > top).then(|| Rect::new(area.x + left, area.y + top, right - left, bottom - top))
|
||||
}
|
||||
|
||||
/// A command's count, as a number of cells to move or scroll by.
|
||||
fn cells(typed: &Typed) -> i32 {
|
||||
// The key engine keeps counts under 10 000, so this always fits.
|
||||
@@ -212,20 +222,13 @@ impl View for CanvasView {
|
||||
}
|
||||
|
||||
fn contexts(&self, mode: &Mode) -> &'static [Context] {
|
||||
match mode {
|
||||
Mode::Normal => &[Context::Canvas, Context::Normal, Context::Motions],
|
||||
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
|
||||
Mode::Text(_) => &[Context::Text],
|
||||
Mode::Move(_) => &[Context::Move],
|
||||
// The command line reads its own keys.
|
||||
Mode::Command(_) => &[],
|
||||
}
|
||||
contexts_for(mode, &[Context::Canvas, Context::Normal, Context::Motions])
|
||||
}
|
||||
|
||||
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
|
||||
self.area = area;
|
||||
// A title is edited in its box: zoomed out, there's no box.
|
||||
if matches!(ctx.mode, Mode::Text(_)) && self.camera.zoom != Zoom::Normal {
|
||||
if ctx.mode.title_edit().is_some() && self.camera.zoom != Zoom::Normal {
|
||||
self.zoom(Zoom::Normal, ctx);
|
||||
}
|
||||
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
|
||||
@@ -262,7 +265,7 @@ impl View for CanvasView {
|
||||
match ctx.mode {
|
||||
Mode::Move(grab) => return self.move_command(typed, *grab, ctx),
|
||||
// Keys inside a title are the edit's, not the view's.
|
||||
Mode::Text(_) => return None,
|
||||
Mode::Text(_) | Mode::Popup(_) => return None,
|
||||
Mode::Normal | Mode::Command(_) => {}
|
||||
}
|
||||
let steps = cells(typed);
|
||||
@@ -309,7 +312,7 @@ impl View for CanvasView {
|
||||
Mode::Text(edit) => edit.finish(ctx),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let start = StartEdit { at: self.title_spot(id, at, ctx), new_node: None };
|
||||
let start = StartEdit::title(self.title_spot(id, at, ctx));
|
||||
actions.extend([Action::cursor(id), Action::App(AppAction::StartEdit(start))]);
|
||||
actions
|
||||
}
|
||||
@@ -328,11 +331,37 @@ impl View for CanvasView {
|
||||
self.camera.pan(cols, rows);
|
||||
Vec::new()
|
||||
}
|
||||
Gesture::RightPress { .. } => {
|
||||
vec![Action::say(Message::info("the right-click menu comes in stage 7"))]
|
||||
Gesture::RightPress { at: cell } => {
|
||||
// A title being edited is done first.
|
||||
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
|
||||
let at = self.screen(cell);
|
||||
let (col, row) = (cell.col, cell.row);
|
||||
match self.scene.node_at(at) {
|
||||
Some(id) => actions.extend([
|
||||
Action::cursor(id),
|
||||
Action::App(AppAction::OpenMenu { what: MenuFor::Node(id), col, row }),
|
||||
]),
|
||||
None => {
|
||||
let what = MenuFor::Canvas(self.camera.to_canvas(at));
|
||||
actions.push(Action::App(AppAction::OpenMenu { what, col, row }));
|
||||
}
|
||||
}
|
||||
actions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> String {
|
||||
match self.camera.zoom {
|
||||
Zoom::Normal => "canvas".to_string(),
|
||||
zoom => format!("canvas · {}", zoom.name().to_lowercase()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_area(&self, ctx: &ViewContext<'_>) -> Option<Rect> {
|
||||
let rect = self.scene.rect_of(ctx.cursor)?;
|
||||
clip(rect, self.area)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
//! 3. [`View::command`] and [`View::mouse`] turn input into actions.
|
||||
|
||||
pub mod canvas;
|
||||
pub mod note;
|
||||
pub mod outline;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -92,6 +94,19 @@ impl ViewContext<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key tables a view reads in a mode: `normal` in normal mode (its own
|
||||
/// table first), and what every view reads in the other modes.
|
||||
pub fn contexts_for(mode: &Mode, normal: &'static [Context]) -> &'static [Context] {
|
||||
match mode {
|
||||
Mode::Normal => normal,
|
||||
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
|
||||
Mode::Text(_) => &[Context::Text],
|
||||
Mode::Move(_) => &[Context::Move],
|
||||
// The command line reads its own keys, and a popup has its own tables.
|
||||
Mode::Command(_) | Mode::Popup(_) => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// What every view does.
|
||||
pub trait View {
|
||||
/// Which view this is.
|
||||
@@ -113,6 +128,19 @@ pub trait View {
|
||||
|
||||
/// A mouse gesture over the view.
|
||||
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action>;
|
||||
|
||||
/// How the status bar names the view: its kind, and anything else worth
|
||||
/// knowing (the canvas's zoom).
|
||||
fn status(&self) -> String {
|
||||
self.kind().name().to_string()
|
||||
}
|
||||
|
||||
/// Where the cursor's node is on screen, as laid out last: the `K` card
|
||||
/// opens beside it. `None` when it doesn't show (or, in the Note view,
|
||||
/// when it's the whole view).
|
||||
fn cursor_area(&self, _ctx: &ViewContext<'_>) -> Option<Rect> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
//! The Note view: one node at full size, to write in.
|
||||
//!
|
||||
//! ```text
|
||||
//! ▌ Raised beds
|
||||
//! #todo #wood ● orange
|
||||
//!
|
||||
//! Cedar, three of them.
|
||||
//! 40 cm high.
|
||||
//!
|
||||
//! Links
|
||||
//! → ask about compost needs
|
||||
//! Children
|
||||
//! · Cedar, untreated
|
||||
//! ```
|
||||
//!
|
||||
//! It shows the cursor's node, and follows it. A note is made of parts: the
|
||||
//! title, the tags, the body, each link and each child. `j` and `k` go from
|
||||
//! part to part (the bar on the left marks the one you're on), and Enter or
|
||||
//! `i` edits it, or goes to that link or child. The body is edited like a
|
||||
//! title, with Enter starting a new line. `h` and `l` turn the page: the
|
||||
//! sibling before or after.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
use crate::app::action::{Action, AppAction, Message};
|
||||
use crate::app::commands;
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::popup::MenuFor;
|
||||
use crate::app::text_edit::{EditAt, Field, StartEdit, TextEdit};
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::command::{Command, Motion};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::mouse::{Cell, Gesture};
|
||||
use crate::model::{Content, Direction, NodeId};
|
||||
use crate::ui::text_editor::layout;
|
||||
use crate::view::{View, ViewContext, ViewKind, contexts_for};
|
||||
|
||||
/// The bar marking the part you're on, then a space, before the text.
|
||||
const MARGIN: u16 = 2;
|
||||
/// How many rows the mouse wheel scrolls.
|
||||
const WHEEL_ROWS: usize = 3;
|
||||
|
||||
/// A part of a note: what `j` and `k` step through.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
enum Part {
|
||||
#[default]
|
||||
Title,
|
||||
Tags,
|
||||
Body,
|
||||
/// A link, in the order the note lists them.
|
||||
Link(usize),
|
||||
/// A child, in order.
|
||||
Child(usize),
|
||||
}
|
||||
|
||||
/// The Note view.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NoteView {
|
||||
/// Where on screen the view is.
|
||||
area: Rect,
|
||||
/// The node the note was about at the last layout. When the cursor goes
|
||||
/// to another one, the note starts again at its title.
|
||||
node: Option<NodeId>,
|
||||
/// The part you're on.
|
||||
part: Part,
|
||||
/// The first row on show.
|
||||
scroll: usize,
|
||||
/// The view was scrolled by hand: it stays put until you change part.
|
||||
scrolled: bool,
|
||||
/// The rows laid out last: what's drawn, and what a click lands on.
|
||||
rows: Vec<Row>,
|
||||
/// Where each link leads, in the note's order.
|
||||
links: Vec<NodeId>,
|
||||
/// The node's children, in order.
|
||||
children: Vec<NodeId>,
|
||||
/// The part being edited, as drawn.
|
||||
editing: Option<Editing>,
|
||||
}
|
||||
|
||||
/// One row of the note, ready to draw.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Row {
|
||||
/// The part it shows (`None` for headings and gaps).
|
||||
part: Option<Part>,
|
||||
line: Line<'static>,
|
||||
}
|
||||
|
||||
/// The text being edited, as drawn: the rows it wraps into, and where the
|
||||
/// text cursor is.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Editing {
|
||||
/// The note's row the text starts on.
|
||||
first_row: usize,
|
||||
text: String,
|
||||
rows: Vec<Range<usize>>,
|
||||
/// The text cursor's row (among `rows`) and column.
|
||||
cursor: (usize, usize),
|
||||
}
|
||||
|
||||
impl NoteView {
|
||||
pub fn new() -> NoteView {
|
||||
NoteView::default()
|
||||
}
|
||||
|
||||
/// How wide the text of the note can be.
|
||||
fn text_width(&self) -> usize {
|
||||
usize::from(self.area.width.saturating_sub(MARGIN)).max(8)
|
||||
}
|
||||
|
||||
/// Lays out the note of the cursor's node.
|
||||
fn build(&mut self, ctx: &ViewContext<'_>) {
|
||||
self.rows.clear();
|
||||
self.editing = None;
|
||||
let (doc, theme, glyphs, id) = (ctx.doc, ctx.theme, ctx.theme.glyphs, ctx.cursor);
|
||||
let Some(node) = doc.node(id) else { return };
|
||||
let edit = ctx.mode.edit().filter(|edit| edit.node == id);
|
||||
let draft = |field: Field| edit.filter(|edit| edit.field == field);
|
||||
let muted = Style::new().fg(theme.muted);
|
||||
|
||||
// The title, in the node's color.
|
||||
let color = theme.node_style(node, id == doc.root()).color;
|
||||
let title_style = Style::new().fg(color).add_modifier(Modifier::BOLD);
|
||||
match (draft(Field::Title), &node.content) {
|
||||
(Some(edit), _) => self.add_draft(Part::Title, edit, title_style),
|
||||
(None, Content::Idea(idea)) => self.add_text(Part::Title, idea.title.as_str(), title_style),
|
||||
(None, Content::Portal(portal)) => {
|
||||
let title = format!("{} {}", glyphs.portal(), portal.title);
|
||||
self.add_text(Part::Title, &title, title_style);
|
||||
let path = format!("a portal to {}", portal.path.as_str());
|
||||
self.add_row(Some(Part::Title), vec![Span::styled(path, muted)]);
|
||||
}
|
||||
}
|
||||
|
||||
// The tags, then the node's color by name.
|
||||
match draft(Field::Tags) {
|
||||
Some(edit) => self.add_draft(Part::Tags, edit, Style::new()),
|
||||
None => {
|
||||
let mut spans = if node.tags.is_empty() {
|
||||
vec![Span::styled("no tags", muted)]
|
||||
} else {
|
||||
vec![Span::raw(node.tags.to_string())]
|
||||
};
|
||||
if let Some(name) = &node.look.color {
|
||||
spans.push(Span::styled(format!(" {} {name}", glyphs.bullet()), Style::new().fg(color)));
|
||||
}
|
||||
self.add_row(Some(Part::Tags), spans);
|
||||
}
|
||||
}
|
||||
self.add_row(None, Vec::new());
|
||||
|
||||
// The body.
|
||||
match (draft(Field::Body), &node.content) {
|
||||
(Some(edit), _) => self.add_draft(Part::Body, edit, Style::new()),
|
||||
(None, Content::Idea(idea)) if !idea.body.is_empty() => {
|
||||
self.add_text(Part::Body, idea.body.as_str(), Style::new());
|
||||
}
|
||||
(None, Content::Idea(_)) => {
|
||||
self.add_row(Some(Part::Body), vec![Span::styled("no body yet: Enter to write one", muted)]);
|
||||
}
|
||||
(None, Content::Portal(_)) => {
|
||||
let text = "a portal's body is its document's";
|
||||
self.add_row(Some(Part::Body), vec![Span::styled(text, muted)]);
|
||||
}
|
||||
}
|
||||
|
||||
// Links out, then links in.
|
||||
let mut links: Vec<(bool, NodeId, String)> = doc
|
||||
.links_of(id)
|
||||
.map(|link| {
|
||||
let out = link.start() == id;
|
||||
let other = if out { link.end() } else { link.start() };
|
||||
(out, other, link.label.as_ref().map(ToString::to_string).unwrap_or_default())
|
||||
})
|
||||
.collect();
|
||||
links.sort_by_key(|&(out, _, _)| !out);
|
||||
self.links = links.iter().map(|&(_, other, _)| other).collect();
|
||||
if !links.is_empty() {
|
||||
self.add_row(None, Vec::new());
|
||||
self.add_row(None, vec![Span::styled("Links", muted)]);
|
||||
}
|
||||
for (index, (out, other, label)) in links.into_iter().enumerate() {
|
||||
let title = doc.node(other).map(|node| node.title().to_string()).unwrap_or_default();
|
||||
let mut spans = vec![Span::raw(format!("{} {title}", glyphs.link_mark(out)))];
|
||||
if !label.is_empty() {
|
||||
spans.push(Span::styled(format!(" {label}"), muted));
|
||||
}
|
||||
self.add_row(Some(Part::Link(index)), spans);
|
||||
}
|
||||
|
||||
// Children.
|
||||
self.children = doc.children(id).to_vec();
|
||||
if !self.children.is_empty() {
|
||||
if self.links.is_empty() {
|
||||
self.add_row(None, Vec::new());
|
||||
}
|
||||
self.add_row(None, vec![Span::styled("Children", muted)]);
|
||||
}
|
||||
for (index, &child) in self.children.clone().iter().enumerate() {
|
||||
let title = doc.node(child).map(|node| node.title().to_string()).unwrap_or_default();
|
||||
self.add_row(
|
||||
Some(Part::Child(index)),
|
||||
vec![Span::raw(format!("{} {title}", glyphs.child_mark()))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_row(&mut self, part: Option<Part>, spans: Vec<Span<'static>>) {
|
||||
self.rows.push(Row { part, line: Line::from(spans) });
|
||||
}
|
||||
|
||||
/// Text over as many rows as it wraps into.
|
||||
fn add_text(&mut self, part: Part, text: &str, style: Style) {
|
||||
for range in layout::rows(text, self.text_width()) {
|
||||
let row = text[range].trim_end_matches('\n').to_string();
|
||||
self.add_row(Some(part), vec![Span::styled(row, style)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text being edited, as typed, with the text cursor in it.
|
||||
fn add_draft(&mut self, part: Part, edit: &TextEdit, style: Style) {
|
||||
let text = edit.editor.text();
|
||||
let rows = layout::rows(text, self.text_width());
|
||||
let cursor = layout::cell(text, &rows, edit.editor.cursor());
|
||||
let first_row = self.rows.len();
|
||||
for range in &rows {
|
||||
let row = text[range.clone()].trim_end_matches('\n').to_string();
|
||||
self.add_row(Some(part), vec![Span::styled(row, style)]);
|
||||
}
|
||||
self.editing = Some(Editing { first_row, text: text.to_string(), rows, cursor });
|
||||
}
|
||||
|
||||
/// The parts of the note, in order.
|
||||
fn parts(&self) -> Vec<Part> {
|
||||
let mut parts: Vec<Part> = Vec::new();
|
||||
for part in self.rows.iter().filter_map(|row| row.part) {
|
||||
if parts.last() != Some(&part) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// The first and last rows of the part you're on.
|
||||
fn part_rows(&self) -> Option<(usize, usize)> {
|
||||
let first = self.rows.iter().position(|row| row.part == Some(self.part))?;
|
||||
let last = self.rows.iter().rposition(|row| row.part == Some(self.part))?;
|
||||
Some((first, last))
|
||||
}
|
||||
|
||||
/// Scrolls as little as it takes to show rows `first` to `last` (or as
|
||||
/// many of them as fit, from `first`).
|
||||
fn show_rows(&mut self, first: usize, last: usize) {
|
||||
let height = usize::from(self.area.height).max(1);
|
||||
if last >= self.scroll + height {
|
||||
self.scroll = last + 1 - height;
|
||||
}
|
||||
if first < self.scroll || last + 1 - first > height {
|
||||
self.scroll = first;
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_by(&mut self, rows: isize) {
|
||||
self.scroll = self.scroll.saturating_add_signed(rows).min(self.rows.len().saturating_sub(1));
|
||||
self.scrolled = true;
|
||||
}
|
||||
|
||||
/// `j` and `k`: a part further down or up.
|
||||
fn step(&mut self, delta: isize) -> Vec<Action> {
|
||||
let parts = self.parts();
|
||||
let Some(here) = parts.iter().position(|&part| part == self.part) else { return Vec::new() };
|
||||
let there = here.saturating_add_signed(delta).min(parts.len().saturating_sub(1));
|
||||
if there == here {
|
||||
let text = if delta > 0 { "that's the end of the note" } else { "that's the top of the note" };
|
||||
return vec![Action::say(Message::info(text))];
|
||||
}
|
||||
self.part = parts[there];
|
||||
self.scrolled = false;
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Enter, `i`, `I` or `cc` on the part you're on: edit it from `at`, or
|
||||
/// go to that link or child. `None` for the title, which edits the way
|
||||
/// it does everywhere (or goes through a portal).
|
||||
fn open(&self, at: EditAt) -> Option<Vec<Action>> {
|
||||
let start = match self.part {
|
||||
Part::Title => return None,
|
||||
Part::Tags => StartEdit::Tags(at),
|
||||
Part::Body => StartEdit::Body { at, in_card: false },
|
||||
Part::Link(_) | Part::Child(_) if at != EditAt::End => {
|
||||
let text = "Enter goes there: the title, the tags and the body are the parts you type in";
|
||||
return Some(vec![Action::say(Message::info(text))]);
|
||||
}
|
||||
// Following a link is a jump: '' comes back.
|
||||
Part::Link(index) => {
|
||||
return Some(self.links.get(index).map(|&to| Action::jump(to)).into_iter().collect());
|
||||
}
|
||||
Part::Child(index) => {
|
||||
return Some(self.children.get(index).map(|&to| Action::cursor(to)).into_iter().collect());
|
||||
}
|
||||
};
|
||||
Some(vec![Action::App(AppAction::StartEdit(start))])
|
||||
}
|
||||
|
||||
/// The row and column under a terminal cell, if it's in the view.
|
||||
fn local(&self, cell: Cell) -> Option<(u16, usize)> {
|
||||
let at = Position::new(cell.col, cell.row);
|
||||
self.area.contains(at).then(|| (at.x - self.area.x, self.scroll + usize::from(at.y - self.area.y)))
|
||||
}
|
||||
|
||||
/// Where a click lands in the text being edited, if it's on its rows.
|
||||
fn text_offset_at(&self, (col, row): (u16, usize)) -> Option<usize> {
|
||||
let editing = self.editing.as_ref()?;
|
||||
let text_row =
|
||||
row.checked_sub(editing.first_row).filter(|&text_row| text_row < editing.rows.len())?;
|
||||
let column = usize::from(col.saturating_sub(MARGIN));
|
||||
Some(layout::offset(&editing.text, &editing.rows, text_row, column))
|
||||
}
|
||||
|
||||
/// The terminal cell of the text cursor, while a part is edited.
|
||||
fn text_cursor(&self) -> Option<Position> {
|
||||
let editing = self.editing.as_ref()?;
|
||||
let row = (editing.first_row + editing.cursor.0).checked_sub(self.scroll)?;
|
||||
let row = u16::try_from(row).ok().filter(|&row| row < self.area.height)?;
|
||||
let col = u16::try_from(editing.cursor.1).ok()?.saturating_add(MARGIN);
|
||||
(col < self.area.width).then(|| Position::new(self.area.x + col, self.area.y + row))
|
||||
}
|
||||
|
||||
/// Where a double-click on a part's text lands in it, to start editing
|
||||
/// there.
|
||||
fn spot_in(&self, part: Part, (col, row): (u16, usize), ctx: &ViewContext<'_>) -> EditAt {
|
||||
let Some(node) = ctx.doc.node(ctx.cursor) else { return EditAt::End };
|
||||
let text = match (part, &node.content) {
|
||||
(Part::Title, Content::Idea(idea)) => idea.title.to_string(),
|
||||
(Part::Tags, _) if !node.tags.is_empty() => node.tags.to_string(),
|
||||
(Part::Body, Content::Idea(idea)) if !idea.body.is_empty() => idea.body.as_str().to_string(),
|
||||
_ => return EditAt::End,
|
||||
};
|
||||
let Some(first) = self.rows.iter().position(|row| row.part == Some(part)) else { return EditAt::End };
|
||||
let rows = layout::rows(&text, self.text_width());
|
||||
EditAt::Offset(layout::offset(&text, &rows, row - first, usize::from(col.saturating_sub(MARGIN))))
|
||||
}
|
||||
}
|
||||
|
||||
impl View for NoteView {
|
||||
fn kind(&self) -> ViewKind {
|
||||
ViewKind::Note
|
||||
}
|
||||
|
||||
fn contexts(&self, mode: &Mode) -> &'static [Context] {
|
||||
contexts_for(mode, &[Context::Note, Context::Normal, Context::Motions])
|
||||
}
|
||||
|
||||
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
|
||||
self.area = area;
|
||||
if self.node.replace(ctx.cursor) != Some(ctx.cursor) {
|
||||
(self.part, self.scroll, self.scrolled) = (Part::Title, 0, false);
|
||||
}
|
||||
self.build(ctx);
|
||||
// The part you were on can be gone (a link removed elsewhere).
|
||||
if !self.rows.iter().any(|row| row.part == Some(self.part)) {
|
||||
self.part = Part::Title;
|
||||
}
|
||||
if let Some(edit) = ctx.mode.edit().filter(|edit| edit.node == ctx.cursor) {
|
||||
self.part = match edit.field {
|
||||
Field::Title => Part::Title,
|
||||
Field::Tags => Part::Tags,
|
||||
Field::Body => Part::Body,
|
||||
};
|
||||
}
|
||||
match &self.editing {
|
||||
Some(editing) => {
|
||||
let row = editing.first_row + editing.cursor.0;
|
||||
self.show_rows(row, row);
|
||||
}
|
||||
None if !self.scrolled => {
|
||||
if let Some((first, last)) = self.part_rows() {
|
||||
self.show_rows(first, last);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
self.scroll = self.scroll.min(self.rows.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) {
|
||||
let buf = frame.buffer_mut();
|
||||
let bar = ctx.theme.glyphs.part_mark().to_string();
|
||||
let bar_style = Style::new().fg(ctx.theme.accent);
|
||||
let width = self.area.width.saturating_sub(MARGIN);
|
||||
let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height));
|
||||
for (row, y) in rows.zip(self.area.y..) {
|
||||
if row.part == Some(self.part) {
|
||||
buf.set_string(self.area.x, y, &bar, bar_style);
|
||||
}
|
||||
buf.set_line(self.area.x + MARGIN, y, &row.line, width);
|
||||
}
|
||||
if let Some(cell) = self.text_cursor() {
|
||||
frame.set_cursor_position(cell);
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
match ctx.mode {
|
||||
Mode::Move(grab) => return commands::reorder(typed, *grab, ctx),
|
||||
Mode::Normal => {}
|
||||
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
|
||||
}
|
||||
let times = isize::try_from(typed.times()).unwrap_or(1);
|
||||
let half_page = isize::try_from(self.area.height / 2).unwrap_or(1).max(1);
|
||||
let tree =
|
||||
|motion: Motion| commands::shared(&Typed { command: Command::Move(motion), ..*typed }, ctx);
|
||||
match typed.command {
|
||||
Command::Move(Motion::Down) => return Some(self.step(times)),
|
||||
Command::Move(Motion::Up) => return Some(self.step(-times)),
|
||||
// h and l turn the page: the sibling before or after.
|
||||
Command::Move(Motion::Left) => return tree(Motion::PreviousSibling),
|
||||
Command::Move(Motion::Right) => return tree(Motion::NextSibling),
|
||||
Command::Activate | Command::EditTitle => return self.open(EditAt::End),
|
||||
Command::EditTitleAtStart => return self.open(EditAt::Start),
|
||||
Command::RetypeTitle => return self.open(EditAt::Retype),
|
||||
Command::ScrollDown => self.scroll_by(times),
|
||||
Command::ScrollUp => self.scroll_by(-times),
|
||||
Command::HalfPageDown => self.scroll_by(half_page * times),
|
||||
Command::HalfPageUp => self.scroll_by(-half_page * times),
|
||||
Command::Center => {
|
||||
if let Some((first, _)) = self.part_rows() {
|
||||
self.scroll = first.saturating_sub(usize::from(self.area.height / 2));
|
||||
self.scrolled = true;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(Vec::new())
|
||||
}
|
||||
|
||||
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
match gesture {
|
||||
Gesture::Press { at, .. } => {
|
||||
let Some(at) = self.local(at) else { return Vec::new() };
|
||||
let edit = ctx.mode.edit().filter(|edit| edit.node == ctx.cursor);
|
||||
if let Some(edit) = edit {
|
||||
// A click in the text moves the text cursor; anywhere
|
||||
// else, it keeps the edit and finishes it.
|
||||
if let Some(offset) = self.text_offset_at(at) {
|
||||
return vec![Action::App(AppAction::TextCursor(offset))];
|
||||
}
|
||||
if let Some(part) = self.rows.get(at.1).and_then(|row| row.part) {
|
||||
self.part = part;
|
||||
}
|
||||
return edit.finish(ctx);
|
||||
}
|
||||
if let Some(part) = self.rows.get(at.1).and_then(|row| row.part) {
|
||||
(self.part, self.scrolled) = (part, false);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
Gesture::DoublePress { at } => {
|
||||
let Some(at) = self.local(at) else { return Vec::new() };
|
||||
if let Some(offset) = self.text_offset_at(at) {
|
||||
return vec![Action::App(AppAction::TextCursor(offset))];
|
||||
}
|
||||
let Some(part) = self.rows.get(at.1).and_then(|row| row.part) else { return Vec::new() };
|
||||
self.part = part;
|
||||
let spot = self.spot_in(part, at, ctx);
|
||||
let mut actions = ctx.mode.edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
|
||||
let open = match part {
|
||||
Part::Title => Some(vec![Action::App(AppAction::StartEdit(StartEdit::title(spot)))]),
|
||||
Part::Tags => Some(vec![Action::App(AppAction::StartEdit(StartEdit::Tags(spot)))]),
|
||||
Part::Body => {
|
||||
let start = StartEdit::Body { at: spot, in_card: false };
|
||||
Some(vec![Action::App(AppAction::StartEdit(start))])
|
||||
}
|
||||
Part::Link(_) | Part::Child(_) => self.open(EditAt::End),
|
||||
};
|
||||
actions.extend(open.unwrap_or_default());
|
||||
actions
|
||||
}
|
||||
Gesture::Scroll { direction, .. } => {
|
||||
let rows = isize::try_from(WHEEL_ROWS).unwrap_or(1);
|
||||
match direction {
|
||||
Direction::Down => self.scroll_by(rows),
|
||||
Direction::Up => self.scroll_by(-rows),
|
||||
Direction::Left | Direction::Right => {}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
Gesture::RightPress { at } => {
|
||||
let mut actions = ctx.mode.edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
|
||||
let what = MenuFor::Node(ctx.cursor);
|
||||
actions.push(Action::App(AppAction::OpenMenu { what, col: at.col, row: at.row }));
|
||||
actions
|
||||
}
|
||||
Gesture::Drag { .. } | Gesture::Release { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,154 @@
|
||||
//! The Note view drawn on a test terminal, and driven by keys and clicks.
|
||||
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
use super::*;
|
||||
use crate::model::test_support::{Garden, garden};
|
||||
use crate::model::{Body, ColorName, Document, EditOp, Label, Link, Tags};
|
||||
use crate::ui::text_editor::Typing;
|
||||
use crate::view::test_support::Setup;
|
||||
|
||||
fn typed(command: Command) -> Typed {
|
||||
Typed { command, count: None, register: None }
|
||||
}
|
||||
|
||||
/// The garden, with Raised beds given a body, tags, a color and a link.
|
||||
fn garden_note() -> (Document, Garden) {
|
||||
let (mut doc, g) = garden();
|
||||
doc.apply(&EditOp::SetBody { id: g.beds, body: Body::new("Cedar, three of them.\n40 cm high.") })
|
||||
.unwrap();
|
||||
doc.apply(&EditOp::SetTags { id: g.beds, tags: Tags::parse("#todo #wood").unwrap() }).unwrap();
|
||||
doc.apply(&EditOp::SetColor { id: g.beds, color: ColorName::parse("orange").ok() }).unwrap();
|
||||
let mut link = Link::new(g.beds, g.compost).unwrap();
|
||||
link.label = Some(Label::parse("needs").unwrap());
|
||||
doc.apply(&EditOp::Link(link)).unwrap();
|
||||
(doc, g)
|
||||
}
|
||||
|
||||
/// Lays the note out at the top-left of a test terminal and draws it.
|
||||
/// Returns what's on screen, row by row.
|
||||
fn draw(view: &mut NoteView, ctx: &ViewContext<'_>) -> Vec<String> {
|
||||
let (width, height) = (40, 11);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
view.layout(Rect::new(0, 0, width, height), ctx);
|
||||
terminal.draw(|frame| view.render(frame, ctx)).unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
(0..height)
|
||||
.map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect::<String>().trim_end().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The rows the bar marks.
|
||||
fn marked(rows: &[String]) -> Vec<usize> {
|
||||
rows.iter().enumerate().filter(|(_, row)| row.starts_with('▌')).map(|(index, _)| index).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_shows_every_part() {
|
||||
let ((doc, g), setup) = (garden_note(), Setup::default());
|
||||
let mut view = NoteView::new();
|
||||
assert_eq!(
|
||||
draw(&mut view, &setup.ctx(&doc, g.beds)),
|
||||
[
|
||||
"▌ Raised beds",
|
||||
" #todo #wood ● orange",
|
||||
"",
|
||||
" Cedar, three of them.",
|
||||
" 40 cm high.",
|
||||
"",
|
||||
" Links",
|
||||
" → ask about compost needs",
|
||||
" Children",
|
||||
" · Cedar",
|
||||
"",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_and_k_go_from_part_to_part() {
|
||||
let ((doc, g), setup) = (garden_note(), Setup::default());
|
||||
let mut view = NoteView::new();
|
||||
let ctx = setup.ctx(&doc, g.beds);
|
||||
draw(&mut view, &ctx);
|
||||
let press = |view: &mut NoteView, command: Command| view.command(&typed(command), &ctx);
|
||||
press(&mut view, Command::Move(Motion::Down));
|
||||
press(&mut view, Command::Move(Motion::Down));
|
||||
assert_eq!(marked(&draw(&mut view, &ctx)), [3, 4], "the body: both its rows");
|
||||
let body = StartEdit::Body { at: EditAt::End, in_card: false };
|
||||
assert_eq!(press(&mut view, Command::Activate), Some(vec![Action::App(AppAction::StartEdit(body))]));
|
||||
press(&mut view, Command::Move(Motion::Down));
|
||||
assert_eq!(press(&mut view, Command::Activate), Some(vec![Action::jump(g.compost)]), "a link: a jump");
|
||||
press(&mut view, Command::Move(Motion::Down));
|
||||
assert_eq!(press(&mut view, Command::EditTitle), Some(vec![Action::cursor(g.cedar)]), "a child");
|
||||
assert_eq!(
|
||||
press(&mut view, Command::Move(Motion::Down)),
|
||||
Some(vec![Action::say(Message::info("that's the end of the note"))])
|
||||
);
|
||||
// Back up to the tags: I edits them from the start.
|
||||
view.command(&Typed { command: Command::Move(Motion::Up), count: Some(3), register: None }, &ctx);
|
||||
let tags = StartEdit::Tags(EditAt::Start);
|
||||
assert_eq!(
|
||||
press(&mut view, Command::EditTitleAtStart),
|
||||
Some(vec![Action::App(AppAction::StartEdit(tags))])
|
||||
);
|
||||
// On the title, the keys edit it the way they do everywhere.
|
||||
press(&mut view, Command::Move(Motion::Up));
|
||||
assert_eq!(press(&mut view, Command::EditTitle), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_note_follows_the_cursor() {
|
||||
let ((doc, g), setup) = (garden_note(), Setup::default());
|
||||
let mut view = NoteView::new();
|
||||
draw(&mut view, &setup.ctx(&doc, g.beds));
|
||||
view.command(&typed(Command::Move(Motion::Down)), &setup.ctx(&doc, g.beds));
|
||||
let rows = draw(&mut view, &setup.ctx(&doc, g.soil));
|
||||
assert_eq!(rows[..2], ["▌ Soil", " no tags"], "another node: its title first");
|
||||
let mut page = |motion| view.command(&typed(Command::Move(motion)), &setup.ctx(&doc, g.beds));
|
||||
assert_eq!(page(Motion::Left), Some(vec![Action::cursor(g.soil)]), "h: the sibling before");
|
||||
assert_eq!(page(Motion::Right), Some(vec![Action::cursor(g.watering)]), "l: the sibling after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_body_is_edited_in_place() {
|
||||
let (doc, g) = garden_note();
|
||||
let mut setup = Setup::default();
|
||||
let start = StartEdit::Body { at: EditAt::End, in_card: false };
|
||||
setup.mode = Mode::Text(TextEdit::start(&setup.ctx(&doc, g.beds), start).unwrap());
|
||||
if let Mode::Text(edit) = &mut setup.mode {
|
||||
"\nThree.".chars().for_each(|c| _ = edit.editor.type_in(Typing::Char(c)));
|
||||
}
|
||||
let mut view = NoteView::new();
|
||||
let ctx = setup.ctx(&doc, g.beds);
|
||||
let rows = draw(&mut view, &ctx);
|
||||
assert_eq!(rows[3..6], ["▌ Cedar, three of them.", "▌ 40 cm high.", "▌ Three."]);
|
||||
assert_eq!(view.text_cursor(), Some(Position::new(8, 5)), "after the last letter typed");
|
||||
let click = Gesture::Press { at: Cell { col: 4, row: 3 }, ctrl: false };
|
||||
assert_eq!(
|
||||
view.mouse(click, &ctx),
|
||||
[Action::App(AppAction::TextCursor(2))],
|
||||
"a click moves the text cursor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_pick_parts_and_double_clicks_open_them() {
|
||||
let ((doc, g), setup) = (garden_note(), Setup::default());
|
||||
let mut view = NoteView::new();
|
||||
let ctx = setup.ctx(&doc, g.beds);
|
||||
draw(&mut view, &ctx);
|
||||
assert_eq!(view.mouse(Gesture::Press { at: Cell { col: 5, row: 9 }, ctrl: false }, &ctx), []);
|
||||
assert_eq!(marked(&draw(&mut view, &ctx)), [9], "the click picked the child");
|
||||
assert_eq!(
|
||||
view.mouse(Gesture::DoublePress { at: Cell { col: 5, row: 9 } }, &ctx),
|
||||
[Action::cursor(g.cedar)]
|
||||
);
|
||||
let start = StartEdit::Body { at: EditAt::Offset(5), in_card: false };
|
||||
assert_eq!(
|
||||
view.mouse(Gesture::DoublePress { at: Cell { col: 7, row: 3 } }, &ctx),
|
||||
[Action::App(AppAction::StartEdit(start))],
|
||||
"on the body: editing it from the letter clicked"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//! The outline: the tree as an indented list, to reorganize and to read.
|
||||
//!
|
||||
//! ```text
|
||||
//! ▾ Garden plans
|
||||
//! ├ Soil #todo
|
||||
//! ├ ▸ Raised beds ¶ +2
|
||||
//! ├ ▾ Watering
|
||||
//! │ ├ Rain barrel
|
||||
//! │ └ ▸ Drip line +1
|
||||
//! └ » Kitchen
|
||||
//! ◦ ask about compost ¶
|
||||
//! ```
|
||||
//!
|
||||
//! A row for each node on show (it folds the way every view does), with its
|
||||
//! fold marker, its tags, `¶` when it has a body and `+N` for what a fold
|
||||
//! hides. The tree lines take the parent's color, as branches do on the
|
||||
//! canvas. `zb` shows every body under its title, to read the document like
|
||||
//! a page.
|
||||
//!
|
||||
//! `j` and `k` go row by row, `h` and `l` to the parent and the first child.
|
||||
//! In move mode a node moves through the tree rather than across the canvas:
|
||||
//! `j` and `k` swap it with its siblings, `h` takes it out a level, and `l`
|
||||
//! puts it under the one above.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app::action::{Action, AppAction, Message};
|
||||
use crate::app::commands;
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::popup::MenuFor;
|
||||
use crate::app::text_edit::{EditAt, StartEdit, TextEdit};
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::command::{Command, Motion};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::mouse::{Cell, Gesture};
|
||||
use crate::model::{Content, Direction, EditOp, Fold, NodeId, Visible};
|
||||
use crate::ui::text_editor::layout;
|
||||
use crate::view::{View, ViewContext, ViewKind, contexts_for};
|
||||
|
||||
/// How close the cursor's row may come to the top or the bottom before the
|
||||
/// outline scrolls, like vim's `scrolloff`.
|
||||
const SCROLL_OFF: usize = 3;
|
||||
/// How many rows the mouse wheel scrolls.
|
||||
const WHEEL_ROWS: usize = 3;
|
||||
/// How many columns each level of the tree is indented.
|
||||
const INDENT: u16 = 4;
|
||||
|
||||
/// The outline view.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OutlineView {
|
||||
/// Where on screen the view is.
|
||||
area: Rect,
|
||||
/// The first row on show.
|
||||
scroll: usize,
|
||||
/// Every body shows under its title (`zb`).
|
||||
bodies: bool,
|
||||
/// The rows laid out last: what's drawn, and what a click lands on.
|
||||
rows: Vec<Row>,
|
||||
/// The cursor at the last layout. When it moves, the view follows.
|
||||
last_cursor: Option<NodeId>,
|
||||
/// The view was scrolled by hand: it stays put until the cursor moves.
|
||||
scrolled: bool,
|
||||
/// The title being edited, as drawn.
|
||||
editing: Option<Editing>,
|
||||
}
|
||||
|
||||
/// One row, ready to draw.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Row {
|
||||
/// The node it's about (a line of a body is about its node).
|
||||
id: NodeId,
|
||||
line: Line<'static>,
|
||||
/// Where the title, or the line of the body, starts.
|
||||
text_col: u16,
|
||||
/// On the title row of a node with children: where the fold marker is,
|
||||
/// and whether the children are folded away.
|
||||
marker: Option<(u16, bool)>,
|
||||
/// The title row, rather than a line of the body.
|
||||
title: bool,
|
||||
}
|
||||
|
||||
/// The title being edited, as drawn: where its text starts, scrolled
|
||||
/// sideways so that the text cursor stays in view.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Editing {
|
||||
row: usize,
|
||||
text_col: u16,
|
||||
/// How many columns of the text are scrolled off to the left.
|
||||
skip: usize,
|
||||
text: String,
|
||||
/// The text cursor's column, counted from the start of the text.
|
||||
cursor_col: usize,
|
||||
}
|
||||
|
||||
impl OutlineView {
|
||||
pub fn new() -> OutlineView {
|
||||
OutlineView::default()
|
||||
}
|
||||
|
||||
/// Lays out a row for every node on show (and the lines of their bodies,
|
||||
/// when bodies show).
|
||||
fn build(&mut self, ctx: &ViewContext<'_>) {
|
||||
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
|
||||
let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus);
|
||||
let edit = ctx.mode.title_edit();
|
||||
// The color of the lines from a node to its children, as on the
|
||||
// canvas: the node's own, or dim when it has none.
|
||||
let line_style = |id: NodeId| {
|
||||
let color =
|
||||
doc.node(id).map_or(Color::Reset, |node| theme.node_style(node, id == doc.root()).color);
|
||||
Style::new().fg(if color == Color::Reset { theme.muted } else { color })
|
||||
};
|
||||
self.rows.clear();
|
||||
self.editing = None;
|
||||
for &id in &visible.order {
|
||||
// Top-down: the top-level node this one is under, …, its parent.
|
||||
let mut path: Vec<NodeId> = doc.ancestors(id).collect();
|
||||
path.reverse();
|
||||
// The lines of the levels above, going on down past this row,
|
||||
// then this node's joint to its parent's line. The lines of its
|
||||
// body go on below the way its children's would.
|
||||
let mut lines = Lines::default();
|
||||
let mut body_lines = Lines::default();
|
||||
body_lines.push(" ", Style::new());
|
||||
if let Some(&parent) = path.last() {
|
||||
lines.push(" ", Style::new());
|
||||
for pair in path.windows(2) {
|
||||
let going_on = doc.next_sibling(pair[1]).is_some();
|
||||
lines.push_level(going_on, glyphs.tree_line(), line_style(pair[0]));
|
||||
}
|
||||
let last = doc.next_sibling(id).is_none();
|
||||
body_lines = lines.clone();
|
||||
body_lines.push_level(!last, glyphs.tree_line(), line_style(parent));
|
||||
lines.push(format!("{} ", glyphs.tree_joint(last)), line_style(parent));
|
||||
}
|
||||
self.add_title(ctx, id, &visible, lines, edit);
|
||||
self.add_body(ctx, id, body_lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// The title row of a node: its marker, title, tags, `¶` and `+N`.
|
||||
fn add_title(
|
||||
&mut self,
|
||||
ctx: &ViewContext<'_>,
|
||||
id: NodeId,
|
||||
visible: &Visible,
|
||||
mut lines: Lines,
|
||||
edit: Option<&TextEdit>,
|
||||
) {
|
||||
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
|
||||
let Some(node) = doc.node(id) else { return };
|
||||
let top_level = lines.spans.is_empty();
|
||||
let color = theme.node_style(node, id == doc.root()).color;
|
||||
let has_children = !doc.children(id).is_empty();
|
||||
let folded = visible.hidden.contains_key(&id);
|
||||
let marker = if node.is_portal() {
|
||||
Some(glyphs.portal())
|
||||
} else if has_children {
|
||||
Some(glyphs.fold(folded))
|
||||
} else if top_level {
|
||||
Some(glyphs.loose())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let marker_at = (has_children && !node.is_portal()).then_some((lines.width, folded));
|
||||
if let Some(marker) = marker {
|
||||
lines.push(format!("{marker} "), Style::new().fg(color));
|
||||
}
|
||||
let text_col = lines.width;
|
||||
let muted = Style::new().fg(theme.muted);
|
||||
let title_style = if id == ctx.cursor {
|
||||
Style::new().fg(Color::Black).bg(theme.accent).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new()
|
||||
};
|
||||
match edit.filter(|edit| edit.node == id) {
|
||||
Some(edit) => {
|
||||
// The title as typed, scrolled so the text cursor shows.
|
||||
let text = edit.editor.text();
|
||||
let room = usize::from(self.area.width.saturating_sub(text_col)).max(1);
|
||||
let cursor_col = text[..edit.editor.cursor()].width();
|
||||
let (shown, skip) = cut_left(text, (cursor_col + 1).saturating_sub(room));
|
||||
let color = if edit.editor.is_typing() { theme.insert } else { theme.accent };
|
||||
lines.push(shown.to_string(), Style::new().fg(color).add_modifier(Modifier::BOLD));
|
||||
self.editing = Some(Editing {
|
||||
row: self.rows.len(),
|
||||
text_col,
|
||||
skip,
|
||||
text: text.to_string(),
|
||||
cursor_col,
|
||||
});
|
||||
}
|
||||
None => lines.push(node.title().to_string(), title_style),
|
||||
}
|
||||
if !node.tags.is_empty() {
|
||||
lines.push(format!(" {}", node.tags), muted);
|
||||
}
|
||||
let has_body = matches!(&node.content, Content::Idea(idea) if !idea.body.is_empty());
|
||||
if has_body && !self.bodies {
|
||||
lines.push(format!(" {}", glyphs.body_mark()), muted);
|
||||
}
|
||||
if let Some(count) = visible.hidden.get(&id) {
|
||||
lines.push(format!(" +{count}"), muted);
|
||||
}
|
||||
self.rows.push(Row { id, line: Line::from(lines.spans), text_col, marker: marker_at, title: true });
|
||||
}
|
||||
|
||||
/// The lines of a node's body, under its title, when bodies show.
|
||||
fn add_body(&mut self, ctx: &ViewContext<'_>, id: NodeId, lines: Lines) {
|
||||
if !self.bodies {
|
||||
return;
|
||||
}
|
||||
let Some(Content::Idea(idea)) = ctx.doc.node(id).map(|node| &node.content) else { return };
|
||||
let body = idea.body.as_str();
|
||||
if body.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text_col = lines.width;
|
||||
let room = usize::from(self.area.width.saturating_sub(text_col)).max(8);
|
||||
for range in layout::rows(body, room) {
|
||||
let text = body[range].trim_end_matches('\n').to_string();
|
||||
let mut spans = lines.spans.clone();
|
||||
spans.push(Span::raw(text));
|
||||
self.rows.push(Row { id, line: Line::from(spans), text_col, marker: None, title: false });
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls so the cursor's row shows, a few rows from the edges.
|
||||
fn show_cursor_row(&mut self, cursor: NodeId) {
|
||||
let Some(index) = self.rows.iter().position(|row| row.id == cursor && row.title) else { return };
|
||||
let height = usize::from(self.area.height).max(1);
|
||||
let off = SCROLL_OFF.min(height.saturating_sub(1) / 2);
|
||||
if index < self.scroll + off {
|
||||
self.scroll = index.saturating_sub(off);
|
||||
} else if index + off >= self.scroll + height {
|
||||
self.scroll = index + off + 1 - height;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls by some rows, up when negative. The cursor stays where it is.
|
||||
fn scroll_by(&mut self, rows: isize) {
|
||||
self.scroll = self.scroll.saturating_add_signed(rows).min(self.rows.len().saturating_sub(1));
|
||||
self.scrolled = true;
|
||||
}
|
||||
|
||||
/// The row and column under a terminal cell, if it's in the view.
|
||||
fn local(&self, cell: Cell) -> Option<(u16, usize)> {
|
||||
let at = Position::new(cell.col, cell.row);
|
||||
self.area.contains(at).then(|| (at.x - self.area.x, self.scroll + usize::from(at.y - self.area.y)))
|
||||
}
|
||||
|
||||
/// Where a click lands in the title being edited, if it's on its text.
|
||||
fn text_offset_at(&self, (col, row): (u16, usize)) -> Option<usize> {
|
||||
let editing =
|
||||
self.editing.as_ref().filter(|editing| editing.row == row && col >= editing.text_col)?;
|
||||
let column = usize::from(col - editing.text_col) + editing.skip;
|
||||
Some(layout::offset(&editing.text, &layout::rows(&editing.text, usize::MAX), 0, column))
|
||||
}
|
||||
|
||||
/// The terminal cell of the text cursor, while a title is edited.
|
||||
fn text_cursor(&self) -> Option<Position> {
|
||||
let editing = self.editing.as_ref()?;
|
||||
let row = u16::try_from(editing.row.checked_sub(self.scroll)?).ok()?;
|
||||
let col = usize::from(editing.text_col) + editing.cursor_col - editing.skip;
|
||||
let col = u16::try_from(col).ok()?;
|
||||
(row < self.area.height && col < self.area.width)
|
||||
.then(|| Position::new(self.area.x + col, self.area.y + row))
|
||||
}
|
||||
|
||||
/// A left press: on a row, its node becomes the cursor; on a fold marker,
|
||||
/// the branch folds or unfolds too.
|
||||
fn press(&self, at: (u16, usize), ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let Some(row) = self.rows.get(at.1) else { return Vec::new() };
|
||||
let mut actions = vec![Action::cursor(row.id)];
|
||||
if row.title
|
||||
&& let Some((col, folded)) = row.marker
|
||||
&& col == at.0
|
||||
&& ctx.doc.contains(row.id)
|
||||
{
|
||||
let fold = if folded { Fold::Open } else { Fold::Closed };
|
||||
actions.push(Action::edit(EditOp::SetFold { ids: vec![row.id], fold }));
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
/// Where a double-click on a title lands in it, to start editing there.
|
||||
fn title_spot(&self, row: &Row, col: u16, ctx: &ViewContext<'_>) -> EditAt {
|
||||
let Some(node) = ctx.doc.node(row.id) else { return EditAt::End };
|
||||
if col < row.text_col {
|
||||
return EditAt::End;
|
||||
}
|
||||
let title = node.title().as_str();
|
||||
EditAt::Offset(layout::offset(
|
||||
title,
|
||||
&layout::rows(title, usize::MAX),
|
||||
0,
|
||||
usize::from(col - row.text_col),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The spans of a row as it's built, and how wide they are so far.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct Lines {
|
||||
spans: Vec<Span<'static>>,
|
||||
width: u16,
|
||||
}
|
||||
|
||||
impl Lines {
|
||||
fn push(&mut self, text: impl Into<String>, style: Style) {
|
||||
let text = text.into();
|
||||
self.width = self.width.saturating_add(u16::try_from(text.width()).unwrap_or(u16::MAX));
|
||||
self.spans.push(Span::styled(text, style));
|
||||
}
|
||||
|
||||
/// One level of the tree: its line going on down, or room where it ended.
|
||||
fn push_level(&mut self, going_on: bool, line: char, style: Style) {
|
||||
let indent = usize::from(INDENT);
|
||||
let text = if going_on { format!("{line}{}", " ".repeat(indent - 1)) } else { " ".repeat(indent) };
|
||||
self.push(text, style);
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` without its first `columns` columns, and how many columns that
|
||||
/// took off (a wide character isn't cut in half, so it can be one more).
|
||||
fn cut_left(text: &str, columns: usize) -> (&str, usize) {
|
||||
let mut cut = 0;
|
||||
for (at, c) in text.char_indices() {
|
||||
if cut >= columns {
|
||||
return (&text[at..], cut);
|
||||
}
|
||||
cut += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
|
||||
}
|
||||
("", cut)
|
||||
}
|
||||
|
||||
fn say(text: &str) -> Vec<Action> {
|
||||
vec![Action::say(Message::info(text))]
|
||||
}
|
||||
|
||||
impl View for OutlineView {
|
||||
fn kind(&self) -> ViewKind {
|
||||
ViewKind::Outline
|
||||
}
|
||||
|
||||
fn contexts(&self, mode: &Mode) -> &'static [Context] {
|
||||
contexts_for(mode, &[Context::Outline, Context::Normal, Context::Motions])
|
||||
}
|
||||
|
||||
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
|
||||
self.area = area;
|
||||
if self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor) {
|
||||
self.scrolled = false;
|
||||
}
|
||||
self.build(ctx);
|
||||
if !self.scrolled || self.editing.is_some() {
|
||||
self.show_cursor_row(ctx.cursor);
|
||||
}
|
||||
self.scroll = self.scroll.min(self.rows.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
|
||||
let buf = frame.buffer_mut();
|
||||
let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height));
|
||||
for (row, y) in rows.zip(self.area.y..) {
|
||||
buf.set_line(self.area.x, y, &row.line, self.area.width);
|
||||
}
|
||||
if let Some(cell) = self.text_cursor() {
|
||||
frame.set_cursor_position(cell);
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
match ctx.mode {
|
||||
Mode::Move(grab) => return commands::reorder(typed, *grab, ctx),
|
||||
Mode::Normal => {}
|
||||
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
|
||||
}
|
||||
let rows = isize::try_from(typed.times()).unwrap_or(1);
|
||||
let half_page = isize::try_from(self.area.height / 2).unwrap_or(1).max(1);
|
||||
// hjkl follow the rows: the next and previous in reading order, the
|
||||
// parent and the first child.
|
||||
let tree =
|
||||
|motion: Motion| commands::shared(&Typed { command: Command::Move(motion), ..*typed }, ctx);
|
||||
match typed.command {
|
||||
Command::Move(Motion::Down) => return tree(Motion::Next),
|
||||
Command::Move(Motion::Up) => return tree(Motion::Previous),
|
||||
Command::Move(Motion::Left) => return tree(Motion::Parent),
|
||||
Command::Move(Motion::Right) => return tree(Motion::FirstChild),
|
||||
Command::ScrollDown => self.scroll_by(rows),
|
||||
Command::ScrollUp => self.scroll_by(-rows),
|
||||
Command::HalfPageDown => self.scroll_by(half_page * rows),
|
||||
Command::HalfPageUp => self.scroll_by(-half_page * rows),
|
||||
Command::Center => {
|
||||
if let Some(index) = self.rows.iter().position(|row| row.id == ctx.cursor && row.title) {
|
||||
self.scroll = index.saturating_sub(usize::from(self.area.height / 2));
|
||||
self.scrolled = true;
|
||||
}
|
||||
}
|
||||
Command::ToggleBodies => {
|
||||
self.bodies = !self.bodies;
|
||||
let text = if self.bodies {
|
||||
"every body shows under its title: zb hides them"
|
||||
} else {
|
||||
"bodies hidden: ¶ marks the nodes that have one"
|
||||
};
|
||||
return Some(say(text));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(Vec::new())
|
||||
}
|
||||
|
||||
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
match gesture {
|
||||
Gesture::Press { at, .. } => {
|
||||
let Some(at) = self.local(at) else { return Vec::new() };
|
||||
let Some(edit) = ctx.mode.title_edit() else { return self.press(at, ctx) };
|
||||
// While a title is edited, a click in it moves the text
|
||||
// cursor; a click anywhere else keeps the edit, finishes it,
|
||||
// and does what a click does.
|
||||
if let Some(offset) = self.text_offset_at(at) {
|
||||
return vec![Action::App(AppAction::TextCursor(offset))];
|
||||
}
|
||||
let mut actions = edit.finish(ctx);
|
||||
actions.extend(self.press(at, ctx));
|
||||
actions
|
||||
}
|
||||
Gesture::DoublePress { at } => {
|
||||
let Some(at) = self.local(at) else { return Vec::new() };
|
||||
if let Some(offset) = self.text_offset_at(at) {
|
||||
return vec![Action::App(AppAction::TextCursor(offset))];
|
||||
}
|
||||
let Some(row) = self.rows.get(at.1).filter(|row| row.title) else { return Vec::new() };
|
||||
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
|
||||
let start = StartEdit::title(self.title_spot(row, at.0, ctx));
|
||||
actions.extend([Action::cursor(row.id), Action::App(AppAction::StartEdit(start))]);
|
||||
actions
|
||||
}
|
||||
Gesture::Scroll { direction, .. } => {
|
||||
let rows = isize::try_from(WHEEL_ROWS).unwrap_or(1);
|
||||
match direction {
|
||||
Direction::Down => self.scroll_by(rows),
|
||||
Direction::Up => self.scroll_by(-rows),
|
||||
Direction::Left | Direction::Right => {}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
Gesture::RightPress { at: cell } => {
|
||||
let Some(row) = self.local(cell).and_then(|at| self.rows.get(at.1)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
|
||||
let (what, col, row_at) = (MenuFor::Node(row.id), cell.col, cell.row);
|
||||
actions.extend([
|
||||
Action::cursor(row.id),
|
||||
Action::App(AppAction::OpenMenu { what, col, row: row_at }),
|
||||
]);
|
||||
actions
|
||||
}
|
||||
Gesture::Drag { .. } | Gesture::Release { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_area(&self, ctx: &ViewContext<'_>) -> Option<Rect> {
|
||||
let index = self.rows.iter().position(|row| row.id == ctx.cursor && row.title)?;
|
||||
let row =
|
||||
u16::try_from(index.checked_sub(self.scroll)?).ok().filter(|&row| row < self.area.height)?;
|
||||
let width = self.rows[index].line.width();
|
||||
let width = u16::try_from(width).unwrap_or(u16::MAX).min(self.area.width).max(1);
|
||||
Some(Rect::new(self.area.x, self.area.y + row, width, 1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,155 @@
|
||||
//! The outline drawn on a test terminal, and driven by keys and clicks.
|
||||
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
use super::*;
|
||||
use crate::app::action::DocAction;
|
||||
use crate::app::buffer::Buffer;
|
||||
use crate::app::mode::Grab;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::model::{Body, Tags};
|
||||
use crate::ui::text_editor::Typing;
|
||||
use crate::view::test_support::Setup;
|
||||
|
||||
fn typed(command: Command) -> Typed {
|
||||
Typed { command, count: None, register: None }
|
||||
}
|
||||
|
||||
/// Lays the outline out at the top-left of a test terminal and draws it.
|
||||
/// Returns what's on screen, row by row.
|
||||
fn draw(view: &mut OutlineView, ctx: &ViewContext<'_>, width: u16, height: u16) -> Vec<String> {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
view.layout(Rect::new(0, 0, width, height), ctx);
|
||||
terminal.draw(|frame| view.render(frame, ctx)).unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
(0..height)
|
||||
.map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect::<String>().trim_end().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_garden_as_an_outline() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let mut view = OutlineView::new();
|
||||
assert_eq!(
|
||||
draw(&mut view, &setup.ctx(&doc, g.root), 40, 7),
|
||||
[
|
||||
"▾ Garden plans",
|
||||
" ├ Soil",
|
||||
" ├ ▾ Raised beds",
|
||||
" │ └ Cedar",
|
||||
" └ Watering",
|
||||
"◦ ask about compost",
|
||||
"",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folds_tags_and_bodies() {
|
||||
let ((mut doc, g), setup) = (garden(), Setup::default());
|
||||
doc.apply(&EditOp::SetFold { ids: vec![g.beds], fold: Fold::Closed }).unwrap();
|
||||
doc.apply(&EditOp::SetBody { id: g.beds, body: Body::new("Cedar, three of them.\n40 cm high.") })
|
||||
.unwrap();
|
||||
doc.apply(&EditOp::SetTags { id: g.soil, tags: Tags::parse("#todo").unwrap() }).unwrap();
|
||||
let mut view = OutlineView::new();
|
||||
let ctx = setup.ctx(&doc, g.root);
|
||||
let rows = draw(&mut view, &ctx, 40, 6);
|
||||
assert_eq!(
|
||||
rows[1..4],
|
||||
[" ├ Soil #todo", " ├ ▸ Raised beds ¶ +1", " └ Watering"],
|
||||
"Cedar is folded away"
|
||||
);
|
||||
// zb: every body under its title, where the children hang.
|
||||
view.command(&typed(Command::ToggleBodies), &ctx);
|
||||
let rows = draw(&mut view, &ctx, 40, 6);
|
||||
assert_eq!(rows[2..5], [" ├ ▸ Raised beds +1", " │ Cedar, three of them.", " │ 40 cm high."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hjkl_follow_the_rows() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let mut view = OutlineView::new();
|
||||
let at_root = setup.ctx(&doc, g.root);
|
||||
draw(&mut view, &at_root, 40, 7);
|
||||
let go = |view: &mut OutlineView, motion: Motion, ctx: &ViewContext<'_>| {
|
||||
view.command(&typed(Command::Move(motion)), ctx)
|
||||
};
|
||||
assert_eq!(go(&mut view, Motion::Down, &at_root), Some(vec![Action::cursor(g.soil)]));
|
||||
assert_eq!(
|
||||
go(&mut view, Motion::Right, &at_root),
|
||||
Some(vec![Action::cursor(g.soil)]),
|
||||
"l: the first child"
|
||||
);
|
||||
let at_cedar = setup.ctx(&doc, g.cedar);
|
||||
assert_eq!(go(&mut view, Motion::Left, &at_cedar), Some(vec![Action::cursor(g.beds)]), "h: the parent");
|
||||
assert_eq!(go(&mut view, Motion::Down, &at_cedar), Some(vec![Action::cursor(g.watering)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_mode_reorders_as_one_undo_step() {
|
||||
let (doc, g) = garden();
|
||||
let setup = Setup { mode: Mode::Move(Grab::new()), ..Setup::default() };
|
||||
let mut buffer = Buffer::new(doc);
|
||||
buffer.apply(DocAction::MoveCursor { to: g.soil, jump: false });
|
||||
let mut view = OutlineView::new();
|
||||
// j: Soil goes after Raised beds; l: under the node above it, which is
|
||||
// Raised beds now.
|
||||
for command in [Command::Move(Motion::Down), Command::Move(Motion::Right)] {
|
||||
for action in view.command(&typed(command), &setup.ctx(buffer.doc(), buffer.cursor())).unwrap() {
|
||||
let Action::Doc(action) = action else { panic!("only edits") };
|
||||
buffer.apply(action);
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer.doc().children(g.root), [g.beds, g.watering]);
|
||||
assert_eq!(buffer.doc().children(g.beds), [g.cedar, g.soil]);
|
||||
buffer.apply(DocAction::Undo(1));
|
||||
assert_eq!(buffer.doc().children(g.root), [g.soil, g.beds, g.watering], "one undo puts it all back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_is_edited_in_its_row() {
|
||||
let (doc, g) = garden();
|
||||
let mut setup = Setup::default();
|
||||
let edit = TextEdit::start(&setup.ctx(&doc, g.soil), StartEdit::title(EditAt::End)).unwrap();
|
||||
setup.mode = Mode::Text(edit);
|
||||
if let Mode::Text(edit) = &mut setup.mode {
|
||||
" mix".chars().for_each(|c| _ = edit.editor.type_in(Typing::Char(c)));
|
||||
}
|
||||
let mut view = OutlineView::new();
|
||||
let ctx = setup.ctx(&doc, g.soil);
|
||||
assert_eq!(draw(&mut view, &ctx, 40, 7)[1], " ├ Soil mix");
|
||||
assert_eq!(view.text_cursor(), Some(Position::new(12, 1)), "after the last letter");
|
||||
// Too narrow for the text: it scrolls sideways to keep the cursor in view.
|
||||
assert_eq!(draw(&mut view, &ctx, 10, 7)[1], " ├ l mix");
|
||||
assert_eq!(view.text_cursor(), Some(Position::new(9, 1)));
|
||||
let click = Gesture::Press { at: Cell { col: 5, row: 1 }, ctrl: false };
|
||||
assert_eq!(view.mouse(click, &ctx), [Action::App(AppAction::TextCursor(4))], "on the space before mix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_select_fold_and_edit() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let mut view = OutlineView::new();
|
||||
let ctx = setup.ctx(&doc, g.root);
|
||||
draw(&mut view, &ctx, 40, 7);
|
||||
let press = |col, row| Gesture::Press { at: Cell { col, row }, ctrl: false };
|
||||
assert_eq!(view.mouse(press(6, 4), &ctx), [Action::cursor(g.watering)]);
|
||||
let fold = Action::edit(EditOp::SetFold { ids: vec![g.beds], fold: Fold::Closed });
|
||||
assert_eq!(view.mouse(press(4, 2), &ctx), [Action::cursor(g.beds), fold], "the marker folds");
|
||||
let start = StartEdit::title(EditAt::Offset(2));
|
||||
assert_eq!(
|
||||
view.mouse(Gesture::DoublePress { at: Cell { col: 6, row: 1 } }, &ctx),
|
||||
[Action::cursor(g.soil), Action::App(AppAction::StartEdit(start))],
|
||||
"a double-click edits from the letter clicked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_follows_the_cursor() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let mut view = OutlineView::new();
|
||||
let rows = draw(&mut view, &setup.ctx(&doc, g.compost), 40, 4);
|
||||
assert_eq!(rows[2], "◦ ask about compost", "a row from the bottom");
|
||||
}
|
||||
Reference in new issue
Block a user