From c587c42115734eaa09f941bbc654c548009d18f7 Mon Sep 17 00:00:00 2001 From: Skkeye Date: Thu, 1 Oct 2026 19:10:09 -0400 Subject: [PATCH] Add the outline and Note views, splits, popups and the bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- DESIGN.md | 108 +++- README.md | 12 +- examples/canvas.rs | 1059 ++++++++++++++++------------------- examples/canvas/tests.rs | 621 ++++++++++++++++++++ examples/kitchen.cum | 43 ++ src/app/action.rs | 66 ++- src/app/calls.rs | 37 +- src/app/commands.rs | 104 +++- src/app/mod.rs | 13 +- src/app/mode.rs | 41 +- src/app/panes.rs | 438 +++++++++++++++ src/app/popup.rs | 773 +++++++++++++++++++++++++ src/app/popup/tests.rs | 148 +++++ src/app/text_edit.rs | 250 ++++++--- src/app/text_edit/tests.rs | 79 ++- src/command/parse.rs | 3 +- src/input/bindings.rs | 61 +- src/input/command.rs | 9 + src/input/keymap.rs | 2 +- src/model/mod.rs | 11 + src/model/op.rs | 44 +- src/ui/card.rs | 207 +++++++ src/ui/command_line.rs | 9 + src/ui/document_bar.rs | 89 +++ src/ui/glyphs.rs | 72 +++ src/ui/list.rs | 480 ++++++++++++++++ src/ui/mod.rs | 11 +- src/ui/status_bar.rs | 103 ++++ src/ui/text_editor/mod.rs | 19 +- src/ui/text_editor/tests.rs | 13 + src/view/canvas/scene.rs | 6 +- src/view/canvas/view.rs | 57 +- src/view/mod.rs | 28 + src/view/note.rs | 504 +++++++++++++++++ src/view/note/tests.rs | 154 +++++ src/view/outline.rs | 480 ++++++++++++++++ src/view/outline/tests.rs | 155 +++++ 37 files changed, 5579 insertions(+), 730 deletions(-) create mode 100644 examples/canvas/tests.rs create mode 100644 examples/kitchen.cum create mode 100644 src/app/panes.rs create mode 100644 src/app/popup.rs create mode 100644 src/app/popup/tests.rs create mode 100644 src/ui/card.rs create mode 100644 src/ui/document_bar.rs create mode 100644 src/ui/list.rs create mode 100644 src/ui/status_bar.rs create mode 100644 src/view/note.rs create mode 100644 src/view/note/tests.rs create mode 100644 src/view/outline.rs create mode 100644 src/view/outline/tests.rs diff --git a/DESIGN.md b/DESIGN.md index 900190b..eb3bd45 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. `w` hides the hint line along with which-key. +- Help (`?`, `?`, `: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. `c` `B` `s` `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 `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** `` | `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 = "" @@ -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>; // None: not mine fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext) -> Vec; + // Added in stage 7, with defaults: + fn status(&self) -> String; // its name in the status bar + fn cursor_area(&self, ctx: &ViewContext) -> Option; // 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. diff --git a/README.md b/README.md index 4126c40..a54d4ad 100644 --- a/README.md +++ b/README.md @@ -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 | -| `` | the action menu | -| `:` `?` | command line, help | +| `` | 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`, ``…), 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 ``` diff --git a/examples/canvas.rs b/examples/canvas.rs index 78b3cb1..4b4e02e 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -1,81 +1,90 @@ -//! A playground for the canvas (stages 2 to 6 of the build): a sample -//! document to move around, zoom, fold, rearrange and write in, with the real -//! vim keys, the which-key popup and the `:` command line. The other views -//! come in stage 7. +//! A playground for cumulus as built so far (stages 2 to 7): two sample +//! documents to move around, rearrange and write in, on the canvas, in the +//! outline and in the Note view, split side by side or stacked, with the +//! real vim keys, which-key, the help, the right-click menu, the pickers, +//! the `K` card and the `:` command line. Files, settings and portals come +//! in stage 8. //! //! It runs the way the app will: the key engine turns keys into commands, -//! the canvas view (or the commands every view shares) turns those into -//! actions, and [`Playground::update`] carries them out. While a title is -//! being edited, keys go to the edit instead. +//! the focused pane's view (or the commands every view shares) turns those +//! into actions, and [`Playground::update`] carries them out. While a text +//! is edited or a popup is open, keys go there instead. //! //! Run it with `cargo run --example canvas`. A few keys to start with: //! //! | Keys | What they do | //! |-----------------|-----------------------------------------------------------| -//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) | +//! | `hjkl`, arrows | the canvas: the nearest node that way; the outline: rows, parent, child; the Note view: parts, siblings | //! | `g` | go to: `gh gl gj gk` the tree, `gg` the root | -//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` | +//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` (`zb`: bodies, in the outline) | //! | `+` `-` | zoom in / out | -//! | `i` `A` `Enter` | edit the title, typing at its end (`I`: at its start) | -//! | `cc` `S` | type the title anew | +//! | `i` `A` `Enter` | edit the title (in the Note view: the part you're on) | //! | `a` `o` `O` | add a child / a sibling below / above, and type its title | //! | `x` `dd` `dib` | delete the node / the branch / the children | //! | `>>` `<<` | the node goes under the one above it / out a level | -//! | `M` | move mode: hjkl moves the branch, Enter ends | +//! | `M` | move mode: on the canvas hjkl move the branch; in the outline j/k swap it with its siblings | +//! | `K` | the body in a card beside the node (`i` there edits it) | //! | `u` `Ctrl-r` | undo / redo | //! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) | -//! | `` | the action menu (`w`: key hints on or off) | -//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) | -//! | `q`, `ZZ` | quit | +//! | `` | the action menu: `c` color, `B` border, `t` tags, `b` documents, `vc` `vo` `vn` the view, `w` hints on or off | +//! | `Ctrl-w` | panes: `v` `s` split, `hjkl` `w` focus, `c` close, `o` only, `=` even | +//! | `gt` `gT` | the next / previous document | +//! | `?` | help: every key and command; `/` searches, Enter runs | +//! | `:` | the command line: try `:vsplit outline`, `:color teal`, `:in an idea`, `:find soil`, `:b kitchen` (Tab completes) | +//! | `q`, `ZZ` | close the pane (the last one quits) | //! //! Typing a title (INSERT, a bar cursor): Enter is done. After `a`, `o` or //! `O`, Enter starts the next idea instead, Tab puts the new idea under the //! one above, Shift-Tab takes it out a level, and an idea left empty goes -//! away. Esc gives vim keys inside the title (TEXT, a block cursor): -//! `h l w b e 0 ^ $`, `f t F T ; ,`, `d c y` with any of those or with -//! `iw aw i" a" i( a(`, `x X s S D C Y r ~ p P`, `u Ctrl-r .`, counts -//! (`3x`, `d2f.`), `i a I A` to type again, `o O` for a new sibling. Esc -//! again: done. +//! away. Esc gives vim keys inside the text (TEXT, a block cursor). Esc +//! again: done. A body is edited the same way, with Enter starting a line. //! -//! Mouse: click selects, dragging a node moves its branch, dragging empty -//! space pans, the wheel scrolls. A double-click on a title edits it from -//! that letter; while editing, a click in the title moves the text cursor, -//! and a click anywhere else finishes the edit. +//! Mouse: click selects (and focuses a pane), dragging a node moves its +//! branch, dragging empty space pans, the wheel scrolls. A double-click on a +//! title edits it from that letter. Right-click: the menu. A click on a name +//! in the document bar shows that document. use std::io; use std::time::{Duration, Instant}; -use cumulus::app::action::{Action, AppAction, Message, Tone}; +use cumulus::app::action::{Action, AppAction, DocumentChoice, Message, Tone}; use cumulus::app::buffer::Buffer; use cumulus::app::mode::{Grab, Mode}; +use cumulus::app::panes::{Arrange, PaneId, Panes}; +use cumulus::app::popup::{Card, MenuFor, Popup}; use cumulus::app::registers::Registers; -use cumulus::app::text_edit::TextEdit; +use cumulus::app::text_edit::{StartEdit, TextEdit}; use cumulus::app::{calls, commands}; use cumulus::command::history::History as CommandHistory; use cumulus::command::parse::{SetArg, Sources, parse}; +use cumulus::input::bindings::Context; use cumulus::input::command::{Bound, Command, help_for}; -use cumulus::input::engine::{KeyEngine, Outcome}; +use cumulus::input::engine::{KeyEngine, Outcome, Typed}; use cumulus::input::key::Key; use cumulus::input::keymap::Keymap; -use cumulus::input::mouse::{Gesture, MouseReader}; +use cumulus::input::mouse::{Cell, Gesture, MouseReader}; use cumulus::model::Document; use cumulus::storage::document_file; use cumulus::ui::command_line::{CommandLine, LineOutcome, PopupMode}; +use cumulus::ui::document_bar::DocumentBar; +use cumulus::ui::status_bar::{StatusBar, breadcrumb}; use cumulus::ui::theme::Theme; use cumulus::ui::which_key::WhichKey; use cumulus::view::canvas::CanvasView; -use cumulus::view::{View, ViewContext, ViewSettings}; +use cumulus::view::note::NoteView; +use cumulus::view::outline::OutlineView; +use cumulus::view::{View, ViewContext, ViewKind, ViewSettings}; use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}; use ratatui::crossterm::execute; -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::Span; +use ratatui::widgets::Block; use ratatui::{DefaultTerminal, Frame}; -/// The playground's own keys.toml: `q` quits. (cumulus itself keeps `q` -/// free for macros; this shows how keys.toml changes a key.) +/// The playground's own keys.toml: `q` closes the pane. (cumulus itself +/// keeps `q` free for macros; this shows how keys.toml changes a key.) const KEYS_TOML: &str = r#" [normal] "q" = "close" @@ -85,8 +94,11 @@ const KEYS_TOML: &str = r#" /// shows right away). const WHICH_KEY_DELAY: Duration = Duration::from_millis(300); +/// The smallest a pane gets, borders included: splitting stops there. +const MIN_PANE: (u16, u16) = (16, 6); + fn main() -> io::Result<()> { - let doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?; + let documents = documents().map_err(io::Error::other)?; let mut terminal = ratatui::try_init()?; execute!(io::stdout(), EnableMouseCapture)?; // On a crash, turn the mouse off and give the cursor back its own shape @@ -96,59 +108,103 @@ fn main() -> io::Result<()> { let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape); restore_the_rest(info); })); - let result = Playground::new(doc).run(&mut terminal); + let result = Playground::new(documents).run(&mut terminal); let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape); ratatui::restore(); result } -/// What the view looks at. +/// The sample documents: the garden, and the kitchen its portal leads to. +fn documents() -> Result, document_file::LoadError> { + Ok(vec![ + document_file::parse(include_str!("garden.cum"))?, + document_file::parse(include_str!("kitchen.cum"))?, + ]) +} + +/// What the views look at. struct State { - buffer: Buffer, + /// The open documents. None ever closes here, so a pane's index into + /// them always holds. + buffers: Vec, mode: Mode, theme: Theme, settings: ViewSettings, } impl State { - fn ctx(&self) -> ViewContext<'_> { + /// The context of the focused pane, showing document `buffer`. + fn ctx(&self, buffer: usize) -> ViewContext<'_> { + let buffer = &self.buffers[buffer]; ViewContext { - doc: self.buffer.doc(), - cursor: self.buffer.cursor(), + doc: buffer.doc(), + cursor: buffer.cursor(), mode: &self.mode, theme: &self.theme, settings: &self.settings, } } + + /// The open documents' names. + fn names(&self) -> Vec { + self.buffers.iter().map(|buffer| buffer.doc().title().to_string()).collect() + } +} + +/// A pane: one of the documents, through a view. +struct Pane { + buffer: usize, + view: Box, +} + +impl Pane { + fn new(buffer: usize, kind: ViewKind) -> Pane { + let view: Box = match kind { + ViewKind::Outline => Box::new(OutlineView::new()), + ViewKind::Note => Box::new(NoteView::new()), + ViewKind::Canvas | ViewKind::Board => Box::new(CanvasView::new()), + }; + Pane { buffer, view } + } +} + +/// Where things were drawn last, for the mouse. +#[derive(Debug, Clone, Copy, Default)] +struct Screen { + bar: Rect, + panes: Rect, } struct Playground { - /// The one view (splits come in stage 7). - view: CanvasView, + panes: Panes, state: State, keymap: Keymap, keys: KeyEngine, /// Yanked and deleted text, kept from one edit to the next. registers: Registers, mouse: MouseReader, + /// Key hints: which-key and the status bar's hint line. which_key: bool, command_history: CommandHistory, /// When the command line's popup shows (`:set completion_popup=on-tab`). - popup: PopupMode, + completion: PopupMode, /// The status bar's message, until the next key. message: Option, /// The cursor shape last sent to the terminal. cursor_shape: SetCursorStyle, + screen: Screen, + /// The pane a mouse drag started in: it gets the whole drag. + dragging: Option, quit: bool, } impl Playground { - fn new(doc: Document) -> Playground { + fn new(documents: Vec) -> Playground { let (keymap, problems) = Keymap::from_toml(KEYS_TOML); Playground { - view: CanvasView::new(), + panes: Panes::new(Pane::new(0, ViewKind::Canvas)), state: State { - buffer: Buffer::new(doc), + buffers: documents.into_iter().map(Buffer::new).collect(), mode: Mode::Normal, theme: Theme::default(), settings: ViewSettings::default(), @@ -159,9 +215,11 @@ impl Playground { mouse: MouseReader::default(), which_key: true, command_history: CommandHistory::default(), - popup: PopupMode::default(), + completion: PopupMode::default(), message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))), cursor_shape: SetCursorStyle::DefaultUserShape, + screen: Screen::default(), + dragging: None, quit: false, } } @@ -205,51 +263,57 @@ impl Playground { Ok(()) } - // ----- Input ---------------------------------------------------------- - - fn gesture(&mut self, gesture: Gesture) { - // A click drops a half-typed key sequence: a `d` typed before - // clicking somewhere else mustn't wait to delete what comes next. - if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. }) - { - self.keys.reset(); - } - let actions = self.view.mouse(gesture, &self.state.ctx()); - self.update(actions); + /// The document the focused pane shows. + fn buffer(&self) -> usize { + self.panes.current().buffer } + /// The key tables in use now: the popup's, or the focused view's. + fn contexts(&self) -> &'static [Context] { + match &self.state.mode { + Mode::Popup(popup) => popup.contexts(), + mode => self.panes.current().view.contexts(mode), + } + } + + // ----- Input ---------------------------------------------------------- + fn key(&mut self, key: Key) { self.message = None; match self.state.mode { Mode::Command(_) => return self.command_line_key(key), Mode::Text(_) => return self.text_key(key), + Mode::Popup(_) => return self.popup_key(key), Mode::Normal | Mode::Move(_) => {} } - let tables = self.keymap.tables(self.view.contexts(&self.state.mode)); - match self.keys.press(key, &tables) { - Outcome::Run(typed) => { - // The view has the first say; the shared commands get the rest. - let ctx = self.state.ctx(); - let actions = self.view.command(&typed, &ctx).or_else(|| commands::shared(&typed, &ctx)); - let actions = actions.unwrap_or_else(|| { - let later = - format!("\"{}\" comes in a later stage of the build", describe(typed.command)); - vec![Action::say(Message::info(later))] - }); - self.update(actions); - } + match self.keys.press(key, &self.keymap.tables(self.contexts())) { + Outcome::Run(typed) => self.dispatch(typed), Outcome::Unknown(keys) => self.message = Some(Message::info(format!("{keys} does nothing here"))), Outcome::Waiting | Outcome::Cancelled => {} } } - /// A key while a title is edited: typed into it, or a vim key inside it. + /// A command: the focused view has the first say, the shared commands + /// get the rest. + fn dispatch(&mut self, typed: Typed) { + let pane = self.panes.current_mut(); + let ctx = self.state.ctx(pane.buffer); + let actions = pane.view.command(&typed, &ctx).or_else(|| commands::shared(&typed, &ctx)); + let actions = actions.unwrap_or_else(|| { + let later = format!("\"{}\" comes in a later stage of the build", describe(typed.command)); + vec![Action::say(Message::info(later))] + }); + self.update(actions); + } + + /// A key while a text is edited in a view: typed into it, or a vim key + /// inside it. fn text_key(&mut self, key: Key) { - let tables = self.keymap.tables(self.view.contexts(&self.state.mode)); + let tables = self.keymap.tables(self.contexts()); // The edit comes out of the mode while it works, so it can look at // the document; it goes back in before its actions run. let Mode::Text(mut edit) = std::mem::take(&mut self.state.mode) else { return }; - let ctx = self.state.ctx(); + let ctx = self.state.ctx(self.panes.current().buffer); let actions = if edit.editor.is_typing() { // Typing: a key the table doesn't bind types itself. edit.typing(key, tables.bound(&[key]), &ctx) @@ -266,6 +330,27 @@ impl Playground { self.update(actions); } + /// A key while a popup is open: typed into its search (or the card's + /// body), or one of its keys. + fn popup_key(&mut self, key: Key) { + let tables = self.keymap.tables(self.contexts()); + let Mode::Popup(mut popup) = std::mem::take(&mut self.state.mode) else { return }; + let ctx = self.state.ctx(self.panes.current().buffer); + let actions = if popup.takes_typing() { + popup.typing(key, &self.keymap, &ctx) + } else { + match self.keys.press(key, &tables) { + Outcome::Run(typed) => popup.command(&typed, &mut self.registers, &ctx), + Outcome::Unknown(keys) => { + vec![Action::say(Message::info(format!("{keys} does nothing here")))] + } + Outcome::Waiting | Outcome::Cancelled => Vec::new(), + } + }; + self.state.mode = Mode::Popup(popup); + self.update(actions); + } + /// A key while the command line is open. fn command_line_key(&mut self, key: Key) { let sources = self.sources(); @@ -279,19 +364,25 @@ impl Playground { return; } self.command_history.add(&text); - let actions = match parse(&text, &sources) { - Ok(call) => calls::actions_for(call, &self.state.ctx()), - Err(error) => vec![Action::say(Message::problem(error.to_string()))], - }; - self.update(actions); + self.run_line(&text); } } } - /// What the command line can name: this document, its nodes, the - /// palette's colors, its tags. + /// Runs a command line (without its `:`). + fn run_line(&mut self, text: &str) { + let sources = self.sources(); + let actions = match parse(text, &sources) { + Ok(call) => calls::actions_for(call, &self.state.ctx(self.buffer())), + Err(error) => vec![Action::say(Message::problem(error.to_string()))], + }; + self.update(actions); + } + + /// What the command line can name: the open documents, the focused + /// document's nodes and tags, the palette's colors. fn sources(&self) -> Sources { - let doc = self.state.buffer.doc(); + let doc = self.state.buffers[self.buffer()].doc(); let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string()))); let mut tags: Vec = doc .outline() @@ -302,7 +393,7 @@ impl Playground { tags.sort(); tags.dedup(); Sources { - documents: vec![doc.title().to_string()], + documents: self.state.names(), nodes: nodes.collect(), colors: self.state.theme.palette.keys().map(ToString::to_string).collect(), styles: self.state.theme.node_styles.keys().map(ToString::to_string).collect(), @@ -313,6 +404,66 @@ impl Playground { } } + /// A mouse gesture: a popup has the mouse while it's open; otherwise it + /// goes to the document bar or to the pane under it (a drag stays with + /// the pane it started in), which takes the focus. + fn gesture(&mut self, gesture: Gesture) { + let at = |cell: Cell| Position::new(cell.col, cell.row); + // A click drops a half-typed key sequence: a `d` typed before + // clicking somewhere else mustn't wait to delete what comes next. + if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. }) + { + self.keys.reset(); + if let Mode::Command(_) = self.state.mode { + self.state.mode = Mode::Normal; + } + } + if let Mode::Popup(_) = self.state.mode { + let Mode::Popup(mut popup) = std::mem::take(&mut self.state.mode) else { return }; + let actions = popup.mouse(gesture, &self.state.ctx(self.buffer())); + self.state.mode = Mode::Popup(popup); + return self.update(actions); + } + if let Gesture::Press { at: cell, .. } = gesture + && cell.row == self.screen.bar.y + { + let names = self.state.names(); + let bar = DocumentBar { names: &names, current: self.buffer(), theme: &self.state.theme }; + if let Some(index) = bar.at(cell.col.saturating_sub(self.screen.bar.x)) { + self.update(vec![Action::App(AppAction::SwitchDocument(DocumentChoice::At(index)))]); + } + return; + } + let target = match gesture { + Gesture::Press { at: cell, .. } + | Gesture::DoublePress { at: cell } + | Gesture::RightPress { at: cell } + | Gesture::Scroll { at: cell, .. } => self.panes.at(self.screen.panes, at(cell)), + Gesture::Drag { .. } | Gesture::Release { .. } => self.dragging, + }; + let Some(target) = target else { return }; + if let Gesture::Press { .. } = gesture { + self.dragging = Some(target); + } + if target != self.panes.focused() { + // Another pane: a text edited in this one is done first, and + // move mode ends. + let ctx = self.state.ctx(self.buffer()); + let mut actions = self.state.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default(); + if let Mode::Move(_) = self.state.mode { + actions.push(Action::App(AppAction::LeaveMode)); + } + self.update(actions); + self.panes.focus(target); + } + let pane = self.panes.current_mut(); + let actions = pane.view.mouse(gesture, &self.state.ctx(pane.buffer)); + if let Gesture::Release { .. } = gesture { + self.dragging = None; + } + self.update(actions); + } + // ----- Update --------------------------------------------------------- /// Carries out actions in order. A problem stops the rest, since they @@ -320,7 +471,10 @@ impl Playground { fn update(&mut self, actions: Vec) { for action in actions { let message = match action { - Action::Doc(action) => self.state.buffer.apply(action), + Action::Doc(action) => { + let buffer = self.buffer(); + self.state.buffers[buffer].apply(action) + } Action::App(action) => self.app(action), }; if let Some(message) = message { @@ -336,14 +490,21 @@ impl Playground { fn app(&mut self, action: AppAction) -> Option { match action { AppAction::StartEdit(start) => { - let started = TextEdit::start(&self.state.ctx(), start); - match started { - Ok(edit) => self.state.mode = Mode::Text(edit), - Err(message) => return Some(message), + let started = TextEdit::start(&self.state.ctx(self.buffer()), start); + let in_card = matches!(start, StartEdit::Body { in_card: true, .. }); + match (started, &mut self.state.mode) { + (Err(message), _) => return Some(message), + (Ok(edit), Mode::Popup(Popup::Card(card))) if in_card => card.edit = Some(edit), + (Ok(edit), mode) => *mode = Mode::Text(edit), } } + AppAction::EndEdit => match &mut self.state.mode { + // Done editing in the card: the card stays. + Mode::Popup(Popup::Card(card)) => card.edit = None, + mode => *mode = Mode::Normal, + }, AppAction::TextCursor(at) => { - if let Mode::Text(edit) = &mut self.state.mode { + if let Some(edit) = self.state.mode.edit_mut() { edit.editor.set_cursor(at); } } @@ -355,9 +516,17 @@ impl Playground { } AppAction::LeaveMode => self.state.mode = Mode::Normal, AppAction::OpenCommandLine => { - let line = CommandLine::new(&self.sources(), &self.command_history, self.popup); + let line = CommandLine::new(&self.sources(), &self.command_history, self.completion); self.state.mode = Mode::Command(line); } + AppAction::CommandLineWith(text) => { + let line = + CommandLine::with_text(&text, &self.sources(), &self.command_history, self.completion); + self.state.mode = Mode::Command(line); + } + AppAction::RunLine(text) => self.run_line(&text), + AppAction::Run(command) => self.dispatch(Typed { command, count: None, register: None }), + AppAction::Type(keys) => keys.into_iter().for_each(|key| self.key(key)), AppAction::ToggleFocus => { let focus = &mut self.state.settings.focus; *focus = !*focus; @@ -370,19 +539,125 @@ impl Playground { return Some(Message::info(if self.which_key { "key hints on" } else { "key hints off" })); } AppAction::Set(arg) => return Some(self.set(arg)), + AppAction::OpenHelp(search) => { + let pane = self.panes.current(); + let contexts = pane.view.contexts(&Mode::Normal); + let help = + Popup::help(&self.keymap, contexts, pane.view.kind().name(), &search, &self.state.theme); + self.state.mode = Mode::Popup(help); + } + AppAction::OpenCard => { + self.state.mode = Mode::Popup(Popup::card(self.state.ctx(self.buffer()).cursor)) + } + AppAction::OpenPicker(kind) => { + let names = self.state.names(); + match Popup::picker(kind, &self.state.ctx(self.buffer()), &names, self.buffer()) { + Ok(picker) => self.state.mode = Mode::Popup(picker), + Err(message) => return Some(message), + } + } + AppAction::OpenMenu { what, col, row } => { + let doc = self.state.buffers[self.buffer()].doc(); + let title = match what { + MenuFor::Node(id) => { + doc.node(id).map(|node| node.title().to_string()).unwrap_or_default() + } + MenuFor::Canvas(_) => "here".to_string(), + }; + let contexts = self.panes.current().view.contexts(&Mode::Normal); + let at = Position::new(col, row); + let menu = Popup::menu(what, &title, at, &self.keymap, contexts, &self.state.theme); + self.state.mode = Mode::Popup(menu); + } + AppAction::Split { arrange, view } => return self.split(arrange, view), + AppAction::FocusPane(direction) => { + if !self.panes.focus_toward(direction, self.screen.panes) { + return Some(Message::info("no pane that way")); + } + } + AppAction::NextPane => self.panes.focus_next(), + AppAction::ClosePane => { + if self.panes.close().is_none() { + self.quit = true; + } + } + AppAction::OnlyPane => { + if self.panes.only().is_empty() { + return Some(Message::info("this is the only pane")); + } + } + AppAction::EqualizePanes => self.panes.equalize(), + AppAction::ShowView(ViewKind::Board) => return Some(Message::info("the board comes in v1.1")), + AppAction::ShowView(kind) => { + let pane = self.panes.current_mut(); + if pane.view.kind() != kind { + *pane = Pane::new(pane.buffer, kind); + } + } + AppAction::SwitchDocument(choice) => return self.switch_document(choice), AppAction::Quit => self.quit = true, AppAction::Say(message) => return Some(message), } None } + /// Splits the focused pane: the new pane shows `view`, or the same view. + fn split(&mut self, arrange: Arrange, view: Option) -> Option { + let focused = self.panes.focused(); + let area = self + .panes + .areas(self.screen.panes) + .into_iter() + .find(|(id, _)| *id == focused) + .map(|(_, area)| area)?; + let room = match arrange { + Arrange::SideBySide => area.width >= 2 * MIN_PANE.0, + Arrange::Stacked => area.height >= 2 * MIN_PANE.1, + }; + if !room { + return Some(Message::problem("not enough room to split this pane")); + } + let pane = self.panes.current(); + let kind = view.unwrap_or(pane.view.kind()); + if kind == ViewKind::Board { + return Some(Message::info("the board comes in v1.1")); + } + let buffer = pane.buffer; + self.panes.split(arrange, Pane::new(buffer, kind)); + None + } + + /// The focused pane shows another document, in a fresh view of the same + /// kind. + fn switch_document(&mut self, choice: DocumentChoice) -> Option { + let count = self.state.buffers.len(); + let here = self.buffer(); + let to = match choice { + DocumentChoice::Next => (here + 1) % count, + DocumentChoice::Previous => (here + count - 1) % count, + DocumentChoice::At(index) => index.min(count - 1), + DocumentChoice::Named(name) => { + let names = self.state.names(); + match names.iter().position(|open| open.eq_ignore_ascii_case(&name)) { + Some(index) => index, + None => return Some(Message::problem(format!("no open document is called \"{name}\""))), + } + } + }; + let pane = self.panes.current_mut(); + if to != pane.buffer { + *pane = Pane::new(to, pane.view.kind()); + } + None + } + /// `:set`. The playground has one setting; the app's come in stage 8. fn set(&mut self, arg: SetArg) -> Message { let value = match arg { SetArg::Value(_, value) => value, SetArg::Reset(_) => popup_name(PopupMode::default()).to_string(), SetArg::Show(_) => { - return Message::info(format!("completion_popup = {}", popup_name(self.popup))); + return Message::info(format!("completion_popup = {}", popup_name(self.completion))); } SetArg::On(_) | SetArg::Off(_) | SetArg::Toggle(_) => { return Message::problem("completion_popup takes a value: :set completion_popup=on-tab"); @@ -390,7 +665,7 @@ impl Playground { }; match PopupMode::named(&value) { Some(popup) => { - self.popup = popup; + self.completion = popup; Message::done(format!("completion_popup = {value}")) } None => { @@ -403,69 +678,90 @@ impl Playground { // ----- Drawing -------------------------------------------------------- fn draw(&mut self, frame: &mut Frame) { - let [canvas, status] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area()); - let ctx = self.state.ctx(); - self.view.layout(canvas, &ctx); - self.view.render(frame, &ctx); - // While the command line is open, it takes the status line's place. + let status_rows = if self.which_key { 2 } else { 1 }; + let [bar, panes_area, status] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(status_rows)]) + .areas(frame.area()); + self.screen = Screen { bar, panes: panes_area }; + let names = self.state.names(); + frame.render_widget( + DocumentBar { names: &names, current: self.buffer(), theme: &self.state.theme }, + bar, + ); + + // Each pane in its box: the view and the document in the top + // border, the focused one in the accent color. Only the focused pane + // is in the mode; the others show their documents as they are. + let focused = self.panes.focused(); + let normal = Mode::Normal; + let State { buffers, mode, theme, settings } = &self.state; + for (id, area) in self.panes.areas(panes_area) { + let Some(pane) = self.panes.get_mut(id) else { continue }; + let buffer = &buffers[pane.buffer]; + let mode = if id == focused { mode } else { &normal }; + let ctx = ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode, theme, settings }; + let color = if id == focused { theme.accent } else { theme.muted }; + let title = format!(" {} · {} ", pane.view.kind().name(), buffer.doc().title()); + let block = Block::bordered() + .border_style(Style::new().fg(color)) + .title(Span::styled(title, Style::new().fg(color))); + let inner = block.inner(area); + frame.render_widget(block, area); + pane.view.layout(inner, &ctx); + pane.view.render(frame, &ctx); + } + + // A popup over the panes; the card sits beside the cursor's node. + let index = self.buffer(); + let anchor = self.panes.current().view.cursor_area(&self.state.ctx(index)); + let State { buffers, mode, theme, settings } = &mut self.state; + if let Mode::Popup(popup) = mode { + let buffer = &buffers[index]; + let ctx = + ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode: &normal, theme, settings }; + popup.layout(panes_area, anchor, &ctx); + popup.render(frame, &ctx); + } + + self.draw_status(frame, status); if let Mode::Command(line) = &self.state.mode { - line.render(frame, status, canvas, &self.state.theme); - return; + // The command line takes the bottom row. + let row = Rect { y: status.bottom() - 1, height: 1, ..status }; + line.render(frame, row, panes_area, &self.state.theme); } if self.which_key_due() - && let Some(hints) = self.keys.hints(&self.keymap.tables(self.view.contexts(&self.state.mode))) + && let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts())) { - frame.render_widget(WhichKey { hints: &hints, theme: &self.state.theme }, canvas); + frame.render_widget(WhichKey { hints: &hints, theme: &self.state.theme }, panes_area); } - self.draw_status(frame, status); } - /// The bottom line: the mode, the zoom and the cursor's title, then a - /// message or key hints, and the keys typed so far on the right. + /// The status bar: the mode, the view, the breadcrumb (or a message), + /// the keys typed so far; then the key hints, unless they're off. fn draw_status(&self, frame: &mut Frame, area: Rect) { let (mode, theme) = (&self.state.mode, &self.state.theme); - let typing = matches!(mode, Mode::Text(edit) if edit.editor.is_typing()); - let hints = match mode { - Mode::Move(Grab { whole: true, .. }) => { - "hjkl moves the branch (HJKL by 5) · M just this node · Enter done" - } - Mode::Move(_) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done", - Mode::Text(edit) if typing && edit.new_node => { - "Enter the next idea · Tab under the one above · Shift-Tab out a level · Esc vim keys" - } - Mode::Text(_) if typing => "Enter done · Esc vim keys", - Mode::Text(_) => { - "w b e 0 $ f t move · d c y + a motion · x r ~ p · u undo · . repeat · i a type · o/O new idea · Esc done" - } - Mode::Normal | Mode::Command(_) => { - "hjkl move · g go to · z folds · +/- zoom · i edit · a/o add · x/dd delete · >>/<< in/out · M move · u undo · : command · menu · q quit" - } - }; - // While a title is edited, the status bar shows it as typed. - let title = match mode { - Mode::Text(edit) => edit.editor.text().to_string(), - _ => { - let buffer = &self.state.buffer; - buffer.doc().node(buffer.cursor()).map(|node| node.title().to_string()).unwrap_or_default() - } - }; + let typing = mode.any_edit().is_some_and(|edit| edit.editor.is_typing()); + let pane = self.panes.current(); + let buffer = &self.state.buffers[pane.buffer]; let focus = if self.state.settings.focus { " · focus" } else { "" }; - let middle = match &self.message { - None => hints.dark_gray(), + let view = format!("{}{focus}", pane.view.status()); + let room = usize::from(area.width).saturating_sub(mode.label().len() + view.len() + 16); + let place = match &self.message { + None => Span::raw(breadcrumb(buffer.doc(), buffer.cursor(), room)), Some(Message { text, tone: Tone::Problem }) => text.clone().light_red(), Some(Message { text, tone: Tone::Done }) => text.clone().green(), Some(Message { text, tone: Tone::Info }) => text.clone().yellow(), }; - let label_color = if typing { theme.insert } else { theme.accent }; - let line = Line::from(vec![ - format!(" {} ", mode.label()).black().bg(label_color).bold(), - format!(" {}{focus} │ {title} │ ", self.view.camera().zoom.name()).into(), - middle, - ]); - frame.render_widget(Paragraph::new(line), area); - let typed = Line::from(format!("{} ", self.keys.shown()).cyan().bold()); - frame.render_widget(Paragraph::new(typed).right_aligned(), area); + let status = StatusBar { + mode: mode.label(), + mode_color: if typing { theme.insert } else { theme.accent }, + view, + place, + keys: self.keys.shown(), + hints: self.which_key.then(|| hints(mode, pane.view.kind())), + theme, + }; + frame.render_widget(status, area); } fn which_key_due(&self) -> bool { @@ -478,14 +774,58 @@ impl Playground { } } +/// The hint line: the keys for what you're doing. +fn hints(mode: &Mode, view: ViewKind) -> &'static str { + match (mode, view) { + (Mode::Normal, ViewKind::Outline) => { + "j/k rows · h/l parent/child · za fold · zb bodies · i edit · a/o add · M reorder · K body · ? help" + } + (Mode::Normal, ViewKind::Note) => { + "j/k parts · Enter edit or go · h/l siblings · a/o add · panes · ? help" + } + (Mode::Normal, _) => { + "hjkl move · i edit · a/o add · dd delete · K body · M move · panes · menu · ? help" + } + (Mode::Text(edit), _) | (Mode::Popup(Popup::Card(Card { edit: Some(edit), .. })), _) => { + text_hints(edit) + } + (Mode::Move(_), ViewKind::Outline | ViewKind::Note) => { + "j/k swap with a sibling (J/K: 5) · h out a level · l under the one above · Enter done" + } + (Mode::Move(Grab { whole: true, .. }), _) => { + "hjkl moves the branch (HJKL by 5) · M just this node · Enter done" + } + (Mode::Move(_), _) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done", + (Mode::Command(_), _) => "Tab completes · ↑ ↓ history · Enter runs · Esc cancels", + (Mode::Popup(Popup::Card(_)), _) => "j/k scroll · i edit the body · K or Esc close", + (Mode::Popup(_), _) => "j/k move · / search · Enter pick · Esc close", + } +} + +/// The hint line while a text is edited. +fn text_hints(edit: &TextEdit) -> &'static str { + use cumulus::app::text_edit::Field; + match (edit.editor.is_typing(), edit.field) { + (true, Field::Title) if edit.new_node => { + "Enter the next idea · Tab under the one above · Shift-Tab out a level · Esc vim keys" + } + (true, Field::Body) => "Enter a new line · Esc vim keys", + (true, _) => "Enter done · Esc vim keys", + (false, _) => { + "w b e 0 $ f t move · d c y + a motion · x r ~ p · u undo · . repeat · i a type · Esc done" + } + } +} + /// The cursor's shape in a mode, as in vim: a bar where typed letters go in /// between two others, a block on the letter vim keys act on, and the /// terminal's own shape where the cursor is hidden anyway. fn cursor_shape(mode: &Mode) -> SetCursorStyle { - match mode { - Mode::Text(edit) if !edit.editor.is_typing() => SetCursorStyle::SteadyBlock, - Mode::Text(_) | Mode::Command(_) => SetCursorStyle::SteadyBar, - Mode::Normal | Mode::Move(_) => SetCursorStyle::DefaultUserShape, + match mode.any_edit() { + Some(edit) if edit.editor.is_typing() => SetCursorStyle::SteadyBar, + Some(_) => SetCursorStyle::SteadyBlock, + None if matches!(mode, Mode::Command(_)) => SetCursorStyle::SteadyBar, + None => SetCursorStyle::DefaultUserShape, } } @@ -503,426 +843,7 @@ fn popup_name(popup: PopupMode) -> &'static str { } /// The playground driven by typed keys and mouse gestures on a test -/// terminal: what this stage says works, checked end to end. +/// terminal: what the stages say works, checked end to end. #[cfg(test)] -mod tests { - use super::*; - use cumulus::input::key::parse_keys; - use cumulus::input::keymap::DEFAULT_LEADER; - use cumulus::input::mouse::Cell; - use cumulus::model::{CanvasPos, NodeId}; - use cumulus::view::canvas::ScreenPos; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - struct Harness { - playground: Playground, - terminal: Terminal, - } - - impl Harness { - fn new() -> Harness { - let doc = document_file::parse(include_str!("garden.cum")).unwrap(); - let terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); - let mut harness = Harness { playground: Playground::new(doc), 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(); - } - - /// A click on a cell of the screen. - fn click(&mut self, col: i32, row: i32) { - let at = cell(col, row); - self.mouse(Gesture::Press { at, ctrl: false }); - self.mouse(Gesture::Release { at, dragged: false }); - } - - /// A double-click, as the mouse reader reports it: a press, then a - /// second one on the same cell. - fn double_click(&mut self, col: i32, row: i32) { - let at = cell(col, row); - self.click(col, row); - self.mouse(Gesture::DoublePress { at }); - self.mouse(Gesture::Release { at, dragged: false }); - } - - /// The title of the cursor's node. - fn cursor(&self) -> String { - let buffer = &self.playground.state.buffer; - self.title(buffer.cursor()) - } - - fn title(&self, id: NodeId) -> String { - self.playground.state.buffer.doc().node(id).unwrap().title().to_string() - } - - fn label(&self) -> &'static str { - self.playground.state.mode.label() - } - - /// The title being edited, as typed so far. - fn draft(&self) -> &str { - match &self.playground.state.mode { - Mode::Text(edit) => edit.editor.text(), - _ => "", - } - } - - /// Where the text cursor is in the title being edited: a byte offset. - fn draft_cursor(&self) -> Option { - match &self.playground.state.mode { - Mode::Text(edit) => Some(edit.editor.cursor()), - _ => None, - } - } - - fn message(&self) -> &str { - self.playground.message.as_ref().map_or("", |message| &message.text) - } - - /// The node with this title. - fn node(&self, title: &str) -> NodeId { - let doc = self.playground.state.buffer.doc(); - doc.outline().into_iter().find(|&id| doc.node(id).unwrap().title().as_str() == title).unwrap() - } - - fn children(&self, title: &str) -> Vec { - let doc = self.playground.state.buffer.doc(); - doc.children(self.node(title)).iter().map(|&id| self.title(id)).collect() - } - - fn parent(&self, title: &str) -> String { - self.title(self.playground.state.buffer.doc().parent(self.node(title)).unwrap()) - } - - fn pos(&self, title: &str) -> CanvasPos { - self.playground.state.buffer.doc().node(self.node(title)).unwrap().pos - } - - /// Where a node's box starts on screen: its top-left corner. Its - /// title starts 2 cells right of that (the border, a space) and a - /// row down. - fn on_screen(&self, title: &str) -> ScreenPos { - self.playground.view.camera().to_screen(self.pos(title)) - } - - /// The terminal's cursor, when it shows. - fn text_cursor(&self) -> Option<(i32, i32)> { - let backend = self.terminal.backend(); - let at = backend.cursor_position(); - backend.cursor_visible().then(|| (i32::from(at.x), i32::from(at.y))) - } - - /// What's on screen, row by row. - fn screen(&self) -> String { - let buffer = self.terminal.backend().buffer(); - let rows = buffer.content().chunks(usize::from(buffer.area.width)); - rows.map(|row| row.iter().map(|cell| cell.symbol()).collect::() + "\n").collect() - } - } - - fn cell(col: i32, row: i32) -> Cell { - Cell { col: u16::try_from(col).unwrap(), row: u16::try_from(row).unwrap() } - } - - #[test] - fn marks_counts_and_undo() { - let mut h = Harness::new(); - 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"); - 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 soil = h.on_screen("Soil"); - let (start, end) = (cell(soil.col + 1, soil.row + 1), cell(soil.col + 5, soil.row + 3)); - h.mouse(Gesture::Press { at: start, ctrl: false }); - assert_eq!(h.cursor(), "Soil", "the press selects"); - h.mouse(Gesture::Drag { from: start, at: end, ctrl: false }); - 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"); - let root = h.playground.state.buffer.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"); - 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"); - assert_eq!(h.cursor(), "Drip line"); - assert_eq!(h.message(), "\"drip\": match 1 of 1"); - h.type_keys(":set completion_popup=on-tab"); - assert_eq!(h.playground.popup, PopupMode::OnTab); - h.type_keys(":frobnicate"); - 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"); - 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().lines().last().unwrap().contains("Far"), "the status bar shows the zoom"); - h.type_keys("q"); - assert!(h.playground.quit); - } - - #[test] - fn typing_a_title_in_its_box() { - let mut h = Harness::new(); - h.type_keys("gli mix"); - assert_eq!(h.label(), "INSERT"); - assert!(h.screen().contains("Soil mix"), "typed into the box:\n{}", h.screen()); - let soil = h.on_screen("Soil mix"); - assert_eq!(h.text_cursor(), Some((soil.col + 2 + 8, soil.row + 1)), "after the last letter"); - assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBar); - h.type_keys(""); - assert_eq!(h.label(), "TEXT"); - let soil = h.on_screen("Soil mix"); - assert_eq!(h.text_cursor(), Some((soil.col + 2 + 7, soil.row + 1)), "on the last letter, as in vim"); - assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBlock); - h.type_keys(""); - assert_eq!(h.label(), "NORMAL"); - assert_eq!(h.text_cursor(), None, "no text cursor outside a title"); - assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::DefaultUserShape); - h.type_keys("u"); - assert_eq!(h.cursor(), "Soil", "the whole edit was one undo step"); - } - - #[test] - fn rapid_entry_with_enter_tab_and_shift_tab() { - let mut h = Harness::new(); - h.type_keys("glgjgjaBucketHose"); - assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line", "Bucket"]); - assert_eq!(h.children("Bucket"), ["Hose"], "Tab: under the idea above"); - h.type_keys("Can"); - assert_eq!( - h.children("Watering"), - ["Rain barrel", "Drip line", "Bucket", "Can"], - "Shift-Tab: out a level" - ); - h.type_keys(""); - assert_eq!(h.label(), "NORMAL", "Enter on an empty idea stops"); - assert_eq!( - h.children("Watering"), - ["Rain barrel", "Drip line", "Bucket", "Can"], - "the empty one went" - ); - assert_eq!(h.children("Bucket"), ["Hose"]); - assert_eq!(h.cursor(), "Can"); - assert_eq!(h.message(), ""); - assert!(h.screen().contains("Can")); - } - - #[test] - fn retyping_and_repeating() { - let mut h = Harness::new(); - h.type_keys("glccCompost"); - assert_eq!(h.cursor(), "Compost", "cc types the title anew"); - h.type_keys("A!.."); - assert_eq!(h.draft(), "Compost!!!", ". repeats the typing too"); - h.type_keys("u"); - assert_eq!(h.cursor(), "Compost!!", "u inside the title undoes its last change"); - h.type_keys("u"); - assert_eq!(h.cursor(), "Compost", "u outside undoes the whole edit"); - h.type_keys("u"); - assert_eq!(h.cursor(), "Soil"); - } - - #[test] - fn vim_keys_inside_a_title() { - let mut h = Harness::new(); - // "Cedar, untreated": change the second word, then find the comma. - h.type_keys("glgjglibcwtreated0f,x"); - assert_eq!(h.draft(), "Cedar treated"); - h.type_keys("w~"); - assert_eq!(h.draft(), "Cedar Treated", "~ switches the case and steps right"); - h.type_keys("$diw"); - assert_eq!(h.draft(), "Cedar "); - h.type_keys(""); - assert_eq!(h.cursor(), "Cedar", "the title is trimmed"); - } - - /// Every other vim key inside a title, one step at a time, each checked - /// against what vim does: the text after it, and where the cursor is. - #[test] - fn the_rest_of_the_vim_keys_inside_a_title() { - let mut h = Harness::new(); - h.type_keys("glccplant (two) \"red\" beans"); - let steps: &[(&str, &str, usize)] = &[ - ("0e", "plant (two) \"red\" beans", 4), - ("^", "plant (two) \"red\" beans", 0), - ("$F(", "plant (two) \"red\" beans", 6), - ("$T\"", "plant (two) \"red\" beans", 17), - // ; repeats the T, skipping the quote it's already next to. - (";", "plant (two) \"red\" beans", 13), - // , repeats it the other way: t". - (",", "plant (two) \"red\" beans", 15), - ("0f(ci(six", "plant (six) \"red\" beans", 9), - ("f\"di\"", "plant (six) \"\" beans", 13), - ("da\"", "plant (six) beans", 12), - ("F(da(", "plant beans", 6), - ("X", "plant beans", 5), - ("wdaw", "plant", 4), - ("A beans", "plant beans", 10), - ("0sP", "Plant beans", 0), - ("$r!", "Plant bean!", 10), - ("u", "Plant beans", 10), - ("", "Plant bean!", 10), - ("0wD", "Plant ", 5), - ("0CSeed", "Seed", 3), - ("0yiwP", "SeedSeed", 3), - ("I>", ">SeedSeed", 0), - ("a-", ">-SeedSeed", 1), - ("SGo", "Go", 1), - ("0iE", "EGo", 0), - ("3~", "egO", 2), - ("Sa.b.c.d.e.f.g0d3f.", "d.e.f.g", 0), - ("2x", "e.f.g", 0), - ("d2f.", "g", 0), - // A title has one line, so Y's line goes in like any text. - ("YP", "gg", 0), - ]; - for &(keys, text, cursor) in steps { - h.type_keys(keys); - assert_eq!((h.draft(), h.draft_cursor()), (text, Some(cursor)), "after {keys}"); - } - h.type_keys("u"); - assert_eq!(h.cursor(), "Soil", "all of it was one undo step"); - } - - #[test] - fn registers_carry_text_from_title_to_title() { - let mut h = Harness::new(); - h.type_keys("gli\"ayiw"); - assert_eq!(h.label(), "NORMAL"); - h.type_keys("gjA \"ap"); - assert_eq!(h.cursor(), "Raised beds Soil", "\"a kept Soil"); - } - - #[test] - fn o_inside_a_title_starts_a_sibling() { - let mut h = Harness::new(); - h.type_keys("glioCompost"); - assert_eq!(h.children("Garden plans"), ["Soil", "Compost", "Raised beds", "Watering", "Kitchen"]); - } - - #[test] - fn the_mouse_while_editing() { - let mut h = Harness::new(); - let watering = h.node("Watering"); - // A double-click on the first e: typing goes in right before it. - let at = h.on_screen("Watering"); - h.double_click(at.col + 2 + 3, at.row + 1); - assert_eq!(h.label(), "INSERT"); - h.type_keys("-"); - assert_eq!(h.draft(), "Wat-ering"); - // A click in the title moves the text cursor. - let at = h.on_screen("Wat-ering"); - h.click(at.col + 2, at.row + 1); - h.type_keys("~"); - assert_eq!(h.draft(), "~Wat-ering"); - // A click anywhere else keeps the edit, finishes it, and selects. - let soil = h.on_screen("Soil"); - h.click(soil.col + 2, soil.row + 1); - assert_eq!(h.label(), "NORMAL"); - assert_eq!(h.cursor(), "Soil"); - assert_eq!(h.title(watering), "~Wat-ering"); - } - - #[test] - fn indenting_and_outdenting_nodes() { - let mut h = Harness::new(); - h.type_keys("glgj>>"); - assert_eq!(h.cursor(), "Raised beds"); - assert_eq!(h.parent("Raised beds"), "Soil", ">>: under the node above"); - h.type_keys("<<"); - assert_eq!( - h.children("Garden plans"), - ["Soil", "Raised beds", "Watering", "Kitchen"], - "<<: back out" - ); - h.type_keys("u"); - assert_eq!(h.parent("Raised beds"), "Soil"); - h.type_keys("u"); - assert_eq!(h.parent("Raised beds"), "Garden plans"); - assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7), "back where it was"); - } - - #[test] - fn keys_from_later_stages_say_so() { - let mut h = Harness::new(); - h.type_keys("yy"); - assert_eq!(h.message(), "\"yank (copy)…\" comes in a later stage of the build"); - h.type_keys("K"); - assert_eq!(h.message(), "\"show the body\" comes in a later stage of the build"); - // Kitchen is a portal: its title is the other document's name. - h.type_keys("glgjgjgj"); - assert_eq!(h.cursor(), "Kitchen"); - h.type_keys("i"); - assert_eq!(h.message(), "a portal shows its document's name: rename that document instead"); - assert_eq!(h.label(), "NORMAL"); - h.type_keys(""); - assert_eq!(h.message(), "going through a portal comes in stage 8"); - } -} +#[path = "canvas/tests.rs"] +mod tests; diff --git a/examples/canvas/tests.rs b/examples/canvas/tests.rs new file mode 100644 index 0000000..69bf4ce --- /dev/null +++ b/examples/canvas/tests.rs @@ -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, +} + +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 { + 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 { + 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 { + 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) -> 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"); + 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"); + 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"); + 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"); + assert_eq!(h.cursor(), "Drip line"); + assert_eq!(h.message(), "\"drip\": match 1 of 1"); + h.type_keys(":set completion_popup=on-tab"); + assert_eq!(h.playground.completion, PopupMode::OnTab); + h.type_keys(":frobnicate"); + 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"); + 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(""); + 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(""); + assert_eq!(h.label(), "NORMAL"); + assert_eq!(h.text_cursor(), None, "no text cursor outside a title"); + assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::DefaultUserShape); + h.type_keys("u"); + assert_eq!(h.cursor(), "Soil", "the whole edit was one undo step"); +} + +#[test] +fn rapid_entry_with_enter_tab_and_shift_tab() { + let mut h = Harness::new(); + h.type_keys("glgjgjaBucketHose"); + assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line", "Bucket"]); + assert_eq!(h.children("Bucket"), ["Hose"], "Tab: under the idea above"); + h.type_keys("Can"); + assert_eq!( + h.children("Watering"), + ["Rain barrel", "Drip line", "Bucket", "Can"], + "Shift-Tab: out a level" + ); + h.type_keys(""); + assert_eq!(h.label(), "NORMAL", "Enter on an empty idea stops"); + assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line", "Bucket", "Can"], "the empty one went"); + assert_eq!(h.children("Bucket"), ["Hose"]); + assert_eq!(h.cursor(), "Can"); + assert_eq!(h.message(), ""); +} + +#[test] +fn retyping_and_repeating() { + let mut h = Harness::new(); + h.type_keys("glccCompost"); + assert_eq!(h.cursor(), "Compost", "cc types the title anew"); + h.type_keys("A!.."); + assert_eq!(h.draft(), "Compost!!!", ". repeats the typing too"); + h.type_keys("u"); + assert_eq!(h.cursor(), "Compost!!", "u inside the title undoes its last change"); + h.type_keys("u"); + assert_eq!(h.cursor(), "Compost", "u outside undoes the whole edit"); + h.type_keys("u"); + assert_eq!(h.cursor(), "Soil"); +} + +#[test] +fn vim_keys_inside_a_title() { + let mut h = Harness::new(); + // "Cedar, untreated": change the second word, then find the comma. + h.type_keys("glgjglibcwtreated0f,x"); + assert_eq!(h.draft(), "Cedar treated"); + h.type_keys("w~"); + assert_eq!(h.draft(), "Cedar Treated", "~ switches the case and steps right"); + h.type_keys("$diw"); + assert_eq!(h.draft(), "Cedar "); + h.type_keys(""); + assert_eq!(h.cursor(), "Cedar", "the title is trimmed"); +} + +/// Every other vim key inside a title, one step at a time, each checked +/// against what vim does: the text after it, and where the cursor is. +#[test] +fn the_rest_of_the_vim_keys_inside_a_title() { + let mut h = Harness::new(); + h.type_keys("glccplant (two) \"red\" beans"); + let steps: &[(&str, &str, usize)] = &[ + ("0e", "plant (two) \"red\" beans", 4), + ("^", "plant (two) \"red\" beans", 0), + ("$F(", "plant (two) \"red\" beans", 6), + ("$T\"", "plant (two) \"red\" beans", 17), + // ; repeats the T, skipping the quote it's already next to. + (";", "plant (two) \"red\" beans", 13), + // , repeats it the other way: t". + (",", "plant (two) \"red\" beans", 15), + ("0f(ci(six", "plant (six) \"red\" beans", 9), + ("f\"di\"", "plant (six) \"\" beans", 13), + ("da\"", "plant (six) beans", 12), + ("F(da(", "plant beans", 6), + ("X", "plant beans", 5), + ("wdaw", "plant", 4), + ("A beans", "plant beans", 10), + ("0sP", "Plant beans", 0), + ("$r!", "Plant bean!", 10), + ("u", "Plant beans", 10), + ("", "Plant bean!", 10), + ("0wD", "Plant ", 5), + ("0CSeed", "Seed", 3), + ("0yiwP", "SeedSeed", 3), + ("I>", ">SeedSeed", 0), + ("a-", ">-SeedSeed", 1), + ("SGo", "Go", 1), + ("0iE", "EGo", 0), + ("3~", "egO", 2), + ("Sa.b.c.d.e.f.g0d3f.", "d.e.f.g", 0), + ("2x", "e.f.g", 0), + ("d2f.", "g", 0), + // A title has one line, so Y's line goes in like any text. + ("YP", "gg", 0), + ]; + for &(keys, text, cursor) in steps { + h.type_keys(keys); + assert_eq!((h.draft(), h.draft_cursor()), (text, Some(cursor)), "after {keys}"); + } + h.type_keys("u"); + assert_eq!(h.cursor(), "Soil", "all of it was one undo step"); +} + +#[test] +fn registers_carry_text_from_title_to_title() { + let mut h = Harness::new(); + h.type_keys("gli\"ayiw"); + assert_eq!(h.label(), "NORMAL"); + h.type_keys("gjA \"ap"); + assert_eq!(h.cursor(), "Raised beds Soil", "\"a kept Soil"); +} + +#[test] +fn o_inside_a_title_starts_a_sibling() { + let mut h = Harness::new(); + h.type_keys("glioCompost"); + assert_eq!(h.children("Garden plans"), ["Soil", "Compost", "Raised beds", "Watering", "Kitchen"]); +} + +#[test] +fn the_mouse_while_editing() { + let mut h = Harness::new(); + let watering = h.node("Watering"); + // A double-click on the first e: typing goes in right before it. + let (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(""); + 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("vvo"); + 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("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"); + assert_eq!(h.playground.panes.count(), 3); + assert!(h.screen().contains("note · Garden plans")); + h.type_keys("o"); + assert_eq!(h.playground.panes.count(), 1, "only this pane"); + h.type_keys("s"); + assert_eq!(h.playground.panes.count(), 2); + h.type_keys("c"); + assert_eq!(h.playground.panes.count(), 1); + h.type_keys("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("vo"); + assert!(h.screen().contains("▾ Garden plans")); + h.type_keys("jA!"); + 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"); + 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("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("jjLoamy, with compost.Test the pH."); + 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 #spring"); + 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"); + 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"); + assert_eq!(h.label(), "NORMAL"); + assert!(h.screen().contains("canvas · far"), "the help ran it"); + h.type_keys("?/:colorteal"); + 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 compostK"); + 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("Gloves too."); + 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"); + assert_eq!(h.label(), "PICK", "color… opens the color picker"); + h.type_keys("/teal"); + 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("Mulch"); + 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("glB/dashed"); + 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("t"); + assert_eq!((h.label(), h.tags("Soil").as_str()), ("PICK", "")); + h.type_keys(""); + // 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("bk"); + assert_eq!(h.cursor(), "Soil", "back in the garden, where the cursor was"); + h.type_keys(":b kitchen"); + 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("vojoMulchStraw"); + 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("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(""); + // Ctrl-w w goes round the panes; Ctrl-w = evens them out. + h.type_keys("v"); + let first = h.playground.panes.focused(); + h.type_keys("w"); + assert_ne!(h.playground.panes.focused(), first); + h.type_keys("=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"); + assert_eq!(h.label(), "HELP"); + assert!(h.screen().contains("/fold")); + h.type_keys(""); + // s: this theme has no named styles. + h.type_keys("s"); + assert_eq!(h.message(), "this theme has no named styles yet: theme files come in stage 8"); + // w: no key hints: which-key and the hint line go. + assert!(h.rows().last().unwrap().contains("? help")); + h.type_keys("w"); + assert_eq!(h.message(), "key hints off"); + assert!(!h.screen().contains("? help"), "{}", h.screen()); +} diff --git a/examples/kitchen.cum b/examples/kitchen.cum new file mode 100644 index 0000000..44bcd23 --- /dev/null +++ b/examples/kitchen.cum @@ -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.", + ), + ], +) diff --git a/src/app/action.rs b/src/app/action.rs index 607e4f7..0f7f814 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -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), /// 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, + }, + 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 { diff --git a/src/app/calls.rs b/src/app/calls.rs index 130b60f..16d2d4b 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -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 { 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 { 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 { + vec![Action::App(action)] +} + fn say(message: Message) -> Vec { vec![Action::say(message)] } diff --git a/src/app/commands.rs b/src/app/commands.rs index 8ce45e6..e38098d 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -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> { 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 { /// Adds a node at `spot`, next to its parent or sibling, and starts typing /// its title: rapid entry. The add and the typing are one undo step. pub fn add(ctx: &ViewContext<'_>, spot: Spot) -> Vec { - // Until something is typed, the node holds a placeholder (a title can't - // be empty). - let Ok(title) = Title::parse("new idea") else { return Vec::new() }; - let node = Node::idea(title, CanvasPos::default()); + let 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 { + 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 { + 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 { 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) -> 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> { + 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| { + 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 { } fn edit(at: EditAt) -> Vec { - app(AppAction::StartEdit(StartEdit { at, new_node: None })) + app(AppAction::StartEdit(StartEdit::title(at))) } fn app(action: AppAction) -> Vec { diff --git a/src/app/mod.rs b/src/app/mod.rs index d6e9b75..3253a01 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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; diff --git a/src/app/mode.rs b/src/app/mode.rs index f09fa20..511ef6a 100644 --- a/src/app/mode.rs +++ b/src/app/mode.rs @@ -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(), } } } diff --git a/src/app/panes.rs b/src/app/panes.rs new file mode 100644 index 0000000..8b1f7e9 --- /dev/null +++ b/src/app/panes.rs @@ -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 }, +} + +#[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 { + 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, + focused: PaneId, + next_id: u32, +} + +impl Panes { + /// One pane, showing `first`. + pub fn new(first: T) -> Panes { + 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 { + 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 { + 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 { + self.layout = Layout::Pane(self.focused); + let others: Vec = 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 { + 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 { + 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::().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) { + 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::().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::>(); + 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())); + } +} diff --git a/src/app/popup.rs b/src/app/popup.rs new file mode 100644 index 0000000..dcc6cc4 --- /dev/null +++ b/src/app/popup.rs @@ -0,0 +1,773 @@ +//! Popups that take the keys for a while: the help (`?`), the right-click +//! menu, the pickers (`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, +} + +/// 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), + /// 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, + /// The node's tags as they are now, in the tags picker. + tags: Tags, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Pick { + Color(Option), + Border(Option), + Style(Option), + 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, + 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 = Vec::new(); + let mut seen: Vec> = 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!("{: 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 { + 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>, 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, Vec) = 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 = doc + .outline() + .into_iter() + .filter_map(|id| doc.node(id)) + .flat_map(|node| node.tags.iter().cloned().collect::>()) + .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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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; diff --git a/src/app/popup/tests.rs b/src/app/popup/tests.rs new file mode 100644 index 0000000..6aad783 --- /dev/null +++ b/src/app/popup/tests.rs @@ -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 { + 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("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)]); +} diff --git a/src/app/text_edit.rs b/src/app/text_edit.rs index 0024602..4abd717 100644 --- a/src/app/text_edit.rs +++ b/src/app/text_edit.rs @@ -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, +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, + }, + 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 { 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, ctx: &ViewContext<'_>) -> Vec { - let typing = match bound { + let typed: Vec = 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 { - 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 { 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 { 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 { 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 { - 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) } } diff --git a/src/app/text_edit/tests.rs b/src/app/text_edit/tests.rs index 94632ab..ac1bd0b 100644 --- a/src/app/text_edit/tests.rs +++ b/src/app/text_edit/tests.rs @@ -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"); 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.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("kA!oFrom the mill."); + 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."); + 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(""); + assert_eq!(app.mode.label(), "NORMAL", "Enter is done, in tags"); + app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Tags(EditAt::End)))]); + app.press(" #"); + 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" + ] + ); +} diff --git a/src/command/parse.rs b/src/command/parse.rs index dcd19f3..fe05209 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -396,7 +396,8 @@ fn board_columns(word: &str) -> Result { } } -fn border(word: &str) -> Result { +/// A border by name, as `:border` takes it. +pub fn border(word: &str) -> Result { match word.to_ascii_lowercase().as_str() { "rounded" => Ok(Border::Rounded), "square" => Ok(Border::Square), diff --git a/src/input/bindings.rs b/src/input/bindings.rs index 329ac48..ccb38e9 100644 --- a/src/input/bindings.rs +++ b/src/input/bindings.rs @@ -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("", C::Move(M::Down), "the next row"), + bind_help("k", C::Move(M::Up), "the row above"), + bind_help("", C::Move(M::Up), "the row above"), + bind_help("h", C::Move(M::Left), "the parent"), + bind_help("", C::Move(M::Left), "the parent"), + bind_help("l", C::Move(M::Right), "the first child"), + bind_help("", 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("", C::Move(M::Down), "the next part"), + bind_help("k", C::Move(M::Up), "the part above"), + bind_help("", C::Move(M::Up), "the part above"), + bind_help("h", C::Move(M::Left), "the sibling before"), + bind_help("", C::Move(M::Left), "the sibling before"), + bind_help("l", C::Move(M::Right), "the sibling after"), + bind_help("", C::Move(M::Right), "the sibling after"), + bind_help("", 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("", C::ScrollDown), + bind("k", C::ScrollUp), + bind("", C::ScrollUp), + bind("", C::HalfPageDown), + bind("", C::HalfPageUp), + bind("i", C::EditBody), + bind("A", C::EditBody), + bind("", C::EditBody), + bind("I", C::EditBodyAtStart), + bind_help("K", C::Escape, "close"), + bind_help("q", C::Escape, "close"), + bind_help("", C::Escape, "close"), + bind_help("", C::Escape, "close"), +]; + /// Visual mode: operators act on the selection right away. const VISUAL: &[DefaultBinding] = &[ bind("o", C::SwapEnds), diff --git a/src/input/command.rs b/src/input/command.rs index 0e4611d..8efd801 100644 --- a/src/input/command.rs +++ b/src/input/command.rs @@ -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)…"), diff --git a/src/input/keymap.rs b/src/input/keymap.rs index d287ee8..fd2541c 100644 --- a/src/input/keymap.rs +++ b/src/input/keymap.rs @@ -373,7 +373,7 @@ mod tests { "[normal] \"\": there's no key called ", "[normal] \"X\": there's no command \"split-vert\"; did you mean \"split-vertical\"?", "[normal] \"Y\": the command should be a name in quotes, like \"delete-branch\"", - "[nromal] isn't a section: use one of motions, normal, canvas, outline, note, visual, move, connect, list, 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"; diff --git a/src/model/mod.rs b/src/model/mod.rs index a497f0e..63af78c 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -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 + } } diff --git a/src/model/op.rs b/src/model/op.rs index 563f455..01e1c56 100644 --- a/src/model/op.rs +++ b/src/model/op.rs @@ -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, }, + /// A node's named style from the theme (`None`: no style). + SetStyle { + id: NodeId, + style: Option, + }, /// 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, remove: Vec, }, + /// All of a node's tags at once, as typed in the Note view. + SetTags { + id: NodeId, + tags: Tags, + }, Link(Link), Unlink(Vec), } @@ -134,6 +149,9 @@ fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result { 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 { } }) .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)); + } } diff --git a/src/ui/card.rs b/src/ui/card.rs new file mode 100644 index 0000000..c50e8b5 --- /dev/null +++ b/src/ui/card.rs @@ -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, + /// 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>, + /// 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, 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 { + 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"); + } +} diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index d1232fe..2b79fa9 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -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() diff --git a/src/ui/document_bar.rs b/src/ui/document_bar.rs new file mode 100644 index 0000000..d7b8c2a --- /dev/null +++ b/src/ui/document_bar.rs @@ -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> { + 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 { + 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"); + } +} diff --git a/src/ui/glyphs.rs b/src/ui/glyphs.rs index e5689cd..3c8c426 100644 --- a/src/ui/glyphs.rs +++ b/src/ui/glyphs.rs @@ -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, rounded: bool) -> char { diff --git a/src/ui/list.rs b/src/ui/list.rs new file mode 100644 index 0000000..756e7db --- /dev/null +++ b/src/ui/list.rs @@ -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>, + /// Shown at the right edge: a key, a check mark. + pub note: Option>, + /// 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>, search: impl Into) -> 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::() + 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, + /// The rows on show, by index: all of them, or the ones the search + /// matches, best first. + shown: Vec, + /// 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, + /// 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, + footer: impl Into, + rows: Vec, + 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 { + 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 { + 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 ), 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 { + 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("", 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); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4449783..bf533b1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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; diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs new file mode 100644 index 0000000..b5b844c --- /dev/null +++ b/src/ui/status_bar.rs @@ -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 `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 = + 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"); + } +} diff --git a/src/ui/text_editor/mod.rs b/src/ui/text_editor/mod.rs index 89b6270..eba035a 100644 --- a/src/ui/text_editor/mod.rs +++ b/src/ui/text_editor/mod.rs @@ -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) -> Option { 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) -> 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, ""), diff --git a/src/ui/text_editor/tests.rs b/src/ui/text_editor/tests.rs index 00e4c4b..5a74674 100644 --- a/src/ui/text_editor/tests.rs +++ b/src/ui/text_editor/tests.rs @@ -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"); + assert_eq!(keys.shown(), "firs|t", "cc on an empty text"); + keys.press("oSsecond"); + assert_eq!(keys.shown(), "first\nsecon|d", "S on an empty line"); + keys.press("osthird"); + 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"); +} diff --git a/src/view/canvas/scene.rs b/src/view/canvas/scene.rs index bf641bd..5f2f540 100644 --- a/src/view/canvas/scene.rs +++ b/src/view/canvas/scene.rs @@ -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 { diff --git a/src/view/canvas/view.rs b/src/view/canvas/view.rs index eefe8c5..d92c21a 100644 --- a/src/view/canvas/view.rs +++ b/src/view/canvas/view.rs @@ -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 { + 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 { + let rect = self.scene.rect_of(ctx.cursor)?; + clip(rect, self.area) + } } #[cfg(test)] diff --git a/src/view/mod.rs b/src/view/mod.rs index 241b913..72dbb0f 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -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; + + /// 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 { + None + } } #[cfg(test)] diff --git a/src/view/note.rs b/src/view/note.rs new file mode 100644 index 0000000..7302eaa --- /dev/null +++ b/src/view/note.rs @@ -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, + /// 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, + /// Where each link leads, in the note's order. + links: Vec, + /// The node's children, in order. + children: Vec, + /// The part being edited, as drawn. + editing: Option, +} + +/// One row of the note, ready to draw. +#[derive(Debug, Clone)] +struct Row { + /// The part it shows (`None` for headings and gaps). + part: Option, + 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>, + /// 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, spans: Vec>) { + 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 { + let mut parts: Vec = 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 { + 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> { + 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 { + 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 { + 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> { + 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 { + 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; diff --git a/src/view/note/tests.rs b/src/view/note/tests.rs new file mode 100644 index 0000000..790f7ae --- /dev/null +++ b/src/view/note/tests.rs @@ -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 { + 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::().trim_end().to_string()) + .collect() +} + +/// The rows the bar marks. +fn marked(rows: &[String]) -> Vec { + 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" + ); +} diff --git a/src/view/outline.rs b/src/view/outline.rs new file mode 100644 index 0000000..faabe25 --- /dev/null +++ b/src/view/outline.rs @@ -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, + /// The cursor at the last layout. When it moves, the view follows. + last_cursor: Option, + /// The view was scrolled by hand: it stays put until the cursor moves. + scrolled: bool, + /// The title being edited, as drawn. + editing: Option, +} + +/// 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 = 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 { + 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 { + 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 { + 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>, + width: u16, +} + +impl Lines { + fn push(&mut self, text: impl Into, 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 { + 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> { + 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 { + 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 { + 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; diff --git a/src/view/outline/tests.rs b/src/view/outline/tests.rs new file mode 100644 index 0000000..52c7512 --- /dev/null +++ b/src/view/outline/tests.rs @@ -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 { + 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::().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"); +}