diff --git a/Cargo.lock b/Cargo.lock index f7d49df..5a73417 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -47,12 +65,31 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -113,6 +150,7 @@ dependencies = [ name = "cumulus" version = "0.1.0" dependencies = [ + "arboard", "directories", "ratatui", "ron", @@ -203,7 +241,17 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", ] [[package]] @@ -215,6 +263,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.18.0" @@ -234,15 +288,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -251,7 +348,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -262,7 +359,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -401,6 +498,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "mio" version = "1.2.3" @@ -410,7 +513,16 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", ] [[package]] @@ -437,6 +549,79 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -449,6 +634,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "palette" version = "0.7.7" @@ -505,6 +700,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -526,6 +744,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -654,7 +881,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -720,6 +947,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -892,6 +1125,17 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "typeid" version = "1.0.3" @@ -933,6 +1177,76 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "winapi" version = "0.3.9" @@ -961,6 +1275,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -970,8 +1293,108 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" diff --git a/Cargo.toml b/Cargo.toml index d9d54ce..5db9644 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ unicode-segmentation = "1" # Where each OS keeps settings and app data: %APPDATA% on Windows, # ~/.config and ~/.local/share on Linux. directories = "6" +# The system clipboard, for the "+ register: Windows, and X11 or Wayland on +# Linux. Images are left out (default features off): cumulus copies text. +arboard = { version = "3", default-features = false, features = ["wayland-data-control"] } [lints.rust] unsafe_code = "forbid" diff --git a/DESIGN.md b/DESIGN.md index 618d134..f0e387d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -32,7 +32,8 @@ align · branch out · export (Markdown, plain text) and import (indented lists) **Later**: peeking into portals and pulling a document back in · branches that keep themselves tidy · the Links map · backlinks inside a document · picking -single nodes in visual mode · `Alt+hjkl` · opening a body in `$EDITOR` · +single nodes in visual mode with keys (Ctrl+click does it with the mouse) · +`Alt+hjkl` · opening a body in `$EDITOR` · persistent undo · sessions · operator + motion and macros · a tutorial document · checkbox, link and date fields · Graphviz and Mermaid export · a Settings view · vim tab pages. @@ -122,10 +123,8 @@ lines. - A title left empty comes back as it was, with a note saying so; a new idea left empty goes away. - Registers are shared by the whole app, as in vim: text yanked in one title - pastes into another. `"+` stays inside cumulus until part 8c connects it to - the system clipboard. -- `.` inside the text repeats the last change there, typing included. `.` on - nodes comes with node yank and paste, in part 8c. + pastes into another. `"+` is the system clipboard (part 8c). +- `.` inside the text repeats the last change there, typing included. - As in vim: `cw` changes to the end of the word, `dw` stops at the end of the line, `j` and `k` move by lines of text rather than wrapped rows, Esc steps back onto the last letter typed. @@ -140,6 +139,62 @@ lines. 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). +**The rest of vim** (settled in part 8c) +- Visual mode takes in what lies between where it started and the cursor: + on the canvas, every node on show that the rectangle from one end's box to + the other's touches; in the outline and the Note view, the nodes in + between in reading order. `V` takes each with its whole branch; `v` in a + `V` selection (and `V` in a `v` one) switches, and the same key again + stops. Ctrl+click adds or takes out one node; Ctrl+drag on empty canvas + adds every node its rectangle touches. The selection is drawn thick in + the theme's visual color, and the status bar counts it. +- Operators take exactly what's selected (the user's pick): `d` deletes the + selected nodes, and with `v` their unselected children move up, as with + `x`; `y` yanks them with their shape among them; `>` and `<` move nodes + that share a parent; `M` moves them on the canvas (with their branches, or + just them after `M` again); the pickers set every one of them, as one undo + step. +- A register holds text or nodes, and either pastes anywhere: nodes pasted + into a text give their titles, text pasted on nodes becomes nodes. `yy`, + `yab`, `yib` yank nodes; every delete (`dd`, `x`, `dib`, `d` on a + selection) keeps what it took, so `dd` then `p` moves a branch. `p` `P` + `]p` paste after, before, or as the last children, keeping the yanked + layout, where a new node would go; a count pastes copies. Links between + the pasted nodes come along, and so do links to nodes still there when + they go back into the document they came from. +- `"+` is the system clipboard. Nodes go there as Markdown, the user's + format: a heading per node (`##` for the nodes yanked, `###` for their + children, `####` for anything deeper), its tags as a list between two + `---` lines, then its body. While the clipboard still holds what cumulus + put there, `"+p` gives back everything (colors, bodies, links, the + layout); text from elsewhere becomes nodes (Markdown headings, or a node + per line nested by its indent). `clipboard = "unnamedplus"` makes every + yank, delete and paste use the clipboard, as vim's setting does. +- `.` repeats the last change made with keys in normal or visual mode: the + command with its count and register, plus everything typed in the text + edit it started (`aMulch` then Esc adds another Mulch). A value picked in a + picker repeats on the cursor's node. A change made on a selection repeats + on the cursor's node (`d` as `x`, or `dd` after `V`; `>` as `>>`). +- `/` opens a search in the middle of the screen, as Telescope does (the + user's pick): what's typed at the top, the matches below it, fuzzy, best + first (titles before tags before bodies), with the letters that matched + standing out, and the note of the match you're on beside them. Case only + matters once there's a capital (smartcase). Enter goes there, Esc goes + back; `n` and `N` then go through the same matches, going round, and + `:find` searches straight away. +- Each pane keeps a jump list, as vim's windows do: `gg`, `G`, marks, a + search, `gd`, a link followed in the Note view and switching documents are + jumps. `Ctrl-o` goes back (into another document, opened again if it was + closed), `Ctrl-i` or Tab forward. `''` still goes back to before the last + jump. +- Capital marks (`mA`, `'A`) lead into any document, opening it if it isn't + open; they're kept in the data folder's `memory.ron`, and the small ones + (`ma`) are kept there with each document, so both last from one run to + the next. +- `gd` follows the cursor's cross-link; with several, a list of them pops up + beside the node. `:marks`, `:jumps` and `:registers` list them; Enter goes + to the mark or the place, or pastes the register. + **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 @@ -378,7 +433,7 @@ text). | **Inside text** | `h j k l w b e 0 ^ $ gg G` (and arrows, Home, End) · `f t F T ; ,` · `d` `c` `y` with any motion and `iw aw i" a" i( a(` · `x X s S D C Y` · `r ~ J p P` · `u Ctrl-r .` · `i a I A` back to typing · `o O` (title: a new sibling; body: a new line) · Esc done | | **Move mode** | `hjkl` 1 cell · `HJKL` 5 cells · `M` branch ↔ node · Enter or Esc done | | **Connect mode** | move to the target as usual · Enter link · `d` remove · `>` attach as child · `` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel | -| **Visual** | `d y > < M C` and `c` `s` `t` act on the whole selection · `o` swaps ends | +| **Visual** | `d` `x` `y` `>` `<` `M` and `c` `s` `B` `t` act on the whole selection · `o` swaps ends · `v` `V` switch, or stop · Ctrl+click, Ctrl+drag pick with the mouse | ## Files @@ -471,6 +526,7 @@ max_node_width = 30 glyphs = "unicode" # "ascii" for fonts without box-drawing characters completion_popup = "as-you-type" # "on-tab": only after Tab, like vim's wildmenu editor = "" # what :config opens files in ("nvim", "code --wait") +clipboard = "cumulus" # "unnamedplus": plain y, d and p use the system clipboard # keys.toml: sections motions, normal, canvas, outline, note, visual, move, # connect, list, card, text (vim keys inside text), insert (typing: one key each, @@ -518,10 +574,10 @@ question = "question" # a #question node gets the "question" style ``` **Data folder** (`%APPDATA%\cumulus\data`, `~/.local/share/cumulus` on Linux): -`memory.ron` (where you left off in each document, keyed by `uid`, the command -history, recent documents; capital marks come in part 8c), `backups/` and -`locks/`. If a second cumulus opens a document that's already open, it opens -it read-only. +`memory.ron` (where you left off in each document, keyed by `uid`, with its +marks; the capital marks; the command history; recent documents), `backups/` +and `locks/`. If a second cumulus opens a document that's already open, it +opens it read-only. **Command line** - `cumulus` opens `./ideas.cum`, or `~/ideas.cum` if the current folder doesn't @@ -546,7 +602,7 @@ cumulus/ ├─ main.rs command-line arguments, then app::run ├─ lib.rs the module list ├─ model/ ids, text, geometry, look, node, portal, link, document, edit, op (edits as - │ data), history, fold + │ data), history, fold, clip (nodes taken out, to paste) ├─ input/ key (vim notation), command (Command enum + names), bindings (default tables), │ keymap (+ keys.toml), engine (counts, registers, operators), mouse ├─ command/ registry, parse, complete, history @@ -559,21 +615,25 @@ cumulus/ ├─ config/ paths (the config and data folders), settings (config.toml, `:set`), keys_file │ (keys.toml's template), theme_file (themes laid over the built-in one) ├─ storage/ document_file (RON), write_atomically, names (file names from titles), backup, - │ lock, stamp (noticing changes made outside), memory (memory.ron) + │ lock, stamp (noticing changes made outside), memory (memory.ron), markdown + │ (nodes as Markdown, for the clipboard) └─ app/ mod.rs (App), input (keys and the mouse, routed), update (carrying actions out), render, terminal (the main loop, setup, teardown, panic hook), startup (what `cumulus` opens), documents (the open documents and their files), files (saving, reloading, locks, where you left off), configure (settings, keys and themes applied; `:config`), action, mode, buffer, commands (keys every view shares), calls (`:` commands), text_edit (a node's text being - edited, rapid entry), registers, panes (the split tree), popup (help, menu, - pickers, card, questions); later jumps, portals (cache, backlinks, renames) + edited, rapid entry), registers (text or nodes), clipboard (the system's, for + "+), yank (nodes into registers and back), visual (selections), repeat (`.`), + search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`), + panes (the split tree), popup (help, menu, pickers, card, questions); later + portals (cache, backlinks, renames) ``` -The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs` and -`app/tests/config.rs`): they copy the sample documents to a temporary folder, -type keys and click on a test terminal, and check the screen, the documents -and the files. +The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`, +`app/tests/config.rs` and `app/tests/vim.rs`): they copy the sample +documents to a temporary folder, type keys and click on a test terminal, and +check the screen, the documents and the files. **Flow:** a terminal event comes in → the key engine turns keys into a typed `Command` (with its count and register) → the focused view answers it with diff --git a/README.md b/README.md index 97dad44..ac94c65 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ what's wrong). a theme can say just `accent = "magenta"`. - The built-in theme has named styles (`s`), also put on by tags: `#question` (yellow, dashed), `#important` (red, thick), `#done` (faded). +- `clipboard = "unnamedplus"` makes plain `y`, `d` and `p` use the system + clipboard, as the vim setting of that name does. ## Keys @@ -69,24 +71,34 @@ The plan for the everyday keys (all of them are in DESIGN.md): | `gh gj gk gl` | parent, next sibling, previous sibling, first child | | `o` `O` `a` | new sibling below / above, new child: type its title, Enter for the next one | | `i` or Enter | edit the title (Esc: vim keys inside it, Esc again: done) | -| `dd` `x` | delete the branch / just the node | +| `dd` `x` | delete the branch / just the node (kept in a register, as in vim) | +| `yy` `p` `P` `]p` | yank the branch / paste after, before, as a child (`dd` then `p` moves a branch) | +| `"a` `"+` | before `y`, `d` or `p`: register `a`; `"+` is the system clipboard (nodes go there as Markdown) | +| `.` | the last change again | | `>>` `<<` | under the node above / out a level | | `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 | +| `v` `V` | select: the nodes between here and where you started (`V`: with their branches); then `d` `y` `>` `<` `M` or `c` act on all of them | +| `M` | move the branch with `hjkl` (`M` again: just the node) | +| `/` `n` `N` | search: titles, tags and bodies, fuzzily, with the note of each match beside it / the next match / the previous one | +| `Ctrl-o` `Tab` | back to where you jumped from / forward again (across documents) | +| `ma` `'a` `mA` | mark a node / go back to it / a capital mark: from any document | +| `gd` | follow a cross-link | | `` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `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) | | `:e` `:new` `:bd` | open a document / make one / close it | +| `:marks` `:jumps` `:registers` | what they hold: Enter goes there (or pastes) | | `:w` `:q` `ZZ` | save / close the pane (the last one quits) / save and close | After the first key of a sequence (`g`, `z`, `d`, ``…), a popup lists what can follow. The right mouse button opens a menu of what you can do with -a node. The wheel scrolls; with Shift or Alt held (or a wheel that tilts), it -scrolls sideways. +a node. Ctrl+click picks nodes one by one, and Ctrl+drag on the empty canvas +picks the ones in its rectangle. The wheel scrolls; with Shift or Alt held +(or a wheel that tilts), it scrolls sideways. ## Development diff --git a/src/app/action.rs b/src/app/action.rs index 6927b87..03c19ab 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -6,12 +6,14 @@ //! [`Buffer::apply`]: super::buffer::Buffer::apply use super::documents::BufferId; +use super::jumps::Place; use super::panes::Arrange; use super::popup::{MenuFor, PickKind}; +use super::repeat::Look; use super::text_edit::StartEdit; use crate::command::parse::SetArg; use crate::config::ConfigFile; -use crate::input::command::Command; +use crate::input::command::{Command, Register}; use crate::input::key::Key; use crate::model::{Direction, EditOp, Group, NodeId, Title}; use crate::view::ViewKind; @@ -34,6 +36,11 @@ pub struct Message { pub tone: Tone, } +/// "1 node", "3 nodes". +pub fn nodes(count: usize) -> String { + if count == 1 { "1 node".to_string() } else { format!("{count} nodes") } +} + impl Message { pub fn info(text: impl Into) -> Message { Message { text: text.into(), tone: Tone::Info } @@ -101,8 +108,20 @@ pub enum AppAction { EndEdit, /// Put the text cursor here, in the text being edited (a click). TextCursor(usize), - /// Move mode: hjkl moves the cursor's branch. - EnterMoveMode, + /// Visual mode, `V` (`branches`) or `v`; in visual mode, switches + /// between the two. + Visual { + branches: bool, + }, + /// In visual mode: the cursor goes to the other end (`o`). + SwapEnds, + /// A node into the selection, or out of it (a Ctrl+click). + ToggleSelected(NodeId), + /// Nodes into the selection (a Ctrl+drag). + SelectAlso(Vec), + /// Move mode: hjkl moves the cursor's branch, or these nodes (a + /// selection's). + EnterMoveMode(Vec), /// In move mode: the whole branch, or just the node. ToggleWhole, /// Back to normal mode. @@ -119,6 +138,22 @@ pub enum AppAction { /// Keys, as if typed: for a key that waits for more (`m` waits for a /// mark's letter). Type(Vec), + /// Keep nodes in a register (`yy`, and before a delete): exactly + /// `nodes`, or (`branches`) each with its whole branch. + Yank { + register: Option, + nodes: Vec, + branches: bool, + }, + /// Paste what a register holds (`p`, `P`, `]p`): its nodes, or its text + /// made into nodes, `times` copies. + Paste { + register: Option, + at: PasteAt, + times: u32, + }, + /// A value was picked in a picker: `.` can put it on another node. + Picked(Look), /// Focus mode: Open branches act like Auto ones. ToggleFocus, ToggleWhichKey, @@ -128,6 +163,34 @@ pub enum AppAction { // Popups. /// Every key and command, with this already searched for. OpenHelp(String), + /// `/`: the search. + OpenSearch, + /// A search went somewhere: `n` and `N` go on with it. + Searched(String), + /// `Ctrl-o` (`back`) and `Ctrl-i`: through the focused pane's jump list. + Travel { + back: bool, + times: u32, + }, + /// `n` and `N`: the next or previous match of the last search. + FindAgain { + forward: bool, + times: u32, + }, + /// `:find`: straight to the best match. + Find(String), + /// A capital mark on the cursor's node (`mA`). + SetFileMark(char), + /// To a capital mark, in any document (`'A`). + JumpToFileMark(char), + /// To a place from the jump list (`:jumps`). + GoToPlace(Place), + /// `:marks`, `:jumps` and `:registers`. + OpenMarks, + OpenJumps, + OpenRegisters, + /// `gd` on a node with several cross-links: which one to follow. + OpenLinks, /// The cursor's body in a card beside it. OpenCard, OpenPicker(PickKind), @@ -193,6 +256,17 @@ pub enum AppAction { Say(Message), } +/// Where `p` and its kin paste, from the cursor's node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PasteAt { + /// Its next siblings (`p`). + After, + /// Its siblings before it (`P`). + Before, + /// Its last children (`]p`). + Child, +} + /// What `:w` and its kin save. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SaveWhich { diff --git a/src/app/buffer.rs b/src/app/buffer.rs index 5e2f447..860ad96 100644 --- a/src/app/buffer.rs +++ b/src/app/buffer.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; -use super::action::{CursorAfter, DocAction, Message}; +use super::action::{self, CursorAfter, DocAction, Message}; use crate::model::{Applied, Document, History, NodeId}; /// An open document, with what goes with it while it's open. @@ -42,6 +42,18 @@ impl Buffer { &self.history } + /// The marks (`ma`), by letter. + pub fn marks(&self) -> &BTreeMap { + &self.marks + } + + /// Marks kept from the last time the document was open: the ones whose + /// nodes are still there. + pub fn keep_marks(&mut self, marks: impl IntoIterator) { + let doc = &self.doc; + self.marks.extend(marks.into_iter().filter(|(_, id)| doc.contains(*id))); + } + /// Carries out an action on the document or its cursor, and says how it /// went when there's something to say. pub fn apply(&mut self, action: DocAction) -> Option { @@ -66,7 +78,7 @@ impl Buffer { // a drag), with nothing to say on its own: an idea left // empty goes away quietly. Applied::Removed(count) if group.is_none() => { - Some(Message::done(format!("deleted {} · u to undo", nodes(count)))) + Some(Message::done(format!("deleted {} · u to undo", action::nodes(count)))) } Applied::Removed(_) | Applied::Added(_) | Applied::Changed => None, } @@ -153,11 +165,6 @@ impl Buffer { } } -/// "1 node", "3 nodes". -fn nodes(count: usize) -> String { - if count == 1 { "1 node".to_string() } else { format!("{count} nodes") } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/app/calls.rs b/src/app/calls.rs index 1178efa..2390d02 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -62,13 +62,13 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec { None => say(Message::problem("a node can't link to itself")), }, Call::Unlink(target) => unlink(ctx, target), - Call::Find(text) => find(ctx, &text), + Call::Find(text) => app(AppAction::Find(text)), Call::Set(arg) => vec![Action::App(AppAction::Set(arg))], Call::Version => say(Message::info(crate::version())), Call::Portal(_) | Call::Backlinks => later("portals come in part 8d of the build"), - Call::Jumps | Call::Marks | Call::Registers => { - later("the lists of jumps, marks and registers come in part 8c of the build") - } + Call::Jumps => app(AppAction::OpenJumps), + Call::Marks => app(AppAction::OpenMarks), + Call::Registers => app(AppAction::OpenRegisters), Call::Theme(name) => vec![Action::App(AppAction::Set(SetArg::Value("theme".into(), name)))], Call::Config => app(AppAction::OpenConfig), Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => { @@ -135,28 +135,6 @@ fn unlink(ctx: &ViewContext<'_>, target: Option) -> Vec { vec![Action::edit(EditOp::Unlink(keys))] } -/// `:find`: jumps to the next node whose title has the text in it, in -/// reading order, starting over at the top after the last. -fn find(ctx: &ViewContext<'_>, text: &str) -> Vec { - let doc = ctx.doc; - let wanted = text.to_lowercase(); - let order = doc.outline(); - let matches: Vec = (0..order.len()) - .filter(|&index| { - doc.node(order[index]).is_some_and(|node| node.title().as_str().to_lowercase().contains(&wanted)) - }) - .collect(); - let here = order.iter().position(|&id| id == ctx.cursor).unwrap_or(0); - let Some(nth) = matches.iter().position(|&index| index > here).or((!matches.is_empty()).then_some(0)) - else { - return say(Message::problem(format!("nothing matches \"{text}\""))); - }; - vec![ - Action::jump(order[matches[nth]]), - Action::say(Message::info(format!("\"{text}\": match {} of {}", nth + 1, matches.len()))), - ] -} - #[cfg(test)] mod tests { use super::*; @@ -194,19 +172,4 @@ mod tests { assert!(rects.iter().all(|(b, other)| a == b || !rect.overlaps(*other)), "{a:?} overlaps"); } } - - #[test] - fn find_goes_round_the_matches() { - let ((doc, g), setup) = (garden(), Setup::default()); - let mut buffer = Buffer::new(doc); - // "Raised beds" and "Cedar" both have "ed" in them. - run(&mut buffer, &setup, Call::Find("ED".into())); - assert_eq!(buffer.cursor(), g.beds); - run(&mut buffer, &setup, Call::Find("ed".into())); - assert_eq!(buffer.cursor(), g.cedar); - run(&mut buffer, &setup, Call::Find("ed".into())); - assert_eq!(buffer.cursor(), g.beds, "back to the first"); - let nothing = actions_for(Call::Find("kale".into()), &setup.ctx(buffer.doc(), buffer.cursor())); - assert_eq!(nothing, say(Message::problem("nothing matches \"kale\""))); - } } diff --git a/src/app/clipboard.rs b/src/app/clipboard.rs new file mode 100644 index 0000000..d44d8d7 --- /dev/null +++ b/src/app/clipboard.rs @@ -0,0 +1,62 @@ +//! The system clipboard, behind the `"+` register (and behind every yank +//! and paste with `clipboard = "unnamedplus"`). It's opened the first time +//! it's used, and kept open: on Linux, text copied there lasts as long as +//! cumulus runs, or until a clipboard manager takes it over. +//! +//! The tests keep their clipboard in memory, so they never touch yours. + +/// The system clipboard. +#[derive(Default)] +pub struct Clipboard { + #[cfg(not(test))] + system: Option, + #[cfg(test)] + text: Option, +} + +impl std::fmt::Debug for Clipboard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Clipboard") + } +} + +#[cfg(not(test))] +impl Clipboard { + /// The text on the clipboard. + pub fn read(&mut self) -> Result { + self.open()?.get_text().map_err(problem) + } + + /// Puts text on the clipboard. + pub fn write(&mut self, text: &str) -> Result<(), String> { + self.open()?.set_text(text.to_string()).map_err(problem) + } + + fn open(&mut self) -> Result<&mut arboard::Clipboard, String> { + if self.system.is_none() { + self.system = Some(arboard::Clipboard::new().map_err(problem)?); + } + self.system.as_mut().ok_or_else(|| "the clipboard isn't available".to_string()) + } +} + +/// What went wrong with the clipboard, in words. +#[cfg(not(test))] +fn problem(error: arboard::Error) -> String { + match error { + arboard::Error::ContentNotAvailable => "the clipboard holds no text".to_string(), + error => format!("the clipboard isn't available: {error}"), + } +} + +#[cfg(test)] +impl Clipboard { + pub fn read(&mut self) -> Result { + self.text.clone().ok_or_else(|| "the clipboard holds no text".to_string()) + } + + pub fn write(&mut self, text: &str) -> Result<(), String> { + self.text = Some(text.to_string()); + Ok(()) + } +} diff --git a/src/app/commands.rs b/src/app/commands.rs index 0329586..3921dcd 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -7,16 +7,16 @@ use std::collections::BTreeMap; -use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message, SaveWhich}; +use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message, PasteAt, SaveWhich}; 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::command::{Command, Motion, Operator, Register, Target}; use crate::input::engine::Typed; use crate::model::{ - CanvasPos, CanvasRect, CanvasSize, Direction, Document, EditOp, Fold, Group, Node, NodeId, Spot, Title, - Visible, + CanvasPos, CanvasRect, CanvasSize, Clip, Direction, Document, EditOp, Fold, Group, Node, NodeId, Spot, + Title, Visible, }; use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, free_spot, spot_below}; use crate::view::{ViewContext, ViewKind}; @@ -27,11 +27,20 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option> { let times = typed.times(); let cursor = ctx.cursor; let actions = match typed.command { + Command::Search => app(AppAction::OpenSearch), + Command::JumpBack => app(AppAction::Travel { back: true, times }), + Command::JumpForward => app(AppAction::Travel { back: false, times }), + Command::Move(Motion::SearchNext) => app(AppAction::FindAgain { forward: true, times }), + Command::Move(Motion::SearchPrevious) => app(AppAction::FindAgain { forward: false, times }), Command::Move(motion) => return tree_motion(motion, times, ctx), Command::Undo => vec![Action::Doc(DocAction::Undo(times))], Command::Redo => vec![Action::Doc(DocAction::Redo(times))], + // Capital marks lead into any document: the app keeps those. + Command::SetMark(name) if name.is_ascii_uppercase() => app(AppAction::SetFileMark(name)), + Command::JumpToMark(name) if name.is_ascii_uppercase() => app(AppAction::JumpToFileMark(name)), Command::SetMark(name) => vec![Action::Doc(DocAction::SetMark(name))], Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))], + Command::FollowLink => follow_link(ctx), Command::Activate if ctx.doc.node(cursor).is_some_and(Node::is_portal) => { say("going through a portal comes in part 8d of the build") } @@ -41,9 +50,27 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option> { Command::AddChild => add(ctx, Spot::LastChildOf(cursor)), Command::AddBelow => add(ctx, Spot::After(cursor)), Command::AddAbove => add(ctx, Spot::Before(cursor)), - Command::DeleteNode => delete_node(ctx), - Command::Operate(Operator::Delete, Target::Current | Target::Branch) => delete_branches(ctx, times), - Command::Operate(Operator::Delete, Target::Children) => delete_children(ctx), + Command::DeleteNode => delete_node(ctx, typed.register), + Command::Operate(Operator::Delete, Target::Current | Target::Branch) => { + delete_branches(ctx, times, typed.register) + } + Command::Operate(Operator::Delete, Target::Children) => delete_children(ctx, typed.register), + Command::Operate(Operator::Yank, Target::Current | Target::Branch) => { + app(AppAction::Yank { register: typed.register, nodes: siblings(ctx, times), branches: true }) + } + Command::Operate(Operator::Yank, Target::Children) => match ctx.doc.children(cursor) { + [] => say("this node has no children to yank"), + children => { + app(AppAction::Yank { register: typed.register, nodes: children.to_vec(), branches: true }) + } + }, + Command::PasteAfter => app(AppAction::Paste { register: typed.register, at: PasteAt::After, times }), + Command::PasteBefore => { + app(AppAction::Paste { register: typed.register, at: PasteAt::Before, times }) + } + Command::PasteAsChild => { + app(AppAction::Paste { register: typed.register, at: PasteAt::Child, times }) + } Command::Operate(Operator::Indent, Target::Current) => indent(ctx, &siblings(ctx, times), None), Command::Operate(Operator::Outdent, Target::Current) => outdent(ctx, &siblings(ctx, times), None), Command::ToggleFold => toggle_fold(ctx), @@ -52,7 +79,9 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option> { Command::AutoFold => set_fold(ctx, Fold::Auto), Command::OpenAllFolds => fold_all(ctx, Fold::Open), Command::CloseAllFolds => fold_all(ctx, Fold::Closed), - Command::MoveMode => app(AppAction::EnterMoveMode), + Command::MoveMode => app(AppAction::EnterMoveMode(Vec::new())), + Command::Visual => app(AppAction::Visual { branches: false }), + Command::VisualBranches => app(AppAction::Visual { branches: true }), Command::CommandLine => app(AppAction::OpenCommandLine), // `i`: an idea to the Inbox, typed in `:in`. Command::Capture => app(AppAction::CommandLineWith("in ".into())), @@ -201,7 +230,7 @@ pub fn outdent(ctx: &ViewContext<'_>, ids: &[NodeId], group: Option) -> V /// `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> { +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, @@ -251,7 +280,7 @@ fn make_room( fn shift_to(doc: &Document, id: NodeId, pos: CanvasPos, group: Group) -> Option { let offset = pos - doc.node(id)?.pos; let moved = offset != Default::default(); - moved.then(|| Action::edit_in(EditOp::Shift { id, offset, branch: true }, group)) + moved.then(|| Action::edit_in(EditOp::Shift { ids: vec![id], offset, branch: true }, group)) } /// The cursor's node and the next siblings, `count` in all. @@ -329,16 +358,37 @@ fn tree_step(ctx: &ViewContext<'_>, from: NodeId, motion: Motion) -> Option) -> Vec { +/// `gd`: to the node at the other end of the cursor's cross-link; with +/// several, a list of them to pick from. A jump: `Ctrl-o` comes back. +fn follow_link(ctx: &ViewContext<'_>) -> Vec { + let cursor = ctx.cursor; + let others: Vec = ctx + .doc + .links_of(cursor) + .map(|link| if link.start() == cursor { link.end() } else { link.start() }) + .collect(); + match others.as_slice() { + [] => say("this node has no cross-links to follow"), + [other] => vec![Action::jump(*other)], + _ => app(AppAction::OpenLinks), + } +} + +/// `x`: deletes just the cursor's node; its children take its place. The +/// node goes in the register, as a delete's text does in vim. +fn delete_node(ctx: &ViewContext<'_>, register: Option) -> Vec { let doc = ctx.doc; let next = doc.children(ctx.cursor).first().copied().or_else(|| doc.parent(ctx.cursor)); - vec![Action::edit_then(EditOp::RemoveKeepingChildren(ctx.cursor), after(next))] + vec![ + Action::App(AppAction::Yank { register, nodes: vec![ctx.cursor], branches: false }), + Action::edit_then(EditOp::RemoveKeepingChildren(vec![ctx.cursor]), after(next)), + ] } /// `dd` or `dab`: the cursor's node and its branch. With a count, the next /// siblings and their branches go too, as `3dd` deletes three lines in vim. -fn delete_branches(ctx: &ViewContext<'_>, count: u32) -> Vec { +/// What's deleted goes in the register. +fn delete_branches(ctx: &ViewContext<'_>, count: u32, register: Option) -> Vec { let doc = ctx.doc; let branches = doc.siblings_from(ctx.cursor, usize::try_from(count).unwrap_or(1)); let Some(&last) = branches.last() else { return Vec::new() }; @@ -347,16 +397,44 @@ fn delete_branches(ctx: &ViewContext<'_>, count: u32) -> Vec { .next_sibling(last) .or_else(|| doc.previous_sibling(ctx.cursor)) .or_else(|| doc.parent(ctx.cursor)); - vec![Action::edit_then(EditOp::RemoveBranches(branches), after(next))] + vec![ + Action::App(AppAction::Yank { register, nodes: branches.clone(), branches: true }), + Action::edit_then(EditOp::RemoveBranches(branches), after(next)), + ] } /// `dib`: everything below the cursor's node. -fn delete_children(ctx: &ViewContext<'_>) -> Vec { +fn delete_children(ctx: &ViewContext<'_>, register: Option) -> Vec { let children = ctx.doc.children(ctx.cursor).to_vec(); if children.is_empty() { return say("this node has no children to delete"); } - vec![Action::edit(EditOp::RemoveBranches(children))] + vec![ + Action::App(AppAction::Yank { register, nodes: children.clone(), branches: true }), + Action::edit(EditOp::RemoveBranches(children)), + ] +} + +/// `p`, `P` and `]p`: copies of a clip around the cursor's node, placed +/// where a new sibling or child would go, one below the other, each keeping +/// the clip's own layout. +pub fn paste(ctx: &ViewContext<'_>, clip: Clip, at: PasteAt, times: u32) -> Option { + let (doc, cursor) = (ctx.doc, ctx.cursor); + let bounds = clip.bounds(|node| ctx.box_size(node))?; + let rects = ctx.box_rects(); + let (spot, start) = match at { + PasteAt::After => (Spot::After(cursor), below_spot(cursor, bounds.size, &rects)), + PasteAt::Before => (Spot::Before(cursor), below_spot(cursor, bounds.size, &rects)), + PasteAt::Child => (Spot::LastChildOf(cursor), child_spot(doc, cursor, bounds.size, &rects)), + }; + let mut offsets = Vec::new(); + let mut pos = start; + for _ in 0..times.max(1) { + pos = free_spot(pos, bounds.size, &rects); + offsets.push(pos - bounds.pos); + pos = CanvasPos::new(pos.x, pos.y + i32::from(bounds.size.height) + 1); + } + Some(EditOp::Paste { clip, spot, offsets }) } /// Where the cursor goes after a delete: to `next`, or the root when there's @@ -392,12 +470,13 @@ mod tests { fn a_count_deletes_the_next_siblings_too() { let ((doc, g), setup) = (garden(), Setup::default()); let dd = Command::Operate(Operator::Delete, Target::Current); + let yank = AppAction::Yank { register: None, nodes: vec![g.soil, g.beds], branches: true }; assert_eq!( shared(&typed(dd, Some(2)), &setup.ctx(&doc, g.soil)), - Some(vec![Action::edit_then( - EditOp::RemoveBranches(vec![g.soil, g.beds]), - CursorAfter::To(g.watering) - )]) + Some(vec![ + Action::App(yank), + Action::edit_then(EditOp::RemoveBranches(vec![g.soil, g.beds]), CursorAfter::To(g.watering)) + ]) ); } diff --git a/src/app/configure.rs b/src/app/configure.rs index 7531671..1f208cc 100644 --- a/src/app/configure.rs +++ b/src/app/configure.rs @@ -14,7 +14,7 @@ use super::mode::Mode; use super::popup::Popup; use crate::command::parse::SetArg; use crate::config::paths::Paths; -use crate::config::settings::Settings; +use crate::config::settings::{ClipboardUse, Settings}; use crate::config::{ConfigFile, keys, theme_file}; use crate::storage::stamp::Stamp; use crate::storage::write_atomically; @@ -67,6 +67,7 @@ impl App { /// titles wrap at. Returns what was wrong with the theme's file. fn settings_changed(&mut self, before: &Settings) -> Vec { self.shared.view.max_node_width = self.settings.max_node_width; + self.registers.unnamed_plus = self.settings.clipboard == ClipboardUse::UnnamedPlus; if self.settings.theme == before.theme && self.settings.glyphs == before.glyphs { return Vec::new(); } diff --git a/src/app/files.rs b/src/app/files.rs index d6f6ab6..c39ee0e 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -19,12 +19,12 @@ use std::time::{Duration, Instant}; use thiserror::Error; +use super::App; use super::action::{DocAction, Keep, Message, SaveWhich, Tone}; use super::buffer::Buffer; use super::documents::{Access, BufferId, DocFile, Documents, Open, Trouble}; use super::mode::Mode; use super::popup::Popup; -use super::{App, Pane}; use crate::config::paths::Paths; use crate::model::{CanvasPos, DocUid, Document, NodeId, Title}; use crate::storage::backup::{self, Day}; @@ -185,6 +185,7 @@ impl App { if open.buffer.doc().contains(cursor) { open.buffer.apply(DocAction::MoveCursor { to: cursor, jump: false }); } + open.buffer.keep_marks(place.marks.iter().map(|(&name, &id)| (name, NodeId::new(id)))); open.camera = Some(Camera { origin: CanvasPos::new(place.x, place.y), zoom: zoom_named(&place.zoom) }); } @@ -216,6 +217,7 @@ impl App { zoom: camera.zoom.name().to_lowercase(), x: camera.origin.x, y: camera.origin.y, + marks: open.buffer.marks().iter().map(|(&name, id)| (name, id.get())).collect(), }; self.memory.places.insert(open.buffer.doc().uid().to_string(), place); } @@ -556,7 +558,7 @@ impl App { let next = self.shared.docs.at(index.saturating_sub(1)); let camera = self.shared.docs.get(next).camera; for (_, pane) in self.panes.iter_mut().filter(|(_, pane)| pane.buffer == id) { - *pane = Pane::new(next, pane.view.kind(), camera); + pane.show(next, pane.view.kind(), camera); } Some(Message::done(format!("closed {name}"))) } @@ -618,9 +620,20 @@ impl App { None } - /// The focused pane shows document `id`, in the same kind of view. Its - /// canvas camera stays with the document it showed. + /// The focused pane shows document `id`, in the same kind of view: a + /// jump, so `Ctrl-o` comes back. pub(super) fn show(&mut self, id: BufferId) { + if self.buffer() != id { + let from = self.place_here(); + self.jumping(from); + self.switch_to(id); + } + } + + /// The focused pane shows document `id`, in the same kind of view. Its + /// canvas camera stays with the document it showed, and a selection + /// ends. + pub(super) fn switch_to(&mut self, id: BufferId) { let pane = self.panes.current_mut(); if pane.buffer == id { return; @@ -629,7 +642,10 @@ impl App { self.shared.docs.get_mut(pane.buffer).camera = Some(camera); } let camera = self.shared.docs.get(id).camera; - *pane = Pane::new(id, pane.view.kind(), camera); + pane.show(id, pane.view.kind(), camera); + if let Mode::Visual(_) = self.shared.mode { + self.shared.mode = Mode::Normal; + } } /// The message for a change refused because the document is read-only. diff --git a/src/app/input.rs b/src/app/input.rs index 3785fd8..d6509df 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -6,7 +6,7 @@ use ratatui::layout::Position; use super::action::{Action, AppAction, DocumentChoice, Message}; use super::mode::Mode; -use super::{App, calls, commands}; +use super::{App, calls, commands, visual}; use crate::command::parse::{Sources, parse}; use crate::config::settings::Setting; use crate::config::theme_file; @@ -21,25 +21,39 @@ impl App { /// A key, as typed. pub fn key(&mut self, key: Key) { self.message = None; + self.record(key); match self.shared.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(_) => {} - } - 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 => {} + Mode::Command(_) => self.command_line_key(key), + Mode::Text(_) => self.text_key(key), + Mode::Popup(_) => self.popup_key(key), + Mode::Normal | Mode::Visual(_) | Mode::Move(_) => { + match self.keys.press(key, &self.keymap.tables(self.contexts())) { + Outcome::Run(typed) => self.run_typed(typed), + Outcome::Unknown(keys) => { + self.message = Some(Message::info(format!("{keys} does nothing here"))); + } + Outcome::Waiting | Outcome::Cancelled => {} + } + } } + self.end_recording(); } - /// A command: the focused view has the first say, the shared commands - /// get the rest. - fn dispatch(&mut self, typed: Typed) { + /// A command: the focused view has the first say, then visual mode's + /// commands (in visual mode), then the shared commands. + pub(super) fn dispatch(&mut self, typed: Typed) { + let selected = self.selection().unwrap_or_default(); let pane = self.panes.current_mut(); + let kind = pane.view.kind(); let ctx = self.shared.ctx(pane.buffer); - let actions = pane.view.command(&typed, &ctx).or_else(|| commands::shared(&typed, &ctx)); + let actions = pane + .view + .command(&typed, &ctx) + .or_else(|| match &self.shared.mode { + Mode::Visual(visual) => visual::command(&typed, visual, &selected, kind, &ctx), + _ => None, + }) + .or_else(|| commands::shared(&typed, &ctx)); let actions = actions.unwrap_or_else(|| { let later = format!("\"{}\" comes in a later part of the build", describe(typed.command)); vec![Action::say(Message::info(later))] @@ -165,6 +179,12 @@ impl App { self.shared.mode = Mode::Normal; } } + // A click without Ctrl ends a selection, as in most apps. + if let (Mode::Visual(_), Gesture::Press { ctrl: false, .. } | Gesture::DoublePress { .. }) = + (&self.shared.mode, gesture) + { + self.shared.mode = Mode::Normal; + } if let Mode::Popup(_) = self.shared.mode { let Mode::Popup(mut popup) = std::mem::take(&mut self.shared.mode) else { return }; let actions = popup.mouse(gesture, &self.shared.ctx(self.buffer())); @@ -195,10 +215,10 @@ impl App { } if target != self.panes.focused() { // Another pane: a text edited in this one is done first, and - // move mode ends. + // move or visual mode ends. let ctx = self.shared.ctx(self.buffer()); let mut actions = self.shared.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default(); - if let Mode::Move(_) = self.shared.mode { + if let Mode::Move(_) | Mode::Visual(_) = self.shared.mode { actions.push(Action::App(AppAction::LeaveMode)); } self.update(actions); diff --git a/src/app/jumps.rs b/src/app/jumps.rs new file mode 100644 index 0000000..92536b0 --- /dev/null +++ b/src/app/jumps.rs @@ -0,0 +1,156 @@ +//! The jump list, as vim keeps one for each window: where the cursor was +//! before each jump (`gg`, `G`, a mark, a search, `gd`, another document), +//! so `Ctrl-o` goes back through them and `Ctrl-i` (or Tab) forward again. +//! Each pane has its own; a new pane starts with a copy of its first +//! pane's. A place in another document opens it, even after it was closed. + +use std::path::PathBuf; + +use super::action::{DocAction, Message}; +use super::{App, files}; +use crate::model::{DocUid, NodeId}; + +/// How many places a jump list keeps. +const MOST: usize = 100; + +/// A place to jump back to: a node in a document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Place { + pub uid: DocUid, + /// The document's file, to open it again if it was closed. + pub path: PathBuf, + pub node: NodeId, +} + +/// A pane's jump list: the places jumped from, oldest first, and where +/// `Ctrl-o` and `Ctrl-i` are in it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Jumps { + places: Vec, + /// Where `Ctrl-o` and `Ctrl-i` are: past the end until they're used. + at: usize, +} + +impl Jumps { + /// A jump from `from`: it goes at the end (an older entry for the same + /// place goes away), and `Ctrl-o` starts again from there. + pub fn push(&mut self, from: Place) { + self.places.retain(|place| *place != from); + self.places.push(from); + if self.places.len() > MOST { + self.places.remove(0); + } + self.at = self.places.len(); + } + + /// `Ctrl-o` from `here`: the place `times` back, if there's one. The + /// first time, `here` goes at the end, so `Ctrl-i` can come back to it. + pub fn back(&mut self, here: Place, times: usize) -> Option { + if self.at >= self.places.len() { + if self.places.is_empty() { + return None; + } + self.places.retain(|place| *place != here); + self.places.push(here); + self.at = self.places.len() - 1; + } + self.at = self.at.checked_sub(times)?; + self.places.get(self.at).cloned() + } + + /// `Ctrl-i`: the place `times` forward, if there's one. + pub fn forward(&mut self, times: usize) -> Option { + let to = self.at.checked_add(times).filter(|&to| to < self.places.len())?; + self.at = to; + self.places.get(to).cloned() + } + + /// The places, oldest first, and which one `Ctrl-o` and `Ctrl-i` are on + /// (none until they're used), for `:jumps`. + pub fn list(&self) -> (&[Place], Option) { + (&self.places, (self.at < self.places.len()).then_some(self.at)) + } +} + +impl App { + /// Where the cursor is in the focused pane, as a place to come back to. + pub(super) fn place_here(&self) -> Place { + let open = self.shared.docs.get(self.buffer()); + Place { uid: open.buffer.doc().uid(), path: open.file.path.clone(), node: open.buffer.cursor() } + } + + /// A jump is about to happen from where the cursor is: the focused + /// pane's list keeps the place. + pub(super) fn jumping(&mut self, from: Place) { + self.panes.current_mut().jumps.push(from); + } + + /// `Ctrl-o` (`back`) and `Ctrl-i`. + pub(super) fn travel(&mut self, back: bool, times: u32) -> Option { + let times = usize::try_from(times.max(1)).unwrap_or(1); + let here = self.place_here(); + let jumps = &mut self.panes.current_mut().jumps; + let place = if back { jumps.back(here, times) } else { jumps.forward(times) }; + match place { + Some(place) => self.go_to(&place), + None if back => Some(Message::info("no older jump: that's where the jump list starts")), + None => Some(Message::info("no newer jump: Ctrl-o goes back")), + } + } + + /// Puts the focused pane on a place: its document (opened again if it + /// was closed), and its node. Not a jump itself. + pub(super) fn go_to(&mut self, place: &Place) -> Option { + let id = match self.shared.docs.find_uid(place.uid) { + Some(id) => id, + None => match files::load(&place.path) { + Ok(loaded) if loaded.doc.uid() == place.uid => self.add_document(loaded).0, + Ok(_) => { + return Some(Message::problem(format!( + "{} holds another document now", + place.path.display() + ))); + } + Err(error) => return Some(Message::problem(error.to_string())), + }, + }; + self.switch_to(id); + if !self.current().doc().contains(place.node) { + return Some(Message::info("that node is gone: here's its document")); + } + self.doc_action(DocAction::MoveCursor { to: place.node, jump: false }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn place(node: u32) -> Place { + Place { + uid: DocUid::parse("0000000000000001").unwrap(), + path: PathBuf::from("a.cum"), + node: NodeId::new(node), + } + } + + #[test] + fn back_and_forward_as_in_vim() { + let mut jumps = Jumps::default(); + assert_eq!(jumps.back(place(9), 1), None, "nothing to go back to"); + jumps.push(place(1)); + jumps.push(place(2)); + // From 3: back to 2, then 1; forward to 2, then 3 (kept by the first + // Ctrl-o), and no further. + assert_eq!(jumps.back(place(3), 1), Some(place(2))); + assert_eq!(jumps.back(place(2), 1), Some(place(1))); + assert_eq!(jumps.back(place(1), 1), None); + assert_eq!(jumps.forward(1), Some(place(2))); + assert_eq!(jumps.forward(1), Some(place(3))); + assert_eq!(jumps.forward(1), None); + // A new jump goes at the end, without its older copy. + jumps.push(place(1)); + assert_eq!(jumps.list().0, [place(2), place(3), place(1)]); + assert_eq!(jumps.back(place(4), 2), Some(place(3)), "a count goes further"); + } +} diff --git a/src/app/marks.rs b/src/app/marks.rs new file mode 100644 index 0000000..1efd251 --- /dev/null +++ b/src/app/marks.rs @@ -0,0 +1,136 @@ +//! Capital marks (`mA`, `'A`): marks that lead into any document, kept in +//! `memory.ron` so they last from one run to the next, as vim's file marks +//! do. The small ones (`ma`) belong to their document: the buffer keeps +//! them, and `memory.ron` keeps them with where you left off. +//! +//! Also here: the lists `:marks`, `:jumps` and `:registers` open, and the +//! one `gd` opens on a node with several cross-links. + +use ratatui::layout::Position; + +use super::App; +use super::action::Message; +use super::jumps::Place; +use super::mode::Mode; +use super::popup::{ListedMark, Popup}; +use crate::model::{DocUid, NodeId}; +use crate::storage::memory::FileMark; + +impl App { + /// `mA`: a capital mark on the cursor's node. + pub(super) fn set_file_mark(&mut self, name: char) -> Option { + let open = self.shared.docs.get(self.buffer()); + let doc = open.buffer.doc(); + let cursor = open.buffer.cursor(); + let mark = FileMark { path: open.file.path.clone(), uid: doc.uid().to_string(), node: cursor.get() }; + let title = doc.node(cursor).map(|node| node.title().to_string()).unwrap_or_default(); + let text = format!( + "mark {name} is on \"{title}\" in {}: '{name} comes back here from any document", + open.name() + ); + self.memory.marks.insert(name, mark); + Some(Message::done(text)) + } + + /// `'A`: to a capital mark, in its document (opened if it isn't open). + /// A jump: `Ctrl-o` comes back. + pub(super) fn jump_to_file_mark(&mut self, name: char) -> Option { + let Some(mark) = self.memory.marks.get(&name).cloned() else { + return Some(Message::info(format!("no mark {name} yet: m{name} sets it"))); + }; + let Some(uid) = DocUid::parse(&mark.uid) else { + return Some(Message::problem(format!("mark {name} is broken: m{name} sets it again"))); + }; + self.jump_to_place(&Place { uid, path: mark.path, node: NodeId::new(mark.node) }) + } + + /// `:marks`: this document's marks, then the capital ones. + pub(super) fn open_marks(&mut self) { + let doc = self.current().doc(); + let small = self + .current() + .marks() + .iter() + .filter_map(|(&name, &id)| { + Some(ListedMark { name, node: doc.node(id)?.title().to_string(), document: None }) + }) + .collect::>(); + let capital = self + .memory + .marks + .iter() + .map(|(&name, mark)| { + let open = DocUid::parse(&mark.uid).and_then(|uid| self.shared.docs.find_uid(uid)); + let (node, document) = match open { + Some(id) => { + let open = self.shared.docs.get(id); + let node = open.buffer.doc().node(NodeId::new(mark.node)); + ( + node.map_or_else(|| "(gone)".to_string(), |node| node.title().to_string()), + open.name(), + ) + } + None => { + let file = mark.path.file_name().map(|name| name.to_string_lossy().into_owned()); + (format!("node {}", mark.node), file.unwrap_or_default()) + } + }; + ListedMark { name, node, document: Some(document) } + }) + .collect::>(); + self.shared.mode = Mode::Popup(Popup::marks(&small, &capital, &self.shared.theme)); + } + + /// `:jumps`: the focused pane's jump list. + pub(super) fn open_jumps(&mut self) { + let (places, at) = self.panes.current().jumps.list(); + let rows: Vec<(String, Place)> = places + .iter() + .map(|place| { + let open = self.shared.docs.find_uid(place.uid).map(|id| self.shared.docs.get(id)); + let text = match open { + Some(open) => { + let node = open.buffer.doc().node(place.node).map(|node| node.title().to_string()); + format!("{} › {}", open.name(), node.unwrap_or_else(|| "(gone)".to_string())) + } + None => format!("{} › node {}", place.path.display(), place.node), + }; + (text, place.clone()) + }) + .collect(); + self.shared.mode = Mode::Popup(Popup::jumps(&rows, at, &self.shared.theme)); + } + + /// `:registers`: what the registers hold. + pub(super) fn open_registers(&mut self) { + let rows: Vec<(char, String)> = + self.registers.list().into_iter().map(|(name, kept)| (name, kept.describe())).collect(); + self.shared.mode = Mode::Popup(Popup::registers(&rows, &self.shared.theme)); + } + + /// `gd` on a node with several cross-links: a list of them, beside it. + pub(super) fn open_links(&mut self) { + let ctx = self.shared.ctx(self.buffer()); + let at = self + .panes + .current() + .view + .cursor_area(&ctx) + .map_or(Position::new(self.screen.panes.x + 2, self.screen.panes.y + 1), |area| { + Position::new(area.x, area.bottom()) + }); + let popup = Popup::links(&ctx, at); + self.shared.mode = Mode::Popup(popup); + } + + /// Goes to a place (a capital mark's, or one picked in `:jumps`): a + /// jump, so `Ctrl-o` comes back. + pub(super) fn jump_to_place(&mut self, place: &Place) -> Option { + let from = self.place_here(); + let said = self.go_to(place); + if self.place_here() != from { + self.jumping(from); + } + said + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index eb08696..66c6b35 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -21,20 +21,27 @@ pub mod action; pub mod buffer; pub mod calls; +mod clipboard; pub mod commands; mod configure; pub mod documents; mod files; mod input; +mod jumps; +mod marks; pub mod mode; pub mod panes; pub mod popup; pub mod registers; mod render; +mod repeat; +pub mod search; pub mod startup; mod terminal; pub mod text_edit; mod update; +pub mod visual; +mod yank; use std::path::PathBuf; use std::time::Instant; @@ -47,7 +54,7 @@ pub use terminal::run; use crate::command::history::History as CommandHistory; use crate::config::Config; use crate::config::paths::Paths; -use crate::config::settings::Settings; +use crate::config::settings::{ClipboardUse, Settings}; use crate::input::bindings::Context; use crate::input::engine::KeyEngine; use crate::input::keymap::Keymap; @@ -61,6 +68,7 @@ use crate::view::{View, ViewContext, ViewKind, ViewSettings}; use action::Message; use buffer::Buffer; use documents::{BufferId, Documents}; +use jumps::Jumps; use mode::Mode; use panes::{PaneId, Panes}; use registers::Registers; @@ -84,6 +92,14 @@ pub struct App { memory: Memory, /// The status bar's message, until the next key. message: Option, + /// The last change, for `.`. + last_change: Option, + /// A text edit started by a key command, while it goes on. + recording: Option, + /// `.` is making a change again: it isn't recorded again. + repeating: bool, + /// The last search, for `n` and `N`. + last_search: Option, screen: Screen, /// The pane a mouse drag started in: it gets the whole drag. dragging: Option, @@ -126,10 +142,11 @@ impl Shared { } } -/// A pane: one of the documents, through a view. +/// A pane: one of the documents, through a view, and where it jumped from. struct Pane { buffer: BufferId, view: Box, + jumps: Jumps, } impl Pane { @@ -143,7 +160,14 @@ impl Pane { if let Some(camera) = camera { view.set_camera(camera); } - Pane { buffer, view } + Pane { buffer, view, jumps: Jumps::default() } + } + + /// The pane shows `buffer` in a new view (a canvas starting at `camera`); + /// its jump list stays. + fn show(&mut self, buffer: BufferId, kind: ViewKind, camera: Option) { + let jumps = std::mem::take(&mut self.jumps); + *self = Pane { jumps, ..Pane::new(buffer, kind, camera) }; } } @@ -163,6 +187,8 @@ impl App { let Config { settings, keymap, theme, problems } = config; let config_stamps = configure::ConfigStamps::read(&paths, &settings.theme); let view = ViewSettings { max_node_width: settings.max_node_width, ..ViewSettings::default() }; + let mut registers = Registers::default(); + registers.unnamed_plus = settings.clipboard == ClipboardUse::UnnamedPlus; let (docs, first_said) = files::open_first(first, &paths); let first = docs.at(0); let mut app = App { @@ -173,10 +199,14 @@ impl App { settings, keymap, keys: KeyEngine::default(), - registers: Registers::default(), + registers, mouse: MouseReader::default(), memory, message: None, + last_change: None, + recording: None, + repeating: false, + last_search: None, screen: Screen::default(), dragging: None, watched: Instant::now(), diff --git a/src/app/mode.rs b/src/app/mode.rs index 511ef6a..6ecdba1 100644 --- a/src/app/mode.rs +++ b/src/app/mode.rs @@ -2,9 +2,11 @@ //! what it needs, so a mode's state can't outlive it (there's no "move mode //! flag" to forget to clear). +use std::collections::BTreeSet; + use super::popup::Popup; use super::text_edit::{Field, TextEdit}; -use crate::model::Group; +use crate::model::{Group, NodeId}; use crate::ui::command_line::CommandLine; /// The app's mode. @@ -16,6 +18,8 @@ pub enum Mode { /// 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), + /// Selecting nodes: what the cursor passes over from where it started. + Visual(Visual), /// Moving the cursor's node (or its branch) with hjkl. Move(Grab), /// Typing a `:` command. @@ -25,19 +29,50 @@ pub enum Mode { Popup(Popup), } +/// Visual mode's state. What's selected also depends on the view (a +/// rectangle on the canvas, rows in the outline): see +/// [`visual::selection`](super::visual::selection). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Visual { + /// Where the selection started. The cursor is its other end; `o` swaps + /// them. + pub anchor: NodeId, + /// `V`: each node with its whole branch. `v`: just the nodes. + pub branches: bool, + /// Nodes a Ctrl+click or a Ctrl+drag added, on top of what the two ends + /// take in. + pub added: BTreeSet, + /// Nodes a Ctrl+click took out. + pub removed: BTreeSet, +} + +impl Visual { + /// Selecting from `anchor`: `v`, or with `branches`, `V`. + pub fn new(anchor: NodeId, branches: bool) -> Visual { + Visual { anchor, branches, added: BTreeSet::new(), removed: BTreeSet::new() } + } +} + /// Move mode's state. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Grab { /// The whole branch moves, or just the node. pub whole: bool, /// Makes every move one undo step. pub group: Group, + /// The nodes a selection put in move mode; empty, the cursor's node. + pub nodes: Vec, } impl Grab { /// Moving a whole branch, as move mode starts. pub fn new() -> Grab { - Grab { whole: true, group: Group::new() } + Grab { whole: true, group: Group::new(), nodes: Vec::new() } + } + + /// Moving the nodes of a selection, each with its branch to start with. + pub fn nodes(nodes: Vec) -> Grab { + Grab { nodes, ..Grab::new() } } } @@ -84,6 +119,8 @@ impl Mode { Mode::Normal => "NORMAL", Mode::Text(edit) if edit.editor.is_typing() => "INSERT", Mode::Text(_) => "TEXT", + Mode::Visual(Visual { branches: false, .. }) => "VISUAL", + Mode::Visual(_) => "VISUAL BRANCH", Mode::Move(_) => "MOVE", Mode::Command(_) => "COMMAND", Mode::Popup(popup) => popup.label(), diff --git a/src/app/popup.rs b/src/app/popup.rs index 1eecae9..0080e4d 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -17,10 +17,13 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::Span; use unicode_width::UnicodeWidthStr; -use super::action::{Action, AppAction, DocumentChoice, Keep, Message}; +use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Keep, Message}; use super::commands; use super::documents::BufferId; +use super::jumps::Place as JumpPlace; use super::registers::Registers; +use super::repeat::Look; +use super::search::Search; use super::text_edit::{EditAt, StartEdit, TextEdit}; use crate::command::parse::border; use crate::command::registry::{BORDERS, SPECS}; @@ -31,7 +34,9 @@ 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::model::{ + Border, CanvasPos, ColorName, Content, Direction, EditOp, Group, NodeId, StyleName, Tag, Tags, +}; use crate::ui::card::{CardText, CardView}; use crate::ui::list::{ListOutcome, ListPopup, ListRow, Place}; use crate::ui::theme::Theme; @@ -59,6 +64,8 @@ pub enum Popup { Ask(Runs), Pick(Picker), Card(Card), + /// `/`: finding a node. + Search(Search), } /// What the menu is for: a node, or a spot on the empty canvas. @@ -110,18 +117,23 @@ enum Run { Ask(String), /// A new loose idea, here on the canvas. NewIdeaAt(CanvasPos), + /// A jump to a node of this document (`gd`'s list). + Jump(NodeId), /// An action of the app's: an answer to a question. App(AppAction), } -/// A picker: the node it sets something on, and each row's value. +/// A picker: the nodes it sets something on (the cursor's, or a +/// selection), and each row's value. #[derive(Debug, Clone)] pub struct Picker { pub list: ListPopup, - node: NodeId, + nodes: Vec, values: Vec, - /// The node's tags as they are now, in the tags picker. + /// The tags every one of the nodes has now, in the tags picker. tags: Tags, + /// Makes the changes to several nodes one undo step. + group: Group, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -284,17 +296,22 @@ impl Popup { Popup::Menu(Runs { list, runs }) } - /// A picker for the cursor's node (or, for documents, for the pane). + /// A picker for some nodes: the cursor's, or a selection's (or, for + /// documents, for the pane). It starts on what the first node has. /// `documents` are the open documents, then the ones opened lately, and /// `current` is the one the pane shows, by its place among them. pub fn picker( kind: PickKind, ctx: &ViewContext<'_>, + nodes: &[NodeId], documents: &[DocumentRow], 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 all: Vec<&crate::model::Node> = nodes.iter().filter_map(|&id| doc.node(id)).collect(); + let node = *all.first().ok_or_else(|| Message::problem("that node is gone"))?; + // What the picker's title says it's for: a title, or how many. + let what = if all.len() == 1 { node.title().to_string() } else { format!("{} nodes", all.len()) }; 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); @@ -326,7 +343,7 @@ impl Popup { } }) .collect(); - (format!("color · {}", node.title()), values, rows) + (format!("color · {what}"), values, rows) } PickKind::Border => { let mut values = vec![Pick::Border(None)]; @@ -343,7 +360,7 @@ impl Popup { rows.push(row(spans, choice.word, on)); values.push(Pick::Border(Some(kind))); } - (format!("border · {}", node.title()), values, rows) + (format!("border · {what}"), values, rows) } PickKind::Style => { let mut values = vec![Pick::Style(None)]; @@ -359,10 +376,16 @@ impl Popup { } }) .collect(); - (format!("style · {}", node.title()), values, rows) + (format!("style · {what}"), values, rows) } PickKind::Tags => { - tags = node.tags.clone(); + // Checked: the tags every node has. + tags = node + .tags + .iter() + .filter(|tag| all.iter().all(|other| other.tags.contains(tag))) + .cloned() + .collect(); // Every tag in the document, and the theme's. let mut all: Vec = doc .outline() @@ -377,7 +400,7 @@ impl Popup { .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) + (format!("tags · {what}"), all.into_iter().map(Pick::Tag).collect(), rows) } PickKind::Document => { let (mut values, mut rows) = (Vec::new(), Vec::new()); @@ -410,7 +433,111 @@ impl Popup { 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 })) + Ok(Popup::Pick(Picker { list, nodes: nodes.to_vec(), values, tags, group: Group::new() })) + } + + /// `:marks`: this document's marks, then the capital ones. Enter goes + /// to the mark. + pub fn marks(small: &[ListedMark], capital: &[ListedMark], theme: &Theme) -> Popup { + let muted = Style::new().fg(theme.muted); + let key = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD); + let (mut rows, mut runs) = (Vec::new(), Vec::new()); + for (heading, marks) in [("this document", small), ("across documents (A–Z)", capital)] { + if marks.is_empty() { + continue; + } + rows.push(ListRow::heading(heading, muted)); + runs.push(Run::Nothing); + for mark in marks { + let mut spans = + vec![Span::styled(format!("{} ", mark.name), key), Span::raw(mark.node.clone())]; + if let Some(document) = &mark.document { + spans.push(Span::styled(format!(" {document}"), muted)); + } + rows.push(ListRow::new(spans, format!("{} {}", mark.name, mark.node))); + runs.push(Run::Keys(vec![Key::char('\''), Key::char(mark.name)])); + } + } + if rows.is_empty() { + rows.push(ListRow::heading("no marks yet: m then a letter sets one", muted)); + runs.push(Run::Nothing); + } + let list = ListPopup::new( + "marks", + "Enter: go there · Esc: close", + rows, + Place::Center { width: CONFLICT_WIDTH }, + ); + Popup::Menu(Runs { list, runs }) + } + + /// `:jumps`: the pane's jump list, oldest first, with where `Ctrl-o` and + /// `Ctrl-i` are. Enter goes there. + pub fn jumps(places: &[(String, JumpPlace)], at: Option, theme: &Theme) -> Popup { + let muted = Style::new().fg(theme.muted); + let (mut rows, mut runs) = (Vec::new(), Vec::new()); + for (index, (text, place)) in places.iter().enumerate() { + let mut row = ListRow::new(vec![Span::raw(text.clone())], text.as_str()); + if Some(index) == at { + row = row.with_note(Span::styled("here", Style::new().fg(theme.accent))); + } + rows.push(row); + runs.push(Run::App(AppAction::GoToPlace(place.clone()))); + } + if rows.is_empty() { + rows.push(ListRow::heading("no jumps yet: gg, G, marks, / and gd are jumps", muted)); + runs.push(Run::Nothing); + } + let footer = "Enter: go there · Ctrl-o / Ctrl-i: back / forward"; + let mut list = ListPopup::new("jumps", footer, rows, Place::Center { width: CONFLICT_WIDTH }); + list.select(at.unwrap_or(places.len().saturating_sub(1))); + Popup::Menu(Runs { list, runs }) + } + + /// `:registers`: what each register holds. Enter pastes it after the + /// cursor's node. + pub fn registers(registers: &[(char, String)], theme: &Theme) -> Popup { + let key = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD); + let muted = Style::new().fg(theme.muted); + let (mut rows, mut runs) = (Vec::new(), Vec::new()); + let named = registers.iter().map(|(name, what)| (*name, what.clone())); + for (name, what) in named.chain(std::iter::once(('+', "the system clipboard".to_string()))) { + let style = if name == '+' { muted } else { Style::new() }; + let spans = vec![Span::styled(format!("\"{name} "), key), Span::styled(what.clone(), style)]; + rows.push(ListRow::new(spans, what)); + runs.push(Run::Keys(vec![Key::char('"'), Key::char(name), Key::char('p')])); + } + let footer = "Enter: paste it after the cursor's node · Esc: close"; + let list = ListPopup::new("registers", footer, rows, Place::Center { width: CONFLICT_WIDTH }); + Popup::Menu(Runs { list, runs }) + } + + /// `gd` on a node with several cross-links: the nodes they lead to (out, + /// then in), beside the node. Enter goes there. + pub fn links(ctx: &ViewContext<'_>, at: Position) -> Popup { + let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs); + let muted = Style::new().fg(theme.muted); + let mut links: Vec<(bool, NodeId, String)> = doc + .links_of(ctx.cursor) + .map(|link| { + let out = link.start() == ctx.cursor; + let other = if out { link.end() } else { link.start() }; + (out, other, theme.link_label(link).unwrap_or_default().to_string()) + }) + .collect(); + links.sort_by_key(|&(out, _, _)| !out); + let (mut rows, mut runs) = (Vec::new(), Vec::new()); + for (out, other, label) in links { + 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)); + } + rows.push(ListRow::new(spans, title)); + runs.push(Run::Jump(other)); + } + let list = ListPopup::new("follow a link", "Enter: go there", rows, Place::At(at)); + Popup::Menu(Runs { list, runs }) } /// The `K` card for a node. @@ -461,6 +588,7 @@ impl Popup { Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => "INSERT", Popup::Card(Card { edit: Some(_), .. }) => "TEXT", Popup::Card(_) => "CARD", + Popup::Search(_) => "SEARCH", } } @@ -471,6 +599,8 @@ impl Popup { Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => &[Context::Insert], Popup::Card(Card { edit: Some(_), .. }) => &[Context::Text], Popup::Card(_) => &[Context::Card], + // Every key goes straight into it. + Popup::Search(_) => &[], } } @@ -481,6 +611,7 @@ impl Popup { Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(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()), + Popup::Search(_) => true, } } @@ -508,6 +639,7 @@ impl Popup { let bound = keymap.tables(&[Context::Insert]).bound(&[key]); return card.edit.as_mut().map(|edit| edit.typing(key, bound, ctx)).unwrap_or_default(); } + Popup::Search(search) => return search.key(key, ctx), }; self.outcome(outcome, ctx) } @@ -523,6 +655,7 @@ impl Popup { Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => runs.list.command(typed), Popup::Pick(picker) => picker.list.command(typed), Popup::Card(card) => return card.command(typed, registers, ctx), + Popup::Search(_) => return Vec::new(), }; self.outcome(outcome, ctx) } @@ -533,6 +666,7 @@ impl Popup { Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => &mut runs.list, Popup::Pick(picker) => &mut picker.list, Popup::Card(card) => return card.mouse(gesture, ctx), + Popup::Search(search) => return search.mouse(gesture), }; let outcome = match gesture { Gesture::Press { at, .. } => list.click(position(at)), @@ -560,6 +694,7 @@ impl Popup { card.view.layout(screen, anchor, &text); } } + Popup::Search(search) => search.layout(screen), } } @@ -572,6 +707,7 @@ impl Popup { card.view.render(frame, &text, ctx.theme); } } + Popup::Search(search) => search.render(frame, ctx), } } @@ -607,6 +743,16 @@ const NODE_MENU: &[(&str, Command)] = &[ ("delete it with its branch", Command::Operate(Operator::Delete, Target::Current)), ]; +/// A mark, as `:marks` lists it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListedMark { + pub name: char, + /// The title of the node it's on. + pub node: String, + /// For a capital mark: its document. + pub document: Option, +} + /// A row of the help, before it's laid out. struct Entry { section: String, @@ -703,6 +849,7 @@ impl Runs { 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), + Run::Jump(id) => vec![Action::jump(*id)], Run::App(action) => vec![Action::App(action.clone())], }; let mut actions = leave(); @@ -712,15 +859,12 @@ impl Runs { } impl Picker { - /// Picks a row. A tag goes on or off and the picker stays open; the - /// rest close it. + /// Picks a row. A tag goes on or off (on every node) and the picker + /// stays open; the rest close it. Setting several nodes is one undo + /// step. 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() }, + let look = match self.values.get(index) { + None | Some(Pick::Heading) => return Vec::new(), Some(Pick::Document(at)) => { let mut actions = leave(); actions.push(Action::App(AppAction::SwitchDocument(DocumentChoice::At(*at)))); @@ -732,7 +876,7 @@ impl Picker { actions.push(Action::App(AppAction::Edit { path, force: false })); return actions; } - Some(Pick::Heading) => return Vec::new(), + // A tag goes on or off, and the picker stays open. Some(Pick::Tag(tag)) => { let tag = tag.clone(); let on = !self.tags.contains(&tag); @@ -742,15 +886,31 @@ impl Picker { 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 })]; + return self.set(Look::Tag { tag, on }); } + Some(Pick::Color(color)) => Look::Color(color.clone()), + Some(Pick::Border(border)) => Look::Border(*border), + Some(Pick::Style(style)) => Look::Style(style.clone()), }; let mut actions = leave(); - actions.push(Action::edit(op)); + actions.extend(self.set(look)); actions } + /// A look put on every node, and kept for `.`. + fn set(&self, look: Look) -> Vec { + let mut actions: Vec = self.nodes.iter().map(|&id| self.edit(look.op(id))).collect(); + actions.push(Action::App(AppAction::Picked(look))); + actions + } + + /// A change to one of the nodes: the changes to several are one undo + /// step. + fn edit(&self, op: EditOp) -> Action { + let group = (self.nodes.len() > 1).then_some(self.group); + Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group }) + } + /// 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(_))) { @@ -762,7 +922,7 @@ impl Picker { 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() })] + self.set(Look::Tag { tag, on: true }) } Err(error) => vec![Action::say(Message::problem(error.to_string()))], } diff --git a/src/app/popup/tests.rs b/src/app/popup/tests.rs index 1dfe447..be6277f 100644 --- a/src/app/popup/tests.rs +++ b/src/app/popup/tests.rs @@ -86,14 +86,18 @@ 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 pick = |kind| Popup::picker(kind, &ctx, &[g.soil], &[], 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 })] + [ + app(AppAction::LeaveMode), + Action::edit(EditOp::SetColor { id: g.soil, color: teal.clone() }), + app(AppAction::Picked(Look::Color(teal))), + ] ); let mut borders = pick(PickKind::Border); @@ -101,7 +105,11 @@ fn pickers_set_what_is_picked() { let dashed = Some(Border::Dashed); assert_eq!( enter(&mut borders, &ctx), - [app(AppAction::LeaveMode), Action::edit(EditOp::SetBorder { id: g.soil, border: dashed })] + [ + app(AppAction::LeaveMode), + Action::edit(EditOp::SetBorder { id: g.soil, border: dashed }), + app(AppAction::Picked(Look::Border(dashed))), + ] ); // The theme's own named styles. @@ -110,7 +118,11 @@ fn pickers_set_what_is_picked() { let important = StyleName::parse("important").ok(); assert_eq!( enter(&mut styles, &ctx), - [app(AppAction::LeaveMode), Action::edit(EditOp::SetStyle { id: g.soil, style: important })] + [ + app(AppAction::LeaveMode), + Action::edit(EditOp::SetStyle { id: g.soil, style: important.clone() }), + app(AppAction::Picked(Look::Style(important))), + ] ); // Tags go on and off, and the picker stays open; a new one is added. @@ -120,15 +132,19 @@ fn pickers_set_what_is_picked() { tags.command(&typed(Command::Move(crate::input::command::Motion::Last)), &mut Registers::default(), &ctx); 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 off = Action::edit(EditOp::Tag { id: g.soil, add: Vec::new(), remove: vec![wood.clone()] }); + let picked = |on| app(AppAction::Picked(Look::Tag { tag: wood.clone(), on })); 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]); + assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [on, picked(true)]); + assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [off, picked(false)]); 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() })] + [ + Action::edit(EditOp::Tag { id: g.soil, add: vec![spring.clone()], remove: Vec::new() }), + app(AppAction::Picked(Look::Tag { tag: spring, on: true })), + ] ); // Documents: it starts on the one shown; the next one is a j away, and @@ -136,7 +152,7 @@ fn pickers_set_what_is_picked() { let row = |name: &str, open| DocumentRow { name: name.into(), path: format!("/notes/{name}.cum").into(), open }; let documents = [row("Garden plans", true), row("Kitchen", true), row("Reading", false)]; - let mut picker = Popup::picker(PickKind::Document, &ctx, &documents, 0).unwrap(); + let mut picker = Popup::picker(PickKind::Document, &ctx, &[g.soil], &documents, 0).unwrap(); let down = typed(Command::Move(crate::input::command::Motion::Down)); picker.command(&down, &mut registers, &ctx); assert_eq!( diff --git a/src/app/registers.rs b/src/app/registers.rs index b9b0fb0..1a46095 100644 --- a/src/app/registers.rs +++ b/src/app/registers.rs @@ -1,46 +1,155 @@ -//! Registers, as in vim: where yanked and deleted text goes, and where `p` -//! takes it from. There's one set for the whole app, so text yanked in one -//! title can be pasted into another. +//! Registers, as in vim: where yanked and deleted things go, and where `p` +//! takes them from. There's one set for the whole app, so what's yanked in +//! one document or title can be pasted into another. +//! +//! A register holds text (from inside a title or a body) or nodes (from +//! `yy`, `dd`, `x`, or a selection). Either pastes anywhere: nodes pasted +//! into a text give their titles, and text pasted on nodes becomes nodes +//! (a node per line, or Markdown sections: see [`markdown`]). //! //! Everything yanked or deleted goes in the unnamed register, which `p` -//! uses when no register is named, and also in the one named with `"a`–`"z`. -//! `"_` keeps nothing. `"+` is kept inside cumulus for now: part 8c -//! connects it to the system clipboard. +//! uses when no register is named, and also in the one named with +//! `"a`–`"z`. `"_` keeps nothing. `"+` is the system clipboard: nodes go +//! there as Markdown, and while the clipboard still holds what cumulus put +//! there, pasting it gives back everything (colors, bodies, links, the +//! layout), not just what the text says. With `clipboard = "unnamedplus"`, +//! the unnamed register is the clipboard too, as with vim's setting. use std::collections::BTreeMap; +use super::clipboard::Clipboard; use crate::input::command::Register; +use crate::model::Clip; +use crate::storage::markdown; use crate::ui::text_editor::Yank; +/// What a register holds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Kept { + /// Text from inside a title or a body. + Text(Yank), + /// Nodes, with their shape. + Nodes(Clip), +} + +impl Kept { + /// As text, to paste inside a title or a body: nodes give their titles, + /// a line each. + pub fn text(&self) -> Yank { + match self { + Kept::Text(yank) => yank.clone(), + Kept::Nodes(clip) => { + let titles: Vec = + clip.nodes().iter().map(|clipped| clipped.node.title().to_string()).collect(); + Yank { text: titles.join("\n"), linewise: true } + } + } + } + + /// As nodes, to paste on nodes: text becomes a node per line (or per + /// Markdown section). `None` for text with nothing in it. + pub fn clip(&self) -> Option { + match self { + Kept::Text(yank) => markdown::read(&yank.text), + Kept::Nodes(clip) => Some(clip.clone()), + } + } + + /// What goes on the system clipboard: nodes as Markdown. + fn clipboard_text(&self) -> String { + match self { + Kept::Text(yank) => yank.text.clone(), + Kept::Nodes(clip) => markdown::write(clip), + } + } + + /// What it holds, in a few words: `3 nodes`, `"Soil"`. + pub fn describe(&self) -> String { + match self { + Kept::Nodes(clip) if clip.count() == 1 => "1 node".to_string(), + Kept::Nodes(clip) => format!("{} nodes", clip.count()), + Kept::Text(yank) => format!("\"{}\"", yank.text.lines().next().unwrap_or_default()), + } + } +} + /// The registers' contents. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Default)] pub struct Registers { - unnamed: Option, - named: BTreeMap, - clipboard: Option, + unnamed: Option, + named: BTreeMap, + clipboard: Clipboard, + /// What cumulus put on the clipboard last, and the text it put there. + copied: Option<(String, Kept)>, + /// `clipboard = "unnamedplus"`: plain yanks, deletes and pastes use the + /// system clipboard too. + pub unnamed_plus: bool, } impl Registers { - /// Keeps text that was yanked or deleted into `register` (or none). - pub fn store(&mut self, register: Option, yank: Yank) { - match register { - Some(Register::BlackHole) => return, + /// Keeps what was yanked or deleted in `register` (or the unnamed one). + /// It's kept in cumulus even when the clipboard can't be written; the + /// error says why, for `"+` (with `unnamedplus`, cumulus quietly makes + /// do without it, as there may be no clipboard at all, over ssh). + pub fn store(&mut self, register: Option, kept: Kept) -> Result<(), String> { + let copied = match register { + Some(Register::BlackHole) => return Ok(()), Some(Register::Named(name)) => { - self.named.insert(name, yank.clone()); + self.named.insert(name, kept.clone()); + Ok(()) } - Some(Register::Clipboard) => self.clipboard = Some(yank.clone()), - Some(Register::Unnamed) | None => {} - } - self.unnamed = Some(yank); + Some(Register::Clipboard) => self.copy(&kept), + Some(Register::Unnamed) | None if self.unnamed_plus => { + let _ = self.copy(&kept); + Ok(()) + } + Some(Register::Unnamed) | None => Ok(()), + }; + self.unnamed = Some(kept); + copied } - /// What a register holds; the unnamed one when none is named. - pub fn get(&self, register: Option) -> Option<&Yank> { + /// What a register holds (the unnamed one when none is named), or why + /// the clipboard can't be read. + pub fn get(&mut self, register: Option) -> Result, String> { match register { - None | Some(Register::Unnamed) => self.unnamed.as_ref(), - Some(Register::Named(name)) => self.named.get(&name), - Some(Register::Clipboard) => self.clipboard.as_ref(), - Some(Register::BlackHole) => None, + None | Some(Register::Unnamed) if self.unnamed_plus => { + Ok(self.paste().ok().or_else(|| self.unnamed.clone())) + } + None | Some(Register::Unnamed) => Ok(self.unnamed.clone()), + Some(Register::Named(name)) => Ok(self.named.get(&name).cloned()), + Some(Register::Clipboard) => self.paste().map(Some), + Some(Register::BlackHole) => Ok(None), + } + } + + /// The registers that hold something, for `:registers`: `"`, then `a` + /// to `z`. (The clipboard isn't read for this.) + pub fn list(&self) -> Vec<(char, &Kept)> { + let unnamed = self.unnamed.iter().map(|kept| ('"', kept)); + unnamed.chain(self.named.iter().map(|(&name, kept)| (name, kept))).collect() + } + + /// The system clipboard (tests put text there as another app would). + #[cfg(test)] + pub fn clipboard(&mut self) -> &mut Clipboard { + &mut self.clipboard + } + + fn copy(&mut self, kept: &Kept) -> Result<(), String> { + let text = kept.clipboard_text(); + self.clipboard.write(&text)?; + self.copied = Some((text, kept.clone())); + Ok(()) + } + + /// What the clipboard holds: what cumulus put there, if it's still + /// there, or else its text. + fn paste(&mut self) -> Result { + let text = self.clipboard.read()?; + match &self.copied { + Some((copied, kept)) if *copied == text => Ok(kept.clone()), + _ => Ok(Kept::Text(Yank { linewise: text.ends_with('\n'), text })), } } } @@ -49,15 +158,37 @@ impl Registers { mod tests { use super::*; + fn text(text: &str) -> Kept { + Kept::Text(Yank { text: text.into(), linewise: false }) + } + #[test] fn named_registers_and_the_black_hole() { - let yank = |text: &str| Yank { text: text.into(), linewise: false }; let mut registers = Registers::default(); - registers.store(Some(Register::Named('a')), yank("cedar")); - registers.store(None, yank("soil")); - registers.store(Some(Register::BlackHole), yank("gone")); - assert_eq!(registers.get(Some(Register::Named('a'))), Some(&yank("cedar"))); - assert_eq!(registers.get(None), Some(&yank("soil")), "the black hole keeps nothing"); - assert_eq!(registers.get(Some(Register::Named('b'))), None); + registers.store(Some(Register::Named('a')), text("cedar")).unwrap(); + registers.store(None, text("soil")).unwrap(); + registers.store(Some(Register::BlackHole), text("gone")).unwrap(); + assert_eq!(registers.get(Some(Register::Named('a'))), Ok(Some(text("cedar")))); + assert_eq!(registers.get(None), Ok(Some(text("soil"))), "the black hole keeps nothing"); + assert_eq!(registers.get(Some(Register::Named('b'))), Ok(None)); + } + + #[test] + fn the_clipboard_gives_back_what_cumulus_put_there() { + let mut registers = Registers::default(); + assert!(registers.get(Some(Register::Clipboard)).is_err(), "nothing there yet"); + let clip = markdown::read("## Soil\n---\n- todo\n---\nLoamy.").unwrap(); + registers.store(Some(Register::Clipboard), Kept::Nodes(clip.clone())).unwrap(); + assert_eq!(registers.clipboard().read().unwrap(), "## Soil\n---\n- todo\n---\nLoamy.\n"); + assert_eq!(registers.get(Some(Register::Clipboard)), Ok(Some(Kept::Nodes(clip)))); + // Another app copies something: that's text. + registers.clipboard().write("mulch").unwrap(); + assert_eq!(registers.get(Some(Register::Clipboard)), Ok(Some(text("mulch")))); + // unnamedplus: plain yanks and pastes go through the clipboard. + registers.unnamed_plus = true; + registers.store(None, text("straw")).unwrap(); + assert_eq!(registers.clipboard().read().unwrap(), "straw"); + registers.clipboard().write("hay").unwrap(); + assert_eq!(registers.get(None), Ok(Some(text("hay")))); } } diff --git a/src/app/render.rs b/src/app/render.rs index d5e622c..a19910a 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -11,7 +11,7 @@ use ratatui::style::{Style, Stylize}; use ratatui::text::Span; use ratatui::widgets::Block; -use super::action::{Message, Tone}; +use super::action::{self, Message, Tone}; use super::mode::{Grab, Mode}; use super::popup::{Card, Popup}; use super::text_edit::{Field, TextEdit}; @@ -110,15 +110,23 @@ impl App { let focus = if self.shared.view.focus { " · focus" } else { "" }; 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(), + // In visual mode, how many nodes are selected takes the breadcrumb's + // place. + let selected = self.selection().map(|selected| format!("{} selected", action::nodes(selected.len()))); + let place = match (&self.message, selected) { + (None, Some(selected)) => Span::raw(selected), + (None, 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 status = StatusBar { mode: mode.label(), - mode_color: if typing { theme.insert } else { theme.accent }, + mode_color: match mode { + _ if typing => theme.insert, + Mode::Visual(_) => theme.visual, + _ => theme.accent, + }, view, place, keys: self.keys.shown(), @@ -171,15 +179,31 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str { (Mode::Text(edit), _) | (Mode::Popup(Popup::Card(Card { edit: Some(edit), .. })), _) => { text_hints(edit) } + (Mode::Visual(_), ViewKind::Canvas | ViewKind::Board) => { + "hjkl more · o other end · d delete · y yank · > < in/out · M move · c color · Esc stop" + } + (Mode::Visual(_), ViewKind::Note) => { + "h/l the siblings too · o other end · d delete · y yank · > < in/out · c color · Esc stop" + } + (Mode::Visual(_), _) => { + "j/k more · o other end · d delete · y yank · > < in/out · c color · Esc stop" + } (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, nodes, .. }), _) if !nodes.is_empty() => { + "hjkl moves them with their branches (HJKL by 5) · M just them · Enter done" + } + (Mode::Move(Grab { nodes, .. }), _) if !nodes.is_empty() => { + "hjkl moves just the selected nodes (HJKL by 5) · M with their branches · 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(Popup::Search(_)), _) => "type to search · ↑ ↓ or Tab move · Enter go there · Esc close", (Mode::Popup(_), _) => "j/k move · / search · Enter pick · Esc close", } } diff --git a/src/app/repeat.rs b/src/app/repeat.rs new file mode 100644 index 0000000..b4fdd22 --- /dev/null +++ b/src/app/repeat.rs @@ -0,0 +1,164 @@ +//! `.` on nodes: the last change again, the way vim's `.` works. A change +//! is a key command that changed the document (`dd`, `p`, `>>`, `zc`…), +//! with everything typed after it when it started a text edit (`aMulch` +//! then Esc), or a value picked in a picker (a color, a border, a style, a +//! tag on or off). A change made on a selection repeats on the cursor's +//! node: `d` as `x` (or `dd` for `V`), `>` as `>>`. + +use super::App; +use super::action::{Action, Message}; +use super::mode::Mode; +use crate::input::command::{Command, Operator, Target}; +use crate::input::engine::Typed; +use crate::input::key::Key; +use crate::model::{Border, ColorName, EditOp, NodeId, StateId, StyleName, Tag}; + +/// A change `.` can make again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Repeat { + /// A key command, as typed (its count and register too), and the keys + /// typed after it while the text edit it started went on. + Keys { typed: Typed, then: Vec }, + /// A value picked for a node. + Look(Look), +} + +/// A value a picker sets on a node, for `.`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Look { + Color(Option), + Border(Option), + Style(Option), + Tag { tag: Tag, on: bool }, +} + +impl Look { + /// The change on a node. + pub fn op(&self, id: NodeId) -> EditOp { + match self { + Look::Color(color) => EditOp::SetColor { id, color: color.clone() }, + Look::Border(border) => EditOp::SetBorder { id, border: *border }, + Look::Style(style) => EditOp::SetStyle { id, style: style.clone() }, + Look::Tag { tag, on: true } => EditOp::Tag { id, add: vec![tag.clone()], remove: Vec::new() }, + Look::Tag { tag, on: false } => EditOp::Tag { id, add: Vec::new(), remove: vec![tag.clone()] }, + } + } +} + +/// A text edit started by a key command, while it goes on: what `.` will +/// repeat once it's done. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Recording { + typed: Typed, + then: Vec, + /// The document's history when it started, to tell whether it changed + /// anything. + before: StateId, +} + +impl App { + /// A key command typed in normal, visual or move mode: carried out, and + /// kept for `.` when it changed the document. + pub(super) fn run_typed(&mut self, typed: Typed) { + if typed.command == Command::Repeat { + return self.repeat(typed.count); + } + if self.repeating { + return self.dispatch(typed); + } + // Only changes made in normal and visual mode count; for a + // selection, whether it took whole branches. + let (normal, branches) = match &self.shared.mode { + Mode::Normal => (true, false), + Mode::Visual(visual) => (false, visual.branches), + _ => return self.dispatch(typed), + }; + let (buffer, before) = (self.buffer(), self.current().history().state()); + self.dispatch(typed); + let changed = self.buffer() == buffer && self.current().history().state() != before; + match &self.shared.mode { + // It started a text edit: the keys typed in it go with it. + Mode::Text(_) if normal => { + self.recording = Some(Recording { typed, then: Vec::new(), before }); + } + Mode::Normal if changed && !matches!(typed.command, Command::Undo | Command::Redo) => { + let typed = if normal { Some(typed) } else { on_the_cursor(typed, branches) }; + if let Some(typed) = typed { + self.last_change = Some(Repeat::Keys { typed, then: Vec::new() }); + } + } + _ => {} + } + } + + /// Before each key: it joins the text edit being recorded, if any. (An + /// edit a click ended is over first, without it.) + pub(super) fn record(&mut self, key: Key) { + self.end_recording(); + if let Some(recording) = &mut self.recording { + recording.then.push(key); + } + } + + /// After each key: a recorded edit that's over becomes the change `.` + /// repeats, if it changed anything. + pub(super) fn end_recording(&mut self) { + match &self.shared.mode { + Mode::Text(_) => {} + Mode::Normal => { + if let Some(Recording { typed, then, before }) = self.recording.take() + && self.current().history().state() != before + { + self.last_change = Some(Repeat::Keys { typed, then }); + } + } + _ => self.recording = None, + } + } + + /// A value picked in a picker, for `.`. + pub(super) fn picked(&mut self, look: Look) { + if !self.repeating { + self.last_change = Some(Repeat::Look(look)); + } + } + + /// `.`: the last change again, with a new count if one is typed. + fn repeat(&mut self, count: Option) { + let Some(change) = self.last_change.clone() else { + self.message = Some(Message::info("no change to repeat yet")); + return; + }; + self.repeating = true; + match change { + Repeat::Keys { typed, then } => { + self.dispatch(Typed { count: count.or(typed.count), ..typed }); + for key in then { + self.key(key); + } + } + Repeat::Look(look) => { + let op = look.op(self.current().cursor()); + self.update(vec![Action::edit(op)]); + } + } + self.repeating = false; + } +} + +/// A change made on a selection, as `.` makes it again on the cursor's +/// node: `d` as `x` (`dd` for whole branches), `>` and `<` as `>>` and +/// `<<`. +fn on_the_cursor(typed: Typed, branches: bool) -> Option { + let command = match typed.command { + Command::Operate(Operator::Delete, Target::Selection) if branches => { + Command::Operate(Operator::Delete, Target::Current) + } + Command::Operate(Operator::Delete, Target::Selection) => Command::DeleteNode, + Command::Operate(operator @ (Operator::Indent | Operator::Outdent), Target::Selection) => { + Command::Operate(operator, Target::Current) + } + _ => return None, + }; + Some(Typed { command, count: None, ..typed }) +} diff --git a/src/app/search.rs b/src/app/search.rs new file mode 100644 index 0000000..151e36f --- /dev/null +++ b/src/app/search.rs @@ -0,0 +1,431 @@ +//! `/`: finding a node, fuzzily, in its title, its tags or its body. A +//! popup in the middle of the screen, the way Telescope does it in neovim +//! (the user's pick in part 8c): what you type at the top, the matches +//! below it, best first, and on the right the note of the one you're on. +//! Enter goes there (a jump: `Ctrl-o` comes back), Esc closes; `n` and `N` +//! then go through the same matches, and `:find` searches straight away. +//! +//! ```text +//! ╭ search · Garden plans ────────────────────────────────────────╮ +//! │ /compo │ ask about compost │ +//! │▌ask about compost │ no tags ● pink │ +//! │ Soil · Loamy, with comp… │ │ +//! │ │ The neighbor has a bin. │ +//! ╰ 1 of 2 · ↑↓ move · Enter go there · Esc close ────────────────╯ +//! ``` +//! +//! Case only matters once the search has a capital in it, as with vim's +//! `smartcase`. + +use ratatui::Frame; +use ratatui::layout::{Position, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear}; + +use super::App; +use super::action::{Action, AppAction, DocAction, Message}; +use super::mode::Mode; +use crate::command::complete::fuzzy_match; +use crate::input::key::{Key, KeyName}; +use crate::input::mouse::{Cell, Gesture}; +use crate::model::{Content, Direction, Document, NodeId}; +use crate::ui::text_input::TextInput; +use crate::view::note::NoteView; +use crate::view::{View, ViewContext}; + +/// A title match counts for more than a match in the tags, and those more +/// than one in the body. +const TITLE_BONUS: i32 = 64; +const TAGS_BONUS: i32 = 32; +/// The widest the popup gets. +const MOST_WIDTH: u16 = 120; +/// The tallest it gets. +const MOST_HEIGHT: u16 = 30; +/// Narrower than this, the note isn't shown beside the matches. +const PREVIEW_FROM: u16 = 60; + +/// A node a search found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Found { + pub id: NodeId, + score: i32, + /// Where it matched. + pub place: Where, + /// Which characters of that text matched, to show them. + letters: Vec, +} + +/// Where in a node a search matched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Where { + Title, + Tags, + /// This line of the body. + Body(String), +} + +impl Where { + /// For the status bar: `in the body`. + fn words(&self) -> &'static str { + match self { + Where::Title => "in the title", + Where::Tags => "in the tags", + Where::Body(_) => "in the body", + } + } +} + +/// The nodes that match `query`, best first. An empty query matches every +/// node, in reading order. +pub fn find(doc: &Document, query: &str) -> Vec { + let ignore_case = !query.chars().any(char::is_uppercase); + let mut found: Vec<(usize, Found)> = doc + .outline() + .into_iter() + .enumerate() + .filter_map(|(order, id)| { + let node = doc.node(id)?; + if query.is_empty() { + return Some((order, Found { id, score: 0, place: Where::Title, letters: Vec::new() })); + } + let title = fuzzy_match(query, node.title().as_str(), ignore_case) + .map(|(score, letters)| (score + TITLE_BONUS, Where::Title, letters)); + let tags = (!node.tags.is_empty()) + .then(|| fuzzy_match(query, &node.tags.to_string(), ignore_case)) + .flatten() + .map(|(score, letters)| (score + TAGS_BONUS, Where::Tags, letters)); + let body = match &node.content { + Content::Idea(idea) => idea + .body + .as_str() + .lines() + .filter_map(|line| { + let (score, letters) = fuzzy_match(query, line, ignore_case)?; + Some((score, Where::Body(line.to_string()), letters)) + }) + .max_by_key(|(score, _, _)| *score), + Content::Portal(_) => None, + }; + let (score, place, letters) = + [title, tags, body].into_iter().flatten().max_by_key(|(score, _, _)| *score)?; + Some((order, Found { id, score, place, letters })) + }) + .collect(); + found.sort_by_key(|(order, found)| (-found.score, *order)); + found.into_iter().map(|(_, found)| found).collect() +} + +/// The search popup. +#[derive(Debug, Clone)] +pub struct Search { + input: TextInput, + found: Vec, + /// The match you're on, among `found`. + selected: usize, + /// The first match on show. + scroll: usize, + /// Where it was drawn last: the box, and the rows of matches (clicks + /// land on these). + area: Rect, + rows: Rect, +} + +impl Search { + /// The search, with nothing typed yet: every node, in reading order. + pub fn new(doc: &Document) -> Search { + Search { + input: TextInput::default(), + found: find(doc, ""), + selected: 0, + scroll: 0, + area: Rect::default(), + rows: Rect::default(), + } + } + + /// A key: typed into the search, or one that moves through the matches, + /// goes to one, or closes. + pub fn key(&mut self, key: Key, ctx: &ViewContext<'_>) -> Vec { + let page = isize::try_from(self.rows.height.max(2) / 2).unwrap_or(1); + match (key.name, key.ctrl) { + (KeyName::Esc, _) | (KeyName::Char('c'), true) => return leave(), + (KeyName::Enter, _) => return self.go(), + (KeyName::Down | KeyName::Tab, _) | (KeyName::Char('n'), true) => self.move_by(1), + (KeyName::Up | KeyName::BackTab, _) | (KeyName::Char('p'), true) => self.move_by(-1), + (KeyName::PageDown, _) => self.move_by(page), + (KeyName::PageUp, _) => self.move_by(-page), + _ => { + if self.input.edit(key) { + self.found = find(ctx.doc, self.input.text()); + (self.selected, self.scroll) = (0, 0); + } + } + } + Vec::new() + } + + /// A mouse gesture: a click on a match goes there, a click outside + /// closes, the wheel moves through the matches. + pub fn mouse(&mut self, gesture: Gesture) -> Vec { + match gesture { + Gesture::Press { at, .. } => { + let at = position(at); + if !self.area.contains(at) { + return leave(); + } + if self.rows.contains(at) { + let row = self.scroll + usize::from(at.y - self.rows.y); + if row < self.found.len() { + self.selected = row; + return self.go(); + } + } + Vec::new() + } + Gesture::Scroll { direction: Direction::Down, .. } => { + self.move_by(3); + Vec::new() + } + Gesture::Scroll { direction: Direction::Up, .. } => { + self.move_by(-3); + Vec::new() + } + _ => Vec::new(), + } + } + + /// Enter: to the match you're on. A search with text in it is kept for + /// `n` and `N`. + fn go(&self) -> Vec { + let Some(found) = self.found.get(self.selected) else { return leave() }; + let mut actions = leave(); + actions.push(Action::jump(found.id)); + let query = self.input.text(); + if !query.is_empty() { + actions.push(Action::App(AppAction::Searched(query.to_string()))); + let (at, count) = (self.selected + 1, self.found.len()); + actions.push(Action::say(Message::info(format!( + "/{query} · {at} of {count} · {}", + found.place.words() + )))); + } + actions + } + + fn move_by(&mut self, delta: isize) { + let last = self.found.len().saturating_sub(1); + self.selected = self.selected.saturating_add_signed(delta).min(last); + } + + /// Works out where it goes on `screen`, and scrolls to keep the match + /// you're on in view. + pub fn layout(&mut self, screen: Rect) { + let width = screen.width.saturating_sub(4).min(MOST_WIDTH).max(20.min(screen.width)); + let height = screen.height.saturating_sub(2).min(MOST_HEIGHT).max(5.min(screen.height)); + self.area = Rect { + x: screen.x + (screen.width - width) / 2, + y: screen.y + (screen.height - height) / 2, + width, + height, + }; + let inner = inner(self.area); + let (list, _) = columns(inner); + // The search is the first row; the matches are below it. + self.rows = Rect { y: list.y + 1, height: list.height.saturating_sub(1), ..list }; + let shown = usize::from(self.rows.height).max(1); + if self.selected < self.scroll { + self.scroll = self.selected; + } else if self.selected >= self.scroll + shown { + self.scroll = self.selected + 1 - shown; + } + } + + /// Draws it where [`Search::layout`] put it, with the note of the match + /// you're on beside the matches. + pub fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) { + let theme = ctx.theme; + let (accent, muted) = (Style::new().fg(theme.accent), Style::new().fg(theme.muted)); + frame.render_widget(Clear, self.area); + let footer = if self.found.is_empty() { + " nothing matches · Esc close ".to_string() + } else { + format!(" {} of {} · ↑↓ move · Enter go there · Esc close ", self.selected + 1, self.found.len()) + }; + let block = Block::bordered() + .border_set(theme.glyphs.frame(true)) + .border_style(muted) + .title(Line::styled( + format!(" search · {} ", ctx.doc.title()), + accent.add_modifier(Modifier::BOLD), + )) + .title_bottom(Line::styled(footer, muted)); + frame.render_widget(block, self.area); + let inner = inner(self.area); + let (list, preview) = columns(inner); + let buf = frame.buffer_mut(); + let prompt = Line::from(vec![Span::styled("/", accent), Span::raw(self.input.text().to_string())]); + buf.set_line(list.x + 1, list.y, &prompt, list.width.saturating_sub(1)); + let bar = theme.glyphs.part_mark().to_string(); + let shown = self.found.iter().enumerate().skip(self.scroll).take(usize::from(self.rows.height)); + for ((index, found), y) in shown.zip(self.rows.y..) { + if index == self.selected { + buf.set_string(list.x, y, &bar, accent); + } + let line = row(found, ctx, accent.add_modifier(Modifier::BOLD), muted); + buf.set_line(list.x + 1, y, &line, list.width.saturating_sub(1)); + } + if let Some(preview) = preview { + let line = theme.glyphs.frame(false).vertical_left; + for y in inner.y..inner.bottom() { + buf.set_string(preview.x - 1, y, line, muted); + } + if let Some(found) = self.found.get(self.selected) { + let ctx = ViewContext { cursor: found.id, mode: &Mode::Normal, ..*ctx }; + let mut note = NoteView::preview(); + note.layout(preview, &ctx); + note.render(frame, &ctx); + } + } + let col = u16::try_from(1 + 1 + self.input.cursor_column()).unwrap_or(u16::MAX); + frame.set_cursor_position(Position::new((list.x + col).min(list.right().saturating_sub(1)), list.y)); + } +} + +/// A match's row: the title, with the letters that matched standing out, +/// then the tags or the line of the body it matched in. +fn row(found: &Found, ctx: &ViewContext<'_>, lit: Style, muted: Style) -> Line<'static> { + let Some(node) = ctx.doc.node(found.id) else { return Line::default() }; + let marked = |text: &str, letters: &[usize], style: Style| -> Vec> { + text.chars() + .enumerate() + .map(|(at, c)| Span::styled(c.to_string(), if letters.contains(&at) { lit } else { style })) + .collect() + }; + let title = node.title().to_string(); + let mut spans = match found.place { + Where::Title => marked(&title, &found.letters, Style::new()), + _ => vec![Span::raw(title)], + }; + match &found.place { + Where::Title if !node.tags.is_empty() => spans.push(Span::styled(format!(" {}", node.tags), muted)), + Where::Title => {} + Where::Tags => { + spans.push(Span::raw(" ")); + spans.extend(marked(&node.tags.to_string(), &found.letters, muted)); + } + Where::Body(line) => { + spans.push(Span::styled(" · ", muted)); + spans.extend(marked(line.trim_end(), &found.letters, muted)); + } + } + Line::from(spans) +} + +/// Inside the border. +fn inner(area: Rect) -> Rect { + Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + } +} + +/// The matches' column, and the note's beside it (with a line between), if +/// there's room for it. +fn columns(inner: Rect) -> (Rect, Option) { + if inner.width < PREVIEW_FROM { + return (inner, None); + } + let list = inner.width * 2 / 5; + let preview = Rect { x: inner.x + list + 1, width: inner.width - list - 1, ..inner }; + (Rect { width: list, ..inner }, Some(preview)) +} + +fn leave() -> Vec { + vec![Action::App(AppAction::LeaveMode)] +} + +fn position(cell: Cell) -> Position { + Position::new(cell.col, cell.row) +} + +impl App { + /// `n` and `N`: the next (or previous) match of the last search, `times` + /// over, going round. Off the matches, `n` goes to the best one. + pub(super) fn find_again(&mut self, forward: bool, times: u32) -> Option { + let Some(query) = self.last_search.clone() else { + return Some(Message::info("no search yet: / starts one")); + }; + let found = find(self.current().doc(), &query); + let count = found.len(); + if count == 0 { + return Some(Message::problem(format!("nothing matches /{query}"))); + } + let cursor = self.current().cursor(); + let mut at = found.iter().position(|found| found.id == cursor); + let mut wrapped = false; + for _ in 0..times.max(1) { + at = Some(match (at, forward) { + // Not on a match: n goes to the best one, N to the last. + (None, true) => 0, + (None, false) => count - 1, + (Some(last), true) if last + 1 == count => { + wrapped = true; + 0 + } + (Some(0), false) => { + wrapped = true; + count - 1 + } + (Some(here), true) => here + 1, + (Some(here), false) => here - 1, + }); + } + let at = at.unwrap_or(0); + let said = self.doc_action(DocAction::MoveCursor { to: found[at].id, jump: true }); + let place = found[at].place.words(); + let again = match (wrapped, forward) { + (false, _) => "", + (true, true) => " · back to the best match", + (true, false) => " · round to the last match", + }; + said.or_else(|| Some(Message::info(format!("/{query} · {} of {count} · {place}{again}", at + 1)))) + } + + /// `:find`: straight to the best match, kept for `n` and `N`. + pub(super) fn find_now(&mut self, query: String) -> Option { + let found = find(self.current().doc(), &query); + let Some(best) = found.first() else { + return Some(Message::problem(format!("nothing matches /{query}"))); + }; + let (id, place, count) = (best.id, best.place.words(), found.len()); + self.last_search = Some(query.clone()); + let said = self.doc_action(DocAction::MoveCursor { to: id, jump: true }); + said.or_else(|| Some(Message::info(format!("/{query} · 1 of {count} · {place}")))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::garden; + use crate::model::{Body, EditOp, Tags}; + + #[test] + fn titles_tags_and_bodies_match_fuzzily_best_first() { + let (mut doc, g) = garden(); + doc.apply(&EditOp::SetBody { id: g.soil, body: Body::new("Loamy,\nwith compost.") }).unwrap(); + doc.apply(&EditOp::SetTags { id: g.beds, tags: Tags::parse("#compost").unwrap() }).unwrap(); + let found = find(&doc, "compost"); + let ids: Vec = found.iter().map(|found| found.id).collect(); + assert_eq!(ids, [g.compost, g.beds, g.soil], "the title first, then the tags, then the body"); + assert_eq!(found[2].place, Where::Body("with compost.".into())); + // Fuzzy: the letters in order, anywhere. + assert_eq!(find(&doc, "rsbd")[0].id, g.beds); + // Smartcase: a capital makes case matter. + assert!(find(&doc, "Compost").iter().all(|found| found.id != g.compost)); + assert_eq!(find(&doc, "").len(), doc.node_count(), "nothing typed: every node"); + } +} diff --git a/src/app/tests.rs b/src/app/tests.rs index 2c9b4b8..987c320 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -5,6 +5,7 @@ mod config; mod files; +mod vim; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -303,7 +304,7 @@ fn command_lines_run_through_the_same_actions() { 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"); + assert_eq!(h.message(), "/drip · 1 of 1 · in the title"); h.type_keys(":set completion_popup=on-tab"); assert_eq!(h.app.settings.completion_popup, PopupMode::OnTab); h.type_keys(":frobnicate"); @@ -502,10 +503,10 @@ fn indenting_and_outdenting_nodes() { #[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 part of the build"); - h.type_keys("v"); - assert_eq!(h.message(), "\"select\" comes in a later part of the build"); + h.type_keys("C"); + assert_eq!(h.message(), "\"connect mode\" comes in a later part of the build"); + h.type_keys("p"); + assert_eq!(h.message(), "\"new portal…\" comes in a later part of the build"); // Kitchen is a portal: its title is the other document's name. h.type_keys("glgjgjgj"); assert_eq!(h.cursor(), "Kitchen"); diff --git a/src/app/tests/vim.rs b/src/app/tests/vim.rs new file mode 100644 index 0000000..ab4af1b --- /dev/null +++ b/src/app/tests/vim.rs @@ -0,0 +1,410 @@ +//! The rest of vim (part 8c), end to end: node registers and the +//! clipboard, visual mode, `.` on nodes, search, the jump list, marks and +//! `gd`. + +use ratatui::style::Color; + +use super::*; + +/// The titles of the root's children, in order. +fn top(h: &Harness) -> Vec { + h.children("Garden plans") +} + +#[test] +fn yank_and_paste_nodes() { + let mut h = Harness::new(); + // yy on Raised beds: the node and its branch. + h.type_keys("glgjyy"); + assert_eq!(h.message(), "yanked 3 nodes"); + // p on Watering: the copy goes after it, with its children. + h.type_keys("gjp"); + assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Raised beds", "Kitchen"]); + let copy = h.doc().children(h.node("Garden plans"))[3]; + assert_eq!(h.app.current().cursor(), copy, "the cursor goes to what was pasted"); + let titles: Vec = h.doc().children(copy).iter().map(|&id| h.title(id)).collect(); + assert_eq!(titles, ["Cedar, untreated", "40 cm high"]); + assert_eq!(h.doc().node(copy).unwrap().look.color.as_ref().map(|c| c.as_str()), Some("orange")); + // ]p: as the last children; 2p: two copies. + h.type_keys("gg]p"); + assert_eq!(top(&h).len(), 6); + h.type_keys("u2p"); + assert_eq!(h.message(), "pasted 6 nodes"); + assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Raised beds", "Kitchen"]); + assert_eq!(h.doc().loose().len(), 4, "after the root: loose nodes, before the two there were"); + h.type_keys("uu"); + assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Kitchen"], "each paste is one undo step"); +} + +#[test] +fn deletes_keep_what_they_take() { + let mut h = Harness::new(); + // dd then p: the branch moves. + h.type_keys("glgjdd"); + assert_eq!(h.cursor(), "Watering"); + h.type_keys("p"); + assert_eq!(top(&h), ["Soil", "Watering", "Raised beds", "Kitchen"]); + assert_eq!(h.children("Raised beds"), ["Cedar, untreated", "40 cm high"]); + // x keeps just the node: its children stay behind. + h.type_keys("x"); + assert_eq!(top(&h), ["Soil", "Watering", "Cedar, untreated", "40 cm high", "Kitchen"]); + h.type_keys("ggP"); + assert_eq!(h.message(), "nothing goes before the root"); + h.type_keys("glP"); + assert_eq!(top(&h)[0], "Raised beds", "P: before Soil"); + assert!(h.children("Raised beds").is_empty()); + // Named registers, and the black hole. + h.type_keys("gl\"ayy"); + assert_eq!(h.message(), "yanked 1 node into \"a"); + h.type_keys("gj\"_dd"); + assert_eq!(h.message(), "deleted 1 node · u to undo"); + h.type_keys("gg]p"); + assert_eq!( + h.children("Garden plans").last().map(String::as_str), + Some("Raised beds"), + "\"_ kept nothing" + ); + h.type_keys("gg\"a]p"); + assert_eq!(h.children("Garden plans").iter().filter(|title| *title == "Raised beds").count(), 3); +} + +#[test] +fn the_clipboard_holds_markdown() { + let mut h = Harness::new(); + h.type_keys("glgj\"+yy"); + assert_eq!(h.message(), "copied 3 nodes to the clipboard"); + let text = h.app.registers.clipboard().read().unwrap(); + assert_eq!(text, "## Raised beds\n\n### Cedar, untreated\n\n### 40 cm high\n"); + // Back from the clipboard: everything, the color too. + h.type_keys("G\"+p"); + let pasted = h.app.current().cursor(); + assert_eq!(h.doc().node(pasted).unwrap().look.color.as_ref().map(|c| c.as_str()), Some("orange")); + // Text another app put there: a node per section, tags and body too. + h.app.registers.clipboard().write("## Mulch\n---\n- todo\n---\nStraw or leaves.\n\n### Straw\n").unwrap(); + h.type_keys("gg\"+]p"); + assert_eq!(h.cursor(), "Mulch"); + assert_eq!((h.tags("Mulch").as_str(), h.body("Mulch").as_str()), ("#todo", "Straw or leaves.")); + assert_eq!(h.children("Mulch"), ["Straw"]); + // A plain list makes nodes too. + h.app.registers.clipboard().write("- Bucket\n - Hose\n").unwrap(); + h.type_keys("\"+p"); + assert_eq!(h.children("Bucket"), ["Hose"]); +} + +#[test] +fn text_and_nodes_paste_into_each_other() { + let mut h = Harness::new(); + // A word yanked in a title, pasted on nodes: a node. + h.type_keys("gliyiwggp"); + assert_eq!(h.cursor(), "Soil"); + assert_eq!(h.doc().loose().len(), 3, "after the root: a loose node"); + // Nodes pasted into a title: their titles. + h.type_keys("gg]pu"); + h.type_keys("glgjyyggi p"); + assert_eq!(h.draft(), "Garden plans Raised beds Cedar, untreated 40 cm high"); +} + +#[test] +fn unnamedplus_uses_the_clipboard_for_everything() { + let mut h = Harness::new(); + h.type_keys(":set clipboard=unnamedplus"); + assert_eq!(h.message(), "clipboard = unnamedplus"); + h.type_keys("glyy"); + assert_eq!(h.app.registers.clipboard().read().unwrap(), "## Soil\n---\n- todo\n---\n"); + h.app.registers.clipboard().write("Compost").unwrap(); + h.type_keys("p"); + assert_eq!(h.cursor(), "Compost"); +} + +#[test] +fn visual_mode_takes_in_a_rectangle() { + let mut h = Harness::new(); + // v on Soil, then down twice: Soil, Raised beds and Watering. + h.type_keys("glvjj"); + assert_eq!((h.label(), h.cursor().as_str()), ("VISUAL", "Watering")); + assert!(h.rows()[h.rows().len() - 2].contains("3 nodes selected"), "{}", h.screen()); + let (col, row) = h.find("Raised beds"); + assert_eq!(h.rows()[usize::from(row) - 1].chars().nth(usize::from(col)), Some('━'), "thick"); + assert_eq!(h.color_at((col - 2, row)), Color::Magenta, "in the visual color"); + let (col, row) = h.find("Kitchen"); + assert_ne!(h.color_at((col - 2, row)), Color::Magenta, "Kitchen is below the rectangle"); + // d: exactly those go; the children that weren't selected move up. + h.type_keys("d"); + assert_eq!(h.label(), "NORMAL"); + assert_eq!(h.message(), "deleted 3 nodes · u to undo"); + assert_eq!(top(&h), ["Cedar, untreated", "40 cm high", "Rain barrel", "Drip line", "Kitchen"]); + h.type_keys("u"); + assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Kitchen"]); + // V: with their branches. + h.type_keys("glVjj"); + assert_eq!(h.label(), "VISUAL BRANCH"); + h.type_keys("d"); + assert_eq!(top(&h), ["Kitchen"]); + assert_eq!(h.message(), "deleted 8 nodes · u to undo"); + // What went is in the register: ]p puts it back under the root, where + // the cursor went. + assert_eq!(h.cursor(), "Garden plans"); + h.type_keys("]p"); + assert_eq!(top(&h), ["Kitchen", "Soil", "Raised beds", "Watering"]); +} + +#[test] +fn visual_mode_switches_swaps_and_stops() { + let mut h = Harness::new(); + h.type_keys("glvjV"); + assert_eq!(h.label(), "VISUAL BRANCH", "V in v: whole branches"); + h.type_keys("v"); + assert_eq!(h.label(), "VISUAL", "and back"); + h.type_keys("o"); + assert_eq!(h.cursor(), "Soil", "o: the other end"); + h.type_keys("v"); + assert_eq!(h.label(), "NORMAL", "v again: stop"); + h.type_keys("vj"); + assert_eq!(h.label(), "NORMAL"); + h.type_keys("vjy"); + assert_eq!((h.label(), h.message()), ("NORMAL", "yanked 2 nodes")); +} + +#[test] +fn operators_and_pickers_act_on_the_whole_selection() { + let mut h = Harness::new(); + // > puts them under the node above the first. + h.type_keys("glgjvj>"); + assert_eq!(h.children("Soil"), ["Raised beds", "Watering"]); + h.type_keys("u"); + assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Kitchen"]); + // A color for all three, as one undo step. + h.type_keys("glvjjc/teal"); + let color = |h: &Harness, title: &str| { + h.doc().node(h.node(title)).unwrap().look.color.as_ref().map(|c| c.as_str().to_string()) + }; + for title in ["Soil", "Raised beds", "Watering"] { + assert_eq!(color(&h, title).as_deref(), Some("teal"), "{title}"); + } + h.type_keys("u"); + assert_eq!(color(&h, "Soil"), None); + assert_eq!(color(&h, "Raised beds").as_deref(), Some("orange")); + // Tags: on every one of them. + h.type_keys("glvjt/wood"); + assert_eq!((h.tags("Soil").as_str(), h.tags("Raised beds").as_str()), ("#todo #wood", "#wood")); + // M moves them all. + h.type_keys("ggglvjMll"); + assert_eq!((h.pos("Soil"), h.pos("Raised beds")), (CanvasPos::new(24, 2), CanvasPos::new(24, 7))); + assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(45, 5), "with their branches"); +} + +#[test] +fn visual_mode_in_the_outline_takes_rows() { + let mut h = Harness::new(); + h.type_keys("vojvjj"); + assert_eq!(h.label(), "VISUAL"); + // Soil, Raised beds and its first child: the rows in between. + assert!(h.rows()[h.rows().len() - 2].contains("3 nodes selected"), "{}", h.screen()); + h.type_keys("d"); + assert_eq!(top(&h), ["40 cm high", "Watering", "Kitchen"]); +} + +#[test] +fn ctrl_click_and_ctrl_drag_select() { + let mut h = Harness::new(); + h.type_keys("gl"); + let watering = h.find("Watering"); + h.mouse(Gesture::Press { at: Cell { col: watering.0, row: watering.1 }, ctrl: true }); + assert_eq!(h.label(), "VISUAL"); + assert!(h.screen().contains("2 nodes selected")); + // Again on Soil: out of the selection. + let soil = h.find("Soil"); + h.mouse(Gesture::Press { at: Cell { col: soil.0, row: soil.1 }, ctrl: true }); + assert!(h.screen().contains("1 node selected")); + // A plain click ends the selection. + h.click(soil); + assert_eq!(h.label(), "NORMAL"); + // A Ctrl+drag from empty space around Soil and Raised beds. + let beds = h.find("Raised beds"); + let (from, to) = (Cell { col: soil.0 - 4, row: soil.1 - 1 }, Cell { col: beds.0 + 2, row: beds.1 }); + h.mouse(Gesture::Press { at: from, ctrl: true }); + h.mouse(Gesture::Drag { from, at: to, ctrl: true }); + assert!(h.screen().contains('┐'), "the rectangle shows while dragging"); + h.mouse(Gesture::Release { at: to, dragged: true }); + assert_eq!((h.label(), h.cursor().as_str()), ("VISUAL", "Soil")); + assert!(h.screen().contains("2 nodes selected"), "{}", h.screen()); +} + +#[test] +fn dot_repeats_the_last_change_on_nodes() { + let mut h = Harness::new(); + // dd, then . on the next one. + h.type_keys("gldd"); + assert_eq!(h.cursor(), "Raised beds"); + h.type_keys("."); + assert_eq!(top(&h), ["Watering", "Kitchen"]); + h.type_keys("uu"); + // An idea typed after a, with its title: . adds it again. + h.type_keys("ggaMulchgg."); + assert_eq!(h.children("Garden plans").iter().filter(|title| *title == "Mulch").count(), 2); + // A title edit: . types it again on another node. + h.type_keys("ggglA!gj."); + assert_eq!(h.cursor(), "Raised beds!"); + // >> with a count, and the count of . taking its place. + h.type_keys("gg"); + h.type_keys("glgj>>"); + assert_eq!(h.parent("Raised beds!"), "Soil!"); + // A color picked: . puts it on the cursor's node. + h.type_keys("c/tealgg."); + let root = h.doc().node(h.node("Garden plans")).unwrap(); + assert_eq!(root.look.color.as_ref().map(|c| c.as_str()), Some("teal")); + // A change on a selection repeats on the cursor's node. + h.type_keys("glvjdgl"); + let before = h.doc().node_count(); + h.type_keys("."); + assert_eq!(h.doc().node_count(), before - 1, "v then d: . deletes one node, as x does"); + h.type_keys("u"); + assert_eq!(h.message(), ""); +} + +#[test] +fn search_finds_nodes_fuzzily_and_shows_their_note() { + let mut h = Harness::new(); + h.type_keys("/"); + assert_eq!(h.label(), "SEARCH"); + assert!(h.screen().contains("search · Garden plans"), "{}", h.screen()); + // The kitchen's compost bin isn't in this document; the garden's + // compost note is, by its title, and so is its body. + h.type_keys("bucket"); + let screen = h.screen(); + assert!(screen.contains("ask about compost · Bring a bucket."), "the body line it matched in:\n{screen}"); + assert!(screen.contains("The neighbor has a bin."), "the note of the match, beside it:\n{screen}"); + h.type_keys(""); + assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "ask about compost")); + assert_eq!(h.message(), "/bucket · 1 of 1 · in the body"); + // Esc goes back to where you were. + h.type_keys("gg/rain"); + assert_eq!(h.cursor(), "Garden plans"); + // Fuzzy: w, t and r in that order, best first. + h.type_keys("/wtr"); + assert!(h.screen().contains("1 of"), "{}", h.screen()); + h.type_keys(""); + assert_eq!(h.cursor(), "Watering"); + // n and N go through the same matches, going round. + h.type_keys(":find e"); + let first = h.cursor(); + h.type_keys("n"); + assert_ne!(h.cursor(), first); + h.type_keys("N"); + assert_eq!(h.cursor(), first); + h.type_keys("N"); + assert!(h.message().ends_with("round to the last match"), "{}", h.message()); + h.type_keys("n"); + assert!(h.message().ends_with("back to the best match"), "{}", h.message()); + assert_eq!(h.cursor(), first); +} + +#[test] +fn the_jump_list_goes_back_and_forward() { + let mut h = Harness::new(); + // G and gg are jumps; Ctrl-o goes back, Tab (or Ctrl-i) forward. + h.type_keys("G"); + let last = h.cursor(); + h.type_keys(":find soil"); + assert_eq!(h.cursor(), "Soil"); + h.type_keys(""); + assert_eq!(h.cursor(), last); + h.type_keys(""); + assert_eq!(h.cursor(), "Garden plans"); + h.type_keys(""); + assert_eq!(h.message(), "no older jump: that's where the jump list starts"); + h.type_keys("2"); + assert_eq!(h.cursor(), "Soil"); + h.type_keys(""); + assert_eq!(h.message(), "no newer jump: Ctrl-o goes back"); + // :jumps lists them; Enter goes to one. + h.type_keys(":jumps"); + assert_eq!(h.label(), "MENU"); + assert!(h.screen().contains("Garden plans › Soil"), "{}", h.screen()); +} + +#[test] +fn jumps_cross_documents_and_open_closed_ones() { + let mut h = Harness::new(); + // gt is a jump too; in the kitchen, G is one. + h.type_keys("gtG"); + assert_eq!(h.cursor(), "Compost bin"); + h.type_keys(":bd"); + assert_eq!(h.app.shared.docs.count(), 1); + // Ctrl-o: back to the kitchen, opened again from its file. + h.type_keys(""); + assert!(h.screen().contains("canvas · Kitchen"), "{}", h.screen()); + assert_eq!(h.cursor(), "Kitchen"); + // Where Ctrl-o started is now ahead, as in vim: Tab goes there. + h.type_keys(""); + assert!(h.screen().contains("canvas · Garden plans")); +} + +#[test] +fn capital_marks_lead_into_any_document_and_last() { + let mut h = Harness::new(); + h.type_keys("glmA"); + assert_eq!(h.message(), "mark A is on \"Soil\" in Garden plans: 'A comes back here from any document"); + h.type_keys("gjma"); + h.type_keys("gt'A"); + assert!(h.screen().contains("canvas · Garden plans")); + assert_eq!(h.cursor(), "Soil"); + h.type_keys(""); + assert!(h.screen().contains("canvas · Kitchen"), "'A was a jump"); + h.type_keys(":marks"); + let screen = h.screen(); + assert!(screen.contains("A Soil Garden plans"), "{screen}"); + h.type_keys(""); + // Both kinds last until the next run. + h.app.remember().unwrap(); + let Harness { app, folder, .. } = h; + drop(app); + let mut h = Harness::open(folder, &["kitchen.cum"]); + h.type_keys("'A"); + assert_eq!((h.app.shared.docs.count(), h.cursor().as_str()), (2, "Soil"), "the garden, opened for it"); + h.type_keys("gg'a"); + assert_eq!(h.cursor(), "Raised beds", "a small mark, kept with its document"); +} + +#[test] +fn gd_follows_a_cross_link() { + let mut h = Harness::new(); + // Seeds to order links to the compost note ("needs"). + h.type_keys(":find seedsgd"); + assert_eq!(h.cursor(), "ask about compost"); + h.type_keys(""); + assert_eq!(h.cursor(), "Seeds to order", "a jump: Ctrl-o comes back"); + h.type_keys("gg"); + h.type_keys("gd"); + assert_eq!(h.message(), "this node has no cross-links to follow"); + // With two links, a list to pick from. + h.type_keys(":find compost:link soilgd"); + assert_eq!(h.label(), "MENU"); + let screen = h.screen(); + assert!(screen.contains("→ Soil") && screen.contains("← Seeds to order needs"), "{screen}"); + h.type_keys(""); + assert_eq!(h.cursor(), "Soil"); +} + +#[test] +fn registers_list_and_paste() { + let mut h = Harness::new(); + h.type_keys("gl\"ayygj\"byy:registers"); + let screen = h.screen(); + assert!(screen.contains("\"a 1 node") && screen.contains("\"b 3 nodes"), "{screen}"); + assert!(screen.contains("\"+ the system clipboard")); + // Enter on "a: pasted after the cursor's node. + h.type_keys("j"); + assert_eq!(top(&h), ["Soil", "Raised beds", "Soil", "Watering", "Kitchen"]); +} + +#[test] +fn an_edit_ended_by_a_click_repeats_without_the_next_key() { + let mut h = Harness::new(); + h.type_keys("glA!"); + let watering = h.find("Watering"); + h.click(watering); + assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "Watering")); + h.type_keys("gk."); + assert_eq!(h.cursor(), "Raised beds!", "just the ! again, not the gk"); +} diff --git a/src/app/text_edit.rs b/src/app/text_edit.rs index 4abd717..2cad21e 100644 --- a/src/app/text_edit.rs +++ b/src/app/text_edit.rs @@ -10,7 +10,7 @@ use super::action::{Action, AppAction, CursorAfter, DocAction, Message}; use super::commands; -use super::registers::Registers; +use super::registers::{Kept, Registers}; use crate::input::command::{Bound, Command, Operator, Target}; use crate::input::engine::Typed; use crate::input::key::Key; @@ -211,11 +211,22 @@ impl TextEdit { } _ => {} } - let paste = registers.get(typed.register).cloned(); + // Only a paste reads the register: the clipboard is read just then. + let paste = match typed.command { + Command::PasteAfter | Command::PasteBefore => match registers.get(typed.register) { + Ok(kept) => kept.map(|kept| kept.text()), + Err(problem) => return vec![Action::say(Message::problem(problem))], + }, + _ => None, + }; let reply = self.editor.command(typed, paste.as_ref()); let mut actions = self.follow(ctx); match reply { - Reply::Yanked(yank) => registers.store(typed.register, yank), + Reply::Yanked(yank) => { + if let Err(problem) = registers.store(typed.register, Kept::Text(yank)) { + actions.push(Action::say(Message::problem(problem))); + } + } Reply::Refused(why) => actions.push(Action::say(Message::info(why))), Reply::Done | Reply::NotMine => {} } diff --git a/src/app/update.rs b/src/app/update.rs index 52f3c2e..c5b7544 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -7,9 +7,10 @@ use ratatui::layout::Position; use super::action::{Action, AppAction, DocAction, DocumentChoice, Message, Tone}; use super::documents::Trouble; -use super::mode::{Grab, Mode}; +use super::mode::{Grab, Mode, Visual}; use super::panes::Arrange; use super::popup::{DocumentRow, MenuFor, Popup}; +use super::search::Search; use super::text_edit::{StartEdit, TextEdit}; use super::{App, Pane}; use crate::ui::command_line::CommandLine; @@ -38,16 +39,20 @@ impl App { } /// A document action, on the focused pane's document. A read-only - /// document takes cursor moves and marks, but no changes. - fn doc_action(&mut self, action: DocAction) -> Option { + /// document takes cursor moves and marks, but no changes. A jump that + /// moves the cursor goes in the pane's jump list. + pub(super) fn doc_action(&mut self, action: DocAction) -> Option { if matches!(action, DocAction::Edit { .. } | DocAction::Undo(_) | DocAction::Redo(_)) && let Some(refused) = self.refuse_read_only() { return Some(refused); } + let jump = matches!(action, DocAction::MoveCursor { jump: true, .. } | DocAction::JumpToMark(_)); + let from = self.place_here(); let open = self.shared.docs.get_mut(self.buffer()); let before = open.buffer.history().state(); let message = open.buffer.apply(action); + let moved = open.buffer.cursor() != from.node; if open.buffer.history().state() != before { open.file.changed = Some(Instant::now()); // A save that failed is tried again after the next change. @@ -55,6 +60,9 @@ impl App { open.file.trouble = None; } } + if jump && moved { + self.jumping(from); + } message } @@ -82,12 +90,29 @@ impl App { edit.editor.set_cursor(at); } } - AppAction::EnterMoveMode => { + AppAction::EnterMoveMode(nodes) => { if let Some(refused) = self.refuse_read_only() { return Some(refused); } - self.shared.mode = Mode::Move(Grab::new()); + let grab = if nodes.is_empty() { Grab::new() } else { Grab::nodes(nodes) }; + self.shared.mode = Mode::Move(grab); } + AppAction::Visual { branches } => { + let cursor = self.current().cursor(); + match &mut self.shared.mode { + Mode::Visual(visual) => visual.branches = branches, + mode => *mode = Mode::Visual(Visual::new(cursor, branches)), + } + } + AppAction::SwapEnds => { + let cursor = self.current().cursor(); + if let Mode::Visual(visual) = &mut self.shared.mode { + let anchor = std::mem::replace(&mut visual.anchor, cursor); + return self.doc_action(DocAction::MoveCursor { to: anchor, jump: false }); + } + } + AppAction::ToggleSelected(id) => self.toggle_selected(id), + AppAction::SelectAlso(ids) => return self.select_also(ids), AppAction::ToggleWhole => { if let Mode::Move(grab) = &mut self.shared.mode { grab.whole = !grab.whole; @@ -107,6 +132,9 @@ impl App { AppAction::RunLine(text) => self.run_line(&text), AppAction::Run(command) => self.run_command(command), AppAction::Type(keys) => keys.into_iter().for_each(|key| self.key(key)), + AppAction::Yank { register, nodes, branches } => return self.yank(register, &nodes, branches), + AppAction::Paste { register, at, times } => return self.paste(register, at, times), + AppAction::Picked(look) => self.picked(look), AppAction::ToggleFocus => { let focus = &mut self.shared.view.focus; *focus = !*focus; @@ -131,10 +159,26 @@ impl App { self.shared.mode = Mode::Popup(help); } AppAction::OpenCard => self.shared.mode = Mode::Popup(Popup::card(self.current().cursor())), + AppAction::OpenSearch => { + self.shared.mode = Mode::Popup(Popup::Search(Search::new(self.current().doc()))); + } + AppAction::Searched(query) => self.last_search = Some(query), + AppAction::FindAgain { forward, times } => return self.find_again(forward, times), + AppAction::Travel { back, times } => return self.travel(back, times), + AppAction::SetFileMark(name) => return self.set_file_mark(name), + AppAction::JumpToFileMark(name) => return self.jump_to_file_mark(name), + AppAction::GoToPlace(place) => return self.jump_to_place(&place), + AppAction::OpenMarks => self.open_marks(), + AppAction::OpenJumps => self.open_jumps(), + AppAction::OpenRegisters => self.open_registers(), + AppAction::OpenLinks => self.open_links(), + AppAction::Find(query) => return self.find_now(query), AppAction::OpenPicker(kind) => { let documents = self.document_rows(); let current = self.shared.docs.index_of(self.buffer()); - match Popup::picker(kind, &self.shared.ctx(self.buffer()), &documents, current) { + // In visual mode, the picker sets every selected node. + let nodes = self.selection().unwrap_or_else(|| vec![self.current().cursor()]); + match Popup::picker(kind, &self.shared.ctx(self.buffer()), &nodes, &documents, current) { Ok(picker) => self.shared.mode = Mode::Popup(picker), Err(message) => return Some(message), } @@ -179,7 +223,7 @@ impl App { let pane = self.panes.current_mut(); if pane.view.kind() != kind { let camera = self.shared.docs.get(pane.buffer).camera; - *pane = Pane::new(pane.buffer, kind, camera); + pane.show(pane.buffer, kind, camera); } } AppAction::SwitchDocument(choice) => return self.switch_document(choice), @@ -219,7 +263,9 @@ impl App { } let buffer = pane.buffer; let camera = pane.view.camera().or(self.shared.docs.get(buffer).camera); - self.panes.split(arrange, Pane::new(buffer, kind, camera)); + // The new pane starts with a copy of this one's jump list, as in vim. + let jumps = pane.jumps.clone(); + self.panes.split(arrange, Pane { jumps, ..Pane::new(buffer, kind, camera) }); None } diff --git a/src/app/visual.rs b/src/app/visual.rs new file mode 100644 index 0000000..5b81b66 --- /dev/null +++ b/src/app/visual.rs @@ -0,0 +1,252 @@ +//! Visual mode: selecting nodes, then acting on all of them at once. +//! +//! `v` starts a selection at the cursor's node, and moving the cursor +//! takes in what lies between: on the canvas, every node on show that the +//! rectangle from one end's box to the other's touches; in the outline and +//! the Note view, the nodes in between in reading order. `V` takes each +//! node with its whole branch. A Ctrl+click adds or takes out one node, a +//! Ctrl+drag on the canvas adds every node its rectangle touches. +//! +//! The operators take exactly what's selected (the user's pick in part +//! 8c): `d` deletes the selected nodes, and with `v` the children that +//! weren't selected move up, as `x` does; `y` yanks them, with their shape +//! among them; `>` and `<` move them through the tree; `M` moves them on +//! the canvas; the pickers set every one of them. + +use std::collections::BTreeSet; + +use super::action::{Action, AppAction, CursorAfter, DocAction, Message}; +use super::mode::{Mode, Visual}; +use super::{App, commands}; +use crate::input::command::{Command, Operator, Register, Target}; +use crate::input::engine::Typed; +use crate::model::{CanvasPos, CanvasRect, CanvasSize, EditOp, NodeId, Visible}; +use crate::view::{ViewContext, ViewKind}; + +/// The nodes selected in a view of this kind, in reading order. +pub fn selection(visual: &Visual, ctx: &ViewContext<'_>, kind: ViewKind) -> Vec { + let doc = ctx.doc; + let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus); + let mut chosen: BTreeSet = match kind { + ViewKind::Canvas | ViewKind::Board => { + let rects = ctx.box_rects(); + let ends: Vec = + [visual.anchor, ctx.cursor].iter().filter_map(|id| rects.get(id).copied()).collect(); + match span(&ends) { + Some(area) => visible + .order + .iter() + .copied() + .filter(|id| rects.get(id).is_some_and(|rect| rect.overlaps(area))) + .collect(), + None => BTreeSet::from([ctx.cursor]), + } + } + ViewKind::Outline | ViewKind::Note => { + let order = doc.outline(); + let at = |id: NodeId| order.iter().position(|&other| other == id); + let (from, to) = match (at(visual.anchor), at(ctx.cursor)) { + (Some(a), Some(b)) => (a.min(b), a.max(b)), + (None, Some(b)) => (b, b), + _ => (0, 0), + }; + order[from..=to.min(order.len().saturating_sub(1))] + .iter() + .copied() + .filter(|&id| visible.contains(id) || id == visual.anchor || id == ctx.cursor) + .collect() + } + }; + chosen.extend(visual.added.iter().copied().filter(|&id| doc.contains(id))); + chosen.retain(|id| !visual.removed.contains(id) && doc.contains(*id)); + if visual.branches { + let below: Vec = chosen.iter().flat_map(|&id| doc.descendants(id)).collect(); + chosen.extend(below); + } + doc.outline().into_iter().filter(|id| chosen.contains(id)).collect() +} + +impl App { + /// The selected nodes, in visual mode, as the focused view takes them. + pub(super) fn selection(&self) -> Option> { + let Mode::Visual(visual) = &self.shared.mode else { return None }; + let pane = self.panes.current(); + Some(selection(visual, &self.shared.ctx(pane.buffer), pane.view.kind())) + } + + /// A Ctrl+click: the node goes into the selection, or out of it. In + /// normal mode, a selection starts with the cursor's node and that one. + pub(super) fn toggle_selected(&mut self, id: NodeId) { + let cursor = self.current().cursor(); + let selected = self.selection().is_some_and(|selected| selected.contains(&id)); + match &mut self.shared.mode { + Mode::Visual(visual) if selected => { + visual.added.remove(&id); + visual.removed.insert(id); + } + Mode::Visual(visual) => { + visual.removed.remove(&id); + visual.added.insert(id); + } + Mode::Normal => { + let mut visual = Visual::new(cursor, false); + visual.added.insert(id); + self.shared.mode = Mode::Visual(visual); + } + _ => {} + } + } + + /// A Ctrl+drag's rectangle: its nodes go into the selection. In normal + /// mode, a selection of just them starts, the cursor on the first. + pub(super) fn select_also(&mut self, ids: Vec) -> Option { + let &first = ids.first()?; + if let Mode::Visual(visual) = &mut self.shared.mode { + for id in &ids { + visual.removed.remove(id); + } + visual.added.extend(ids); + return None; + } + let said = self.doc_action(DocAction::MoveCursor { to: first, jump: false }); + let mut visual = Visual::new(first, false); + visual.added.extend(ids); + self.shared.mode = Mode::Visual(visual); + said + } +} + +/// The smallest rectangle around some boxes. +fn span(rects: &[CanvasRect]) -> Option { + let left = rects.iter().map(|rect| rect.left()).min()?; + let top = rects.iter().map(|rect| rect.top()).min()?; + let right = rects.iter().map(|rect| rect.right()).max()?; + let bottom = rects.iter().map(|rect| rect.bottom()).max()?; + let size = |from: i32, to: i32| u16::try_from(to - from).unwrap_or(u16::MAX); + Some(CanvasRect::new(CanvasPos::new(left, top), CanvasSize::new(size(left, right), size(top, bottom)))) +} + +/// A command in visual mode, or `None` for the ones that mean the same as +/// in normal mode (moving, scrolling, marks…). +pub fn command( + typed: &Typed, + visual: &Visual, + selected: &[NodeId], + kind: ViewKind, + ctx: &ViewContext<'_>, +) -> Option> { + let leave = Action::App(AppAction::LeaveMode); + let actions = match typed.command { + // `v` in a `V` selection makes it a `v` one, and `v` again stops, as + // in vim; the same for `V`. + Command::Visual if visual.branches => app(AppAction::Visual { branches: false }), + Command::VisualBranches if !visual.branches => app(AppAction::Visual { branches: true }), + Command::Visual | Command::VisualBranches => vec![leave], + Command::SwapEnds => app(AppAction::SwapEnds), + Command::Operate(Operator::Delete, Target::Selection) => { + delete(visual, selected, typed.register, ctx) + } + Command::Operate(Operator::Yank, Target::Selection) => vec![ + leave, + Action::App(AppAction::Yank { + register: typed.register, + nodes: selected.to_vec(), + branches: false, + }), + ], + Command::Operate(operator @ (Operator::Indent | Operator::Outdent), Target::Selection) => { + let tops = tops(selected, ctx); + let parent = tops.first().and_then(|&first| ctx.doc.parent(first)); + if tops.iter().any(|&top| ctx.doc.parent(top) != parent) { + return Some(say("> and < move nodes that share a parent: these don't")); + } + let mut actions = vec![leave]; + actions.extend(if operator == Operator::Indent { + commands::indent(ctx, &tops, None) + } else { + commands::outdent(ctx, &tops, None) + }); + actions + } + Command::MoveMode if kind == ViewKind::Canvas => app(AppAction::EnterMoveMode(selected.to_vec())), + Command::MoveMode => { + say("several nodes move on the canvas; here, > and < move them through the tree") + } + Command::ConnectMode => say("connect mode comes in part 8d of the build"), + _ => return None, + }; + Some(actions) +} + +/// `d` on a selection: exactly the selected nodes go (the root stays). With +/// `v`, the children that weren't selected move up, as with `x`; with `V` +/// the branches go whole. What's deleted goes in the register. +fn delete( + visual: &Visual, + selected: &[NodeId], + register: Option, + ctx: &ViewContext<'_>, +) -> Vec { + let doc = ctx.doc; + let nodes: Vec = selected.iter().copied().filter(|&id| id != doc.root()).collect(); + if nodes.is_empty() { + return say("the root can't be deleted"); + } + // The cursor goes to the last node before them that stays. + let gone: BTreeSet = nodes.iter().copied().collect(); + let order = doc.outline(); + let first = order.iter().position(|id| gone.contains(id)).unwrap_or(0); + let stays = order[..first].iter().rev().find(|id| !gone.contains(id)).copied(); + let op = if visual.branches { + EditOp::RemoveBranches(nodes.clone()) + } else { + EditOp::RemoveKeepingChildren(nodes.clone()) + }; + vec![ + Action::App(AppAction::LeaveMode), + Action::App(AppAction::Yank { register, nodes, branches: false }), + Action::edit_then(op, stays.map_or(CursorAfter::Stay, CursorAfter::To)), + ] +} + +/// The selected nodes that aren't under another selected node. +fn tops(selected: &[NodeId], ctx: &ViewContext<'_>) -> Vec { + let set: BTreeSet = selected.iter().copied().collect(); + selected + .iter() + .copied() + .filter(|&id| !ctx.doc.ancestors(id).any(|ancestor| set.contains(&ancestor))) + .collect() +} + +fn app(action: AppAction) -> Vec { + vec![Action::App(action)] +} + +fn say(text: &str) -> Vec { + vec![Action::say(Message::info(text))] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::garden; + use crate::view::test_support::Setup; + + #[test] + fn the_canvas_takes_the_rectangle_the_outline_the_rows() { + let ((doc, g), setup) = (garden(), Setup::default()); + // Soil (22,0) to Watering (22,8): Raised beds (22,4) is between; + // Cedar (40,4) is off to the right. + let visual = Visual::new(g.soil, false); + let ctx = setup.ctx(&doc, g.watering); + assert_eq!(selection(&visual, &ctx, ViewKind::Canvas), [g.soil, g.beds, g.watering]); + // In reading order, Cedar is between Raised beds and Watering. + assert_eq!(selection(&visual, &ctx, ViewKind::Outline), [g.soil, g.beds, g.cedar, g.watering]); + // V: with their branches; Ctrl+click picks: added and taken out. + let mut visual = Visual::new(g.soil, true); + visual.removed.insert(g.watering); + visual.added.insert(g.compost); + assert_eq!(selection(&visual, &ctx, ViewKind::Canvas), [g.soil, g.beds, g.cedar, g.compost]); + } +} diff --git a/src/app/yank.rs b/src/app/yank.rs new file mode 100644 index 0000000..cff6629 --- /dev/null +++ b/src/app/yank.rs @@ -0,0 +1,64 @@ +//! Nodes into registers and back: `yy` and the deletes keep nodes in a +//! register, `p` `P` `]p` paste them (or a register's text, made into +//! nodes). See [`registers`](super::registers). + +use std::collections::BTreeSet; + +use super::App; +use super::action::{self, CursorAfter, DocAction, Message, PasteAt}; +use super::commands; +use super::registers::Kept; +use crate::input::command::Register; +use crate::model::{Clip, NodeId}; + +impl App { + /// Keeps nodes of the focused document in a register: exactly `nodes`, + /// or (`branches`) each with its whole branch. + pub(super) fn yank( + &mut self, + register: Option, + nodes: &[NodeId], + branches: bool, + ) -> Option { + let doc = self.current().doc(); + let clip = if branches { + Clip::branches(doc, nodes) + } else { + Clip::take(doc, &nodes.iter().copied().collect::>()) + }; + let count = action::nodes(clip.count()); + if let Err(problem) = self.registers.store(register, Kept::Nodes(clip)) { + return Some(Message::problem(problem)); + } + Some(Message::done(match register { + Some(Register::Clipboard) => format!("copied {count} to the clipboard"), + Some(Register::Named(name)) => format!("yanked {count} into \"{name}"), + Some(Register::BlackHole) => return None, + Some(Register::Unnamed) | None => format!("yanked {count}"), + })) + } + + /// Pastes what a register holds around the cursor's node, `times` over. + pub(super) fn paste(&mut self, register: Option, at: PasteAt, times: u32) -> Option { + if let Some(refused) = self.refuse_read_only() { + return Some(refused); + } + let kept = match self.registers.get(register) { + Ok(Some(kept)) => kept, + Ok(None) => return Some(Message::info("nothing to paste yet: yy or dd keeps nodes to paste")), + Err(problem) => return Some(Message::problem(problem)), + }; + let Some(mut clip) = kept.clip() else { + return Some(Message::info("the register's text is empty: no nodes to make")); + }; + let ctx = self.shared.ctx(self.buffer()); + if !clip.placed { + clip.lay_out(|node| ctx.box_size(node)); + } + let count = clip.count() * usize::try_from(times.max(1)).unwrap_or(1); + let op = commands::paste(&ctx, clip, at, times)?; + let done = + self.doc_action(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Added, group: None }); + done.or_else(|| (count > 1).then(|| Message::done(format!("pasted {}", action::nodes(count))))) + } +} diff --git a/src/command/complete.rs b/src/command/complete.rs index cf894ea..e574c0b 100644 --- a/src/command/complete.rs +++ b/src/command/complete.rs @@ -189,13 +189,46 @@ fn starts_with_ignoring_case(text: &str, start: &str) -> bool { /// in `text`, in order, ignoring case. Matches at the start of a word and /// matches in a row score higher, so "gpl" picks "Garden plans". pub fn fuzzy_score(query: &str, text: &str) -> Option { - let mut wanted = query.chars().flat_map(char::to_lowercase).peekable(); + fuzzy_match(query, text, true).map(|(score, _)| score) +} + +/// How well `query` matches `text` (see [`fuzzy_score`]), minding case or +/// not, and which of its characters matched (their places among its +/// characters), to show them. +pub fn fuzzy_match(query: &str, text: &str, ignore_case: bool) -> Option<(i32, Vec)> { + let wanted: Vec = if ignore_case { + query.chars().flat_map(char::to_lowercase).collect() + } else { + query.chars().collect() + }; + let chars: Vec = text.chars().collect(); + let same = |c: char, wanted: char| { + if ignore_case { c.to_lowercase().eq(std::iter::once(wanted)) } else { c == wanted } + }; + let Some(&first) = wanted.first() else { return Some((-(chars.len() as i32), Vec::new())) }; + // Each place the first character shows up is tried as the start: "bin" + // in "neighbor has a bin" is best found at its own b. + (0..chars.len()) + .filter(|&start| same(chars[start], first)) + .filter_map(|start| match_from(&chars, start, &wanted, same)) + .max_by_key(|(score, found)| (*score, std::cmp::Reverse(found.first().copied()))) +} + +/// [`fuzzy_match`], with the first character matched at `start`. +fn match_from( + chars: &[char], + start: usize, + wanted: &[char], + same: impl Fn(char, char) -> bool, +) -> Option<(i32, Vec)> { + let mut wanted = wanted.iter().copied().peekable(); let mut score = 0; - let mut previous: Option = None; + let mut found = Vec::new(); + let mut previous: Option = start.checked_sub(1).map(|before| chars[before]); let mut previous_matched = false; - for c in text.chars() { + for (at, &c) in chars.iter().enumerate().skip(start) { let Some(&next) = wanted.peek() else { break }; - let matched = c.to_lowercase().eq(std::iter::once(next)); + let matched = same(c, next); if matched { score += 1; if previous.is_none_or(|before| !before.is_alphanumeric()) { @@ -204,6 +237,7 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option { if previous_matched { score += 4; } + found.push(at); wanted.next(); } previous_matched = matched; @@ -213,7 +247,7 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option { return None; } // Among equal matches, the shorter text is the closer one. - Some(score * 16 - text.chars().count() as i32) + Some((score * 16 - chars.len() as i32, found)) } /// The texts that `query` fuzzily matches, best first. An empty query diff --git a/src/config/settings.rs b/src/config/settings.rs index a6cf30b..affe689 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -34,6 +34,24 @@ pub struct Settings { /// The program `:config` opens files in; empty: `$VISUAL`, `$EDITOR`, /// or the system's own. pub editor: String, + /// Where plain yanks, deletes and pastes go. + pub clipboard: ClipboardUse, +} + +/// Where plain yanks, deletes and pastes go (the `clipboard` setting). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ClipboardUse { + /// cumulus's own registers; `"+` is the system clipboard. + #[default] + Cumulus, + /// The system clipboard too, like vim's `clipboard=unnamedplus`. + UnnamedPlus, +} + +impl ClipboardUse { + /// The setting's values, as written in the config. + pub const VALUES: [(&str, ClipboardUse); 2] = + [("cumulus", ClipboardUse::Cumulus), ("unnamedplus", ClipboardUse::UnnamedPlus)]; } impl Default for Settings { @@ -49,6 +67,7 @@ impl Default for Settings { glyphs: Glyphs::Unicode, completion_popup: PopupMode::AsYouType, editor: String::new(), + clipboard: ClipboardUse::Cumulus, } } } @@ -66,6 +85,7 @@ pub enum Setting { Glyphs, CompletionPopup, Editor, + Clipboard, } /// The widths `max_node_width` can be. @@ -75,7 +95,7 @@ const LONGEST_DELAY: Duration = Duration::from_secs(5); impl Setting { /// Every setting, in the order config.toml lists them. - pub const ALL: [Setting; 10] = [ + pub const ALL: [Setting; 11] = [ Setting::Theme, Setting::DefaultDocument, Setting::Autosave, @@ -86,6 +106,7 @@ impl Setting { Setting::Glyphs, Setting::CompletionPopup, Setting::Editor, + Setting::Clipboard, ]; /// Its name, as config.toml and `:set` write it. @@ -101,6 +122,7 @@ impl Setting { Setting::Glyphs => "glyphs", Setting::CompletionPopup => "completion_popup", Setting::Editor => "editor", + Setting::Clipboard => "clipboard", } } @@ -133,6 +155,11 @@ impl Setting { "The program :config opens files in, like \"nvim\" or \"code --wait\".\n\ Empty: $VISUAL, then $EDITOR, then notepad on Windows and vi elsewhere." } + Setting::Clipboard => { + "Where plain y, d and p keep what they take: \"cumulus\" (its own\n\ + registers; \"+ is the system clipboard), or \"unnamedplus\" (the system\n\ + clipboard, like vim's setting)." + } } } @@ -155,6 +182,10 @@ impl Setting { values.join(" or ") } Setting::Editor => "a program, with what it takes before the file".to_string(), + Setting::Clipboard => { + let values: Vec<&str> = ClipboardUse::VALUES.iter().map(|(name, _)| *name).collect(); + values.join(" or ") + } } } } @@ -181,6 +212,11 @@ impl Settings { .map_or("?", |(name, _)| *name) .to_string(), Setting::Editor => self.editor.clone(), + Setting::Clipboard => ClipboardUse::VALUES + .iter() + .find(|(_, mode)| *mode == self.clipboard) + .map_or("?", |(name, _)| *name) + .to_string(), } } @@ -231,6 +267,7 @@ impl Settings { Setting::Glyphs => self.glyphs = default.glyphs, Setting::CompletionPopup => self.completion_popup = default.completion_popup, Setting::Editor => self.editor = default.editor, + Setting::Clipboard => self.clipboard = default.clipboard, } } @@ -289,6 +326,13 @@ impl Settings { } Setting::CompletionPopup => self.completion_popup = PopupMode::named(value).ok_or_else(wrong)?, Setting::Editor => self.editor = value.to_string(), + Setting::Clipboard => { + self.clipboard = ClipboardUse::VALUES + .iter() + .find(|(name, _)| *name == value) + .map(|(_, mode)| *mode) + .ok_or_else(wrong)?; + } } Ok(()) } @@ -460,7 +504,7 @@ mod tests { assert_eq!(problems.len(), 2, "{problems:?}"); assert_eq!( problems[0], - "config.toml: there's no setting called colour (there's theme, default_document, autosave, mouse, which_key, which_key_delay, max_node_width, glyphs, completion_popup, editor)" + "config.toml: there's no setting called colour (there's theme, default_document, autosave, mouse, which_key, which_key_delay, max_node_width, glyphs, completion_popup, editor, clipboard)" ); assert_eq!(problems[1], "config.toml: mouse is on or off"); diff --git a/src/model/clip.rs b/src/model/clip.rs new file mode 100644 index 0000000..d833ef3 --- /dev/null +++ b/src/model/clip.rs @@ -0,0 +1,300 @@ +//! Nodes taken out of a document to be put back somewhere: what a yank or a +//! delete keeps in a register, and what `p` pastes. A clip holds the nodes +//! with their shape (who's under whom among them), the lines from their +//! parents, and the cross-links that touch them. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::document::{Document, Place}; +use super::edit::{Edit, EditError, Spot}; +use super::geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize}; +use super::ids::{DocUid, NodeId}; +use super::link::Link; +use super::look::LineLook; +use super::node::Node; + +/// Columns between a parent and its children when a clip is laid out (as +/// new children are placed). +const BRANCH_GAP: i32 = 6; +/// Rows between boxes stacked one above another. +const ROW_GAP: i32 = 1; + +/// Some nodes of a document, with their shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Clip { + /// The document they came from: links to nodes that stayed there come + /// back when they're pasted into it again. Text read from elsewhere has + /// none. + pub from: Option, + /// The top nodes, in order, each with what's under it. + pub roots: Vec, + /// The cross-links touching the clipped nodes, by their old ids. + pub links: Vec, + /// Whether the nodes' positions mean something: text read from + /// elsewhere has none until [`Clip::lay_out`] gives it some. + pub placed: bool, +} + +/// A node in a clip, with the nodes under it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipNode { + /// Its id in the document it came from, to put its links back. + pub id: NodeId, + pub node: Node, + /// How the line from its parent was drawn: it keeps that look under its + /// new parent, as a moved node does. + pub branch: LineLook, + pub children: Vec, +} + +impl ClipNode { + /// A node read from text: no id of its own, no children yet. + pub fn new(node: Node) -> ClipNode { + ClipNode { id: NodeId::new(0), node, branch: LineLook::default(), children: Vec::new() } + } + + /// This node and everything under it, each node before its children. + fn walk<'a>(&'a self, found: &mut Vec<&'a ClipNode>) { + found.push(self); + for child in &self.children { + child.walk(found); + } + } +} + +impl Clip { + /// Exactly the nodes in `ids` (any that exist), in reading order. Under + /// each goes what's under it among them: a node whose parent isn't + /// clipped hangs from its nearest clipped ancestor, or is a top node. + pub fn take(doc: &Document, ids: &BTreeSet) -> Clip { + let order: Vec = doc.outline().into_iter().filter(|id| ids.contains(id)).collect(); + // Each clipped node's nearest clipped ancestor, if it has one. + let above: BTreeMap = order + .iter() + .filter_map(|&id| Some((id, doc.ancestors(id).find(|ancestor| ids.contains(ancestor))?))) + .collect(); + let tops: Vec = order.iter().copied().filter(|id| !above.contains_key(id)).collect(); + let roots = tops.into_iter().filter_map(|id| clip_node(doc, id, &order, &above)).collect(); + let links = doc + .links() + .filter(|link| ids.contains(&link.start()) || ids.contains(&link.end())) + .cloned() + .collect(); + Clip { from: Some(doc.uid()), roots, links, placed: true } + } + + /// Each of `tops` with its whole branch. + pub fn branches(doc: &Document, tops: &[NodeId]) -> Clip { + let ids = tops.iter().flat_map(|&top| doc.branch(top)).collect(); + Clip::take(doc, &ids) + } + + /// Every node in the clip, each before its children. + pub fn nodes(&self) -> Vec<&ClipNode> { + let mut found = Vec::new(); + for root in &self.roots { + root.walk(&mut found); + } + found + } + + /// How many nodes it holds. + pub fn count(&self) -> usize { + self.nodes().len() + } + + /// The box around all its nodes, given the size of each node's box. + pub fn bounds(&self, size_of: impl Fn(&Node) -> CanvasSize) -> Option { + let rects: Vec = self + .nodes() + .into_iter() + .map(|clipped| CanvasRect::new(clipped.node.pos, size_of(&clipped.node))) + .collect(); + let left = rects.iter().map(|rect| rect.left()).min()?; + let top = rects.iter().map(|rect| rect.top()).min()?; + let right = rects.iter().map(|rect| rect.right()).max()?; + let bottom = rects.iter().map(|rect| rect.bottom()).max()?; + let size = |from: i32, to: i32| u16::try_from(to - from).unwrap_or(u16::MAX); + Some(CanvasRect::new( + CanvasPos::new(left, top), + CanvasSize::new(size(left, right), size(top, bottom)), + )) + } + + /// Gives the nodes places as a mind map growing to the right: each first + /// child level with its parent, the next ones below it, the top nodes + /// one under another. For text read from elsewhere, which has none. + pub fn lay_out(&mut self, size_of: impl Fn(&Node) -> CanvasSize) { + let mut y = 0; + for root in &mut self.roots { + y = place(root, 0, y, &size_of) + ROW_GAP; + } + self.placed = true; + } +} + +/// A clipped node with the clipped nodes under it. +fn clip_node( + doc: &Document, + id: NodeId, + order: &[NodeId], + above: &BTreeMap, +) -> Option { + let node = doc.node(id)?.clone(); + let branch = match doc.place(id) { + Some(Place::Child { branch, .. }) => branch.clone(), + _ => LineLook::default(), + }; + let children = order + .iter() + .filter(|&&below| above.get(&below) == Some(&id)) + .filter_map(|&below| clip_node(doc, below, order, above)) + .collect(); + Some(ClipNode { id, node, branch, children }) +} + +/// Places a node at (`x`, `y`) and its children to its right. Returns the +/// bottom of the lowest box placed. +fn place(clipped: &mut ClipNode, x: i32, y: i32, size_of: &impl Fn(&Node) -> CanvasSize) -> i32 { + clipped.node.pos = CanvasPos::new(x, y); + let size = size_of(&clipped.node); + let mut bottom = y + i32::from(size.height); + let child_x = x + i32::from(size.width) + BRANCH_GAP; + let mut child_y = y; + for child in &mut clipped.children { + let child_bottom = place(child, child_x, child_y, size_of); + bottom = bottom.max(child_bottom); + child_y = child_bottom + ROW_GAP; + } + bottom +} + +impl Edit<'_> { + /// Puts copies of a clip into the document, one per offset: the first + /// at `spot`, each next one right after the one before. Every node keeps + /// its place in the clip's shape, moved by its copy's offset. Links + /// between clipped nodes come too, and so do links to nodes that are + /// still there when the clip goes back into the document it came from. + /// Returns the first copy's first node. + pub fn paste(&mut self, clip: &Clip, spot: Spot, offsets: &[CanvasOffset]) -> Result { + let mut first = None; + let mut spot = spot; + let home = clip.from == Some(self.doc().uid()); + for &offset in offsets { + let mut new_ids: BTreeMap = BTreeMap::new(); + let mut last_top = None; + for root in &clip.roots { + let at = match last_top { + Some(previous) => Spot::After(previous), + None => spot, + }; + let top = self.paste_node(root, at, offset, &mut new_ids)?; + first.get_or_insert(top); + last_top = Some(top); + } + for link in &clip.links { + let end = |old: NodeId| match new_ids.get(&old) { + Some(&new) => Some(new), + None => (home && self.doc().contains(old)).then_some(old), + }; + let (Some(start), Some(end)) = (end(link.start()), end(link.end())) else { continue }; + if let Some(mut new) = Link::new(start, end) { + new.label = link.label.clone(); + new.look = link.look.clone(); + self.link(new)?; + } + } + if let Some(top) = last_top { + spot = Spot::After(top); + } + } + first.ok_or(EditError::Empty) + } + + /// Adds one clipped node at `spot`, then its children under it. + fn paste_node( + &mut self, + clipped: &ClipNode, + spot: Spot, + offset: CanvasOffset, + new_ids: &mut BTreeMap, + ) -> Result { + let mut node = clipped.node.clone(); + node.pos += offset; + let id = self.add(node, spot)?; + if self.doc().parent(id).is_some() && clipped.branch != LineLook::default() { + self.set_branch_look(id, clipped.branch.clone())?; + } + new_ids.insert(clipped.id, id); + for child in &clipped.children { + self.paste_node(child, Spot::LastChildOf(id), offset, new_ids)?; + } + Ok(id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::{garden, idea, title}; + + #[test] + fn a_clip_keeps_the_shape_among_its_nodes() { + let (doc, g) = garden(); + // Garden plans and Cedar, without Raised beds between them: Cedar + // hangs from the root in the clip. + let clip = Clip::take(&doc, &BTreeSet::from([g.root, g.cedar, g.compost])); + let titles = + |nodes: &[ClipNode]| nodes.iter().map(|n| n.node.title().to_string()).collect::>(); + assert_eq!(titles(&clip.roots), ["Garden plans", "ask about compost"]); + assert_eq!(titles(&clip.roots[0].children), ["Cedar"]); + assert_eq!(clip.count(), 3); + let branches = Clip::branches(&doc, &[g.beds]); + assert_eq!(titles(&branches.roots), ["Raised beds"]); + assert_eq!(titles(&branches.roots[0].children), ["Cedar"]); + } + + #[test] + fn pasting_copies_nodes_their_shape_and_links() { + let (mut doc, g) = garden(); + doc.edit(|e| e.link(Link::new(g.cedar, g.soil).unwrap())).unwrap(); + doc.edit(|e| e.link(Link::new(g.beds, g.cedar).unwrap())).unwrap(); + let clip = Clip::branches(&doc, &[g.beds]); + let two = [CanvasOffset::new(0, 10), CanvasOffset::new(0, 20)]; + let (first, _) = doc.edit(|e| e.paste(&clip, Spot::LastChildOf(g.watering), &two)).unwrap(); + doc.assert_rules(); + let copies = doc.children(g.watering).to_vec(); + assert_eq!(copies.len(), 2, "two copies, one after the other"); + assert_eq!(first, copies[0]); + assert_eq!(doc.node(first).unwrap().pos, CanvasPos::new(22, 14), "moved by its offset"); + let cedar = doc.children(first)[0]; + assert_eq!(doc.node(cedar).unwrap().title().as_str(), "Cedar"); + assert!(doc.links_of(cedar).any(|link| link.key().touches(first)), "the link inside came too"); + assert!( + doc.links_of(cedar).any(|link| link.key().touches(g.soil)), + "and the one to Soil, still here" + ); + // In another document, the link to Soil has nowhere to go. + let mut other = Document::with_uid(title("Other"), DocUid::parse("0000000000000002").unwrap()); + let root = other.root(); + other.edit(|e| e.paste(&clip, Spot::LastChildOf(root), &[CanvasOffset::default()])).unwrap(); + assert_eq!(other.links().count(), 1, "only the link between the pasted nodes"); + } + + #[test] + fn text_gets_a_layout() { + let mut clip = Clip { + from: None, + roots: vec![ClipNode::new(idea("a")), ClipNode::new(idea("b"))], + links: Vec::new(), + placed: false, + }; + clip.roots[0].children = vec![ClipNode::new(idea("a1")), ClipNode::new(idea("a2"))]; + clip.lay_out(|_| CanvasSize::new(10, 3)); + let pos: Vec = clip.nodes().iter().map(|n| n.node.pos).collect(); + assert_eq!( + pos, + [CanvasPos::new(0, 0), CanvasPos::new(16, 0), CanvasPos::new(16, 4), CanvasPos::new(0, 8)] + ); + } +} diff --git a/src/model/edit.rs b/src/model/edit.rs index be6c947..9a9e8db 100644 --- a/src/model/edit.rs +++ b/src/model/edit.rs @@ -53,6 +53,8 @@ pub enum EditError { NotLinked(NodeId, NodeId), #[error("this document can't hold any more nodes")] Full, + #[error("there's nothing to paste")] + Empty, } /// One undo step: what an edit touched, before and after. diff --git a/src/model/history.rs b/src/model/history.rs index 259ffa1..41fadbb 100644 --- a/src/model/history.rs +++ b/src/model/history.rs @@ -206,7 +206,7 @@ mod tests { let drag = Group::new(); for _ in 0..3 { let op = crate::model::EditOp::Shift { - id: g.beds, + ids: vec![g.beds], offset: crate::model::CanvasOffset::new(1, 0), branch: true, }; diff --git a/src/model/mod.rs b/src/model/mod.rs index 63af78c..5d8621d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -10,6 +10,7 @@ //! turned into these types. //! - [`Visible`] works out which nodes folding leaves on show. +mod clip; mod document; mod edit; mod fold; @@ -23,6 +24,7 @@ mod op; mod portal; mod text; +pub use clip::{Clip, ClipNode}; pub use document::{Document, NodeRecord, Place, StructureError}; pub use edit::{Change, Edit, EditError, Spot}; pub use fold::Visible; diff --git a/src/model/op.rs b/src/model/op.rs index 01e1c56..47a444b 100644 --- a/src/model/op.rs +++ b/src/model/op.rs @@ -2,6 +2,9 @@ //! an [`EditOp`], and the app applies it with [`Document::apply`]: views //! never touch the document themselves. +use std::collections::BTreeSet; + +use super::clip::Clip; use super::document::Document; use super::edit::{Change, Edit, EditError, Spot}; use super::geometry::CanvasOffset; @@ -26,16 +29,18 @@ pub enum EditOp { }, /// Nodes with their branches (`dd`, `3dd`, `dib`). RemoveBranches(Vec), - /// Just this node; its children take its place (`x`). - RemoveKeepingChildren(NodeId), + /// Just these nodes; the children of each take its place (`x`, and + /// `d` on a selection). + RemoveKeepingChildren(Vec), /// A node, with its branch, to another spot in the tree. MoveTo { id: NodeId, spot: Spot, }, - /// A node moves on the canvas, with its branch when `branch` is set. + /// Nodes move on the canvas, each with its branch when `branch` is set + /// (a node in two of the branches still moves once). Shift { - id: NodeId, + ids: Vec, offset: CanvasOffset, branch: bool, }, @@ -81,6 +86,13 @@ pub enum EditOp { }, Link(Link), Unlink(Vec), + /// Copies of a clip (`p`): the first at `spot`, each moved by its + /// offset, the next ones right after it. + Paste { + clip: Clip, + spot: Spot, + offsets: Vec, + }, } /// The Inbox an idea goes into: the one there is, or a new one. @@ -129,13 +141,28 @@ fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result { } Ok(Applied::Removed(removed)) } - EditOp::RemoveKeepingChildren(id) => edit.remove_keeping_children(*id).map(|()| Applied::Removed(1)), - EditOp::MoveTo { id, spot } => edit.move_to(*id, *spot).map(|()| Applied::Changed), - EditOp::Shift { id, offset, branch: true } => { - edit.shift_branch(*id, *offset).map(|()| Applied::Changed) + EditOp::RemoveKeepingChildren(ids) => { + let mut removed = 0; + for &id in ids { + // A node goes once (and the root not at all: that's refused). + if edit.doc().contains(id) { + edit.remove_keeping_children(id)?; + removed += 1; + } + } + Ok(Applied::Removed(removed)) } - EditOp::Shift { id, offset, branch: false } => { - edit.update(*id, |node| node.pos += *offset).map(|()| Applied::Changed) + EditOp::MoveTo { id, spot } => edit.move_to(*id, *spot).map(|()| Applied::Changed), + EditOp::Shift { ids, offset, branch } => { + let moving: BTreeSet = if *branch { + ids.iter().flat_map(|&id| edit.doc().branch(id)).collect() + } else { + ids.iter().copied().collect() + }; + for id in moving { + edit.update(id, |node| node.pos += *offset)?; + } + Ok(Applied::Changed) } EditOp::SetFold { ids, fold } => { for &id in ids { @@ -186,6 +213,7 @@ fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result { } Ok(Applied::Changed) } + EditOp::Paste { clip, spot, offsets } => edit.paste(clip, *spot, offsets).map(Applied::Added), } } diff --git a/src/storage/markdown.rs b/src/storage/markdown.rs new file mode 100644 index 0000000..8040ee9 --- /dev/null +++ b/src/storage/markdown.rs @@ -0,0 +1,290 @@ +//! Nodes as Markdown text, for the system clipboard (`"+y`, `"+p`): a +//! section per node, the way the user picked in part 8c. +//! +//! ```text +//! ## Raised beds +//! --- +//! - wood +//! - todo +//! --- +//! Cedar, three of them. +//! +//! ### Cedar, untreated +//! +//! ### 40 cm high +//! ``` +//! +//! The nodes yanked are `##` headings, their children `###`, and everything +//! further down `####`. The tags (when there are any) are a list between +//! two `---` lines, and the body (when there is one) follows. A body line +//! that would read as a heading or a `---` gets a `\` in front. +//! +//! Text from elsewhere reads back too: headings of any level nest by their +//! level, and text without headings is a node per line, nested by its +//! indent (list bullets `-` `*` `+` and `1.` are dropped; a line without a +//! bullet under a bulleted one is part of its body). + +use crate::model::{Body, Clip, ClipNode, Content, Node, Tag, Tags, Title}; + +/// The heading level of the nodes yanked. +const TOP_LEVEL: usize = 2; +/// How many levels further down headings go: deeper nodes stay at the last. +const DEEPEST: usize = 2; + +/// A clip as Markdown text. +pub fn write(clip: &Clip) -> String { + let mut text = String::new(); + for root in &clip.roots { + write_node(&mut text, root, 0); + } + text +} + +fn write_node(text: &mut String, clipped: &ClipNode, depth: usize) { + if !text.is_empty() { + text.push('\n'); + } + let node = &clipped.node; + text.push_str(&format!("{} {}\n", "#".repeat(TOP_LEVEL + depth.min(DEEPEST)), node.title())); + if !node.tags.is_empty() { + text.push_str("---\n"); + for tag in node.tags.iter() { + text.push_str(&format!("- {}\n", tag.name())); + } + text.push_str("---\n"); + } + if let Content::Idea(idea) = &node.content { + for line in idea.body.as_str().lines() { + if line.starts_with('#') || line.starts_with('\\') || line.trim() == "---" { + text.push('\\'); + } + text.push_str(line); + text.push('\n'); + } + } + for child in &clipped.children { + write_node(text, child, depth + 1); + } +} + +/// The nodes some text holds, or `None` when it holds none (it's empty, or +/// only blank lines). They have no places on the canvas yet. +pub fn read(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + let flat = if lines.iter().any(|line| heading(line).is_some()) { sections(&lines) } else { list(&lines) }; + let roots = nest(flat); + (!roots.is_empty()).then_some(Clip { from: None, roots, links: Vec::new(), placed: false }) +} + +/// A heading line's level and text: `## Soil` is (2, "Soil"). +fn heading(line: &str) -> Option<(usize, &str)> { + let level = line.chars().take_while(|&c| c == '#').count(); + let rest = &line[level..]; + let text = rest.strip_prefix(' ')?.trim(); + ((1..=6).contains(&level) && !text.is_empty()).then_some((level, text)) +} + +/// Text with headings: a node per heading, with its tags and body. +fn sections(lines: &[&str]) -> Vec<(usize, ClipNode)> { + let top = lines.iter().filter_map(|line| heading(line)).map(|(level, _)| level).min().unwrap_or(1); + let mut flat = Vec::new(); + let mut at = 0; + while at < lines.len() { + let Some((level, title)) = heading(lines[at]) else { + // Text before the first heading has no node to go with. + at += 1; + continue; + }; + at += 1; + let section_end = + (at..lines.len()).find(|&index| heading(lines[index]).is_some()).unwrap_or(lines.len()); + let section = &lines[at..section_end]; + at = section_end; + let Ok(title) = Title::parse(title) else { continue }; + // The tags: between two `---` lines, right after the heading. + let first = section.iter().position(|line| !line.trim().is_empty()); + let (tags, body) = match first.filter(|&index| section[index].trim() == "---") { + Some(open) => match section[open + 1..].iter().position(|line| line.trim() == "---") { + Some(close) => (tags(§ion[open + 1..open + 1 + close]), §ion[open + close + 2..]), + None => (Tags::default(), section), + }, + None => (Tags::default(), section), + }; + let body: Vec<&str> = body.iter().map(|line| line.strip_prefix('\\').unwrap_or(line)).collect(); + flat.push((level - top, clip_node(title, tags, &body))); + } + flat +} + +/// The tags listed between the `---` lines: `- todo`, or `#todo #idea`. +/// Words that can't be tags are left out. +fn tags(lines: &[&str]) -> Tags { + lines + .iter() + .flat_map(|line| line.trim().trim_start_matches(['-', '*', '+']).split_whitespace()) + .filter_map(|word| Tag::parse(word).ok()) + .collect() +} + +/// Text without headings: a node per line, nested by its indent. Under a +/// bulleted line, a line without a bullet that's indented further is part of +/// that node's body. +fn list(lines: &[&str]) -> Vec<(usize, ClipNode)> { + let mut flat: Vec<(usize, ClipNode)> = Vec::new(); + let mut bodies: Vec> = Vec::new(); + // The last node's indent, and whether it had a bullet. + let mut last: Option<(usize, bool)> = None; + for line in lines.iter().filter(|line| !line.trim().is_empty()) { + let indent = indent(line); + let rest = line.trim(); + match bullet(rest) { + None if last.is_some_and(|(above, bulleted)| bulleted && indent > above) => { + if let Some(body) = bodies.last_mut() { + body.push(rest.to_string()); + } + } + found => { + let Ok(title) = Title::parse(found.unwrap_or(rest)) else { continue }; + flat.push((indent, ClipNode::new(Node::idea(title, Default::default())))); + bodies.push(Vec::new()); + last = Some((indent, found.is_some())); + } + } + } + for ((_, clipped), body) in flat.iter_mut().zip(bodies) { + if let Content::Idea(idea) = &mut clipped.node.content { + idea.body = Body::new(&body.join("\n")); + } + } + flat +} + +/// A list item's text without its bullet (`- `, `* `, `+ `, `1. `, `1) `), +/// if it has one. +fn bullet(text: &str) -> Option<&str> { + for mark in ["- ", "* ", "+ "] { + if let Some(rest) = text.strip_prefix(mark) { + return Some(rest.trim()); + } + } + let digits = text.chars().take_while(char::is_ascii_digit).count(); + let rest = &text[digits..]; + let rest = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") "))?; + (digits > 0).then(|| rest.trim()) +} + +/// How far a line is indented, a tab counting as four spaces. +fn indent(line: &str) -> usize { + line.chars().take_while(|c| c.is_whitespace()).map(|c| if c == '\t' { 4 } else { 1 }).sum() +} + +/// A node read from text. +fn clip_node(title: Title, tags: Tags, body: &[&str]) -> ClipNode { + let mut node = Node::idea(title, Default::default()); + node.tags = tags; + let start = body.iter().position(|line| !line.trim().is_empty()).unwrap_or(body.len()); + let end = body.iter().rposition(|line| !line.trim().is_empty()).map_or(start, |last| last + 1); + if let Content::Idea(idea) = &mut node.content { + idea.body = Body::new(&body[start..end.max(start)].join("\n")); + } + ClipNode::new(node) +} + +/// Nodes read one after another, each with its depth (or its indent: only +/// the order of the numbers matters), as a tree: each node goes under the +/// last one before it that's less deep. +fn nest(flat: Vec<(usize, ClipNode)>) -> Vec { + let mut roots = Vec::new(); + let mut open: Vec<(usize, ClipNode)> = Vec::new(); + for (depth, clipped) in flat { + close(&mut open, &mut roots, depth); + open.push((depth, clipped)); + } + close(&mut open, &mut roots, 0); + roots +} + +/// Ends the open nodes at `depth` or deeper: each goes under the one below +/// it on the stack, or among the roots. +fn close(open: &mut Vec<(usize, ClipNode)>, roots: &mut Vec, depth: usize) { + while open.last().is_some_and(|(at, _)| *at >= depth) { + let Some((_, done)) = open.pop() else { break }; + match open.last_mut() { + Some((_, parent)) => parent.children.push(done), + None => roots.push(done), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::idea; + + fn node(title: &str, tags: &str, body: &str, children: Vec) -> ClipNode { + let mut clipped = ClipNode::new(idea(title)); + clipped.node.tags = Tags::parse(tags).unwrap(); + if let Content::Idea(idea) = &mut clipped.node.content { + idea.body = Body::new(body); + } + clipped.children = children; + clipped + } + + fn clip(roots: Vec) -> Clip { + Clip { from: None, roots, links: Vec::new(), placed: false } + } + + /// Titles with their depth, each node before its children. + fn outline(clip: &Clip) -> Vec { + fn walk(nodes: &[ClipNode], depth: usize, out: &mut Vec) { + for clipped in nodes { + out.push(format!("{}{}", " ".repeat(depth), clipped.node.title())); + walk(&clipped.children, depth + 1, out); + } + } + let mut out = Vec::new(); + walk(&clip.roots, 0, &mut out); + out + } + + #[test] + fn nodes_become_sections_and_come_back() { + let great = node("Pine", "", "", Vec::new()); + let grand = node("Cedar", "", "", vec![great]); + let child = node("Wood", "", "", vec![grand]); + let beds = node("Raised beds", "#wood #todo", "Three of them.\n## not a heading\n---", vec![child]); + let original = clip(vec![beds, node("Soil", "", "", Vec::new())]); + let text = write(&original); + assert_eq!( + text, + "## Raised beds\n---\n- todo\n- wood\n---\nThree of them.\n\\## not a heading\n\\---\n\n\ + ### Wood\n\n#### Cedar\n\n#### Pine\n\n## Soil\n" + ); + let back = read(&text).unwrap(); + assert_eq!( + outline(&back), + ["Raised beds", " Wood", " Cedar", " Pine", "Soil"], + "deeper than #### comes back at that level" + ); + assert_eq!(back.roots[0].node.tags, original.roots[0].node.tags); + let Content::Idea(idea) = &back.roots[0].node.content else { panic!("an idea") }; + assert_eq!(idea.body.as_str(), "Three of them.\n## not a heading\n---"); + assert!(!back.placed && back.from.is_none()); + } + + #[test] + fn lists_and_plain_lines_from_elsewhere() { + let back = + read("- Watering\n - Rain barrel\n Empty it in May.\n - Drip line\n- Soil\n").unwrap(); + assert_eq!(outline(&back), ["Watering", " Rain barrel", " Drip line", "Soil"]); + let barrel = &back.roots[0].children[0].node; + let Content::Idea(idea) = &barrel.content else { panic!("an idea") }; + assert_eq!(idea.body.as_str(), "Empty it in May."); + let plain = read("Garden\n\tSoil\n\tBeds\n1. First\n").unwrap(); + assert_eq!(outline(&plain), ["Garden", " Soil", " Beds", "First"]); + assert_eq!(outline(&read("# One\ntext\n### Three").unwrap()), ["One", " Three"]); + assert!(read(" \n\n").is_none()); + assert_eq!(outline(&read("a word").unwrap()), ["a word"]); + } +} diff --git a/src/storage/memory.rs b/src/storage/memory.rs index 2f88080..5cd5485 100644 --- a/src/storage/memory.rs +++ b/src/storage/memory.rs @@ -1,6 +1,7 @@ //! What cumulus remembers between runs, in `memory.ron` in the data folder: -//! where you left off in each document (by uid, so it survives renames), -//! the documents opened lately, and the command history. +//! where you left off in each document (by uid, so it survives renames, +//! with its marks), the documents opened lately, the capital marks, and +//! the command history. //! //! cumulus writes this file itself, and it's only a convenience: one it //! can't read is set aside as if it were empty. Two cumulus running at once @@ -31,6 +32,17 @@ pub struct Memory { pub recent: Vec, /// Command lines typed, the oldest first. pub history: Vec, + /// The capital marks (`mA`), which lead into any document. + pub marks: BTreeMap, +} + +/// A capital mark: a node in a document, wherever it is. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileMark { + /// Where its document's file is, in full. + pub path: PathBuf, + pub uid: String, + pub node: u32, } /// Where you left off in a document: the cursor's node, and how the canvas @@ -44,6 +56,8 @@ pub struct LeftOff { /// The canvas point in the view's top-left corner. pub x: i32, pub y: i32, + /// The document's marks (`ma`): the nodes they're on. + pub marks: BTreeMap, } /// A document opened lately. @@ -80,6 +94,9 @@ impl Memory { for (uid, place) in disk.places { merged.places.entry(uid).or_insert(place); } + for (name, mark) in disk.marks { + merged.marks.entry(name).or_insert(mark); + } for recent in disk.recent { if !merged.recent.iter().any(|known| known.uid == recent.uid) { merged.recent.push(recent); diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 7cf980e..fc997f8 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -6,12 +6,14 @@ //! - [`lock`]: one cumulus at a time edits a document; another one opens //! it read-only, //! - [`stamp`]: noticing that something else changed a file, +//! - [`markdown`]: nodes as Markdown, for the system clipboard, //! - [`memory`]: what cumulus remembers between runs (where you left off in //! each document, recent documents, the command history). pub mod backup; pub mod document_file; pub mod lock; +pub mod markdown; pub mod memory; pub mod names; pub mod stamp; diff --git a/src/view/canvas/camera.rs b/src/view/canvas/camera.rs index 0b00a90..61cfc34 100644 --- a/src/view/canvas/camera.rs +++ b/src/view/canvas/camera.rs @@ -70,6 +70,20 @@ impl ScreenRect { (self.left()..self.right()).contains(&cell.col) && (self.top()..self.bottom()).contains(&cell.row) } + /// The rectangle with two cells as its opposite corners. + pub fn spanning(a: ScreenPos, b: ScreenPos) -> ScreenRect { + let (left, top) = (a.col.min(b.col), a.row.min(b.row)); + ScreenRect::new(left, top, a.col.max(b.col) - left + 1, a.row.max(b.row) - top + 1) + } + + /// Whether the two rectangles share at least one cell. + pub fn overlaps(self, other: ScreenRect) -> bool { + self.left() < other.right() + && other.left() < self.right() + && self.top() < other.bottom() + && other.top() < self.bottom() + } + /// The rectangle grown by `cols` on the left and right. pub fn widened(self, cols: i32) -> ScreenRect { ScreenRect::new(self.left() - cols, self.top(), self.width + 2 * cols, self.height) diff --git a/src/view/canvas/render.rs b/src/view/canvas/render.rs index b7efb1e..4d1eac0 100644 --- a/src/view/canvas/render.rs +++ b/src/view/canvas/render.rs @@ -29,8 +29,11 @@ impl Widget for &Scene { } Shape::Label(text) => self.draw_label(&mut painter, node, text), Shape::Dot => { - let style = - if node.selected { self.selected_style() } else { Style::new().fg(node.color) }; + let style = match (node.selected, node.picked) { + (true, _) => self.selected_style(), + (false, true) => self.picked_style(), + (false, false) => Style::new().fg(node.color), + }; painter.put(node.rect.pos, self.glyphs.bullet(), style); } } @@ -88,8 +91,9 @@ impl Scene { /// Far zoom: a colored bullet and the title on one line. fn draw_label(&self, painter: &mut Painter<'_>, node: &SceneNode, text: &str) { - if node.selected { - painter.text(node.rect.pos, text, self.selected_style(), |_| true); + if node.selected || node.picked { + let style = if node.selected { self.selected_style() } else { self.picked_style() }; + painter.text(node.rect.pos, text, style, |_| true); return; } let mut chars = text.chars(); @@ -105,6 +109,11 @@ impl Scene { fn selected_style(&self) -> Style { Style::new().fg(Color::Black).bg(self.accent) } + + /// A selected node when zoomed out: dark text on the visual color. + fn picked_style(&self) -> Style { + Style::new().fg(Color::Black).bg(self.visual) + } } /// Writes into the part of the buffer the canvas covers, in screen cells diff --git a/src/view/canvas/scene.rs b/src/view/canvas/scene.rs index 9c46aa7..272fe65 100644 --- a/src/view/canvas/scene.rs +++ b/src/view/canvas/scene.rs @@ -2,7 +2,7 @@ //! document, the cursor, the camera and the theme. Clicks are looked up in //! the same scene, so what you click is what you see. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ops::Range; use ratatui::layout::Size; @@ -13,12 +13,14 @@ 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::app::visual::selection; use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible}; use crate::ui::glyphs::Glyphs; use crate::ui::text_editor::layout; use crate::ui::theme::{LineStyle, Theme}; -use crate::view::ViewContext; +use crate::view::{ViewContext, ViewKind}; /// How many columns of a title show at Far zoom. const FAR_TITLE_WIDTH: usize = 14; @@ -48,6 +50,8 @@ pub struct SceneNode { pub faded: bool, /// Whether the cursor is on it. pub selected: bool, + /// Whether it's in visual mode's selection. + pub picked: bool, } /// Text drawn over the lines: an arrowhead or a label. @@ -76,6 +80,8 @@ pub struct Scene { pub(super) glyphs: Glyphs, pub(super) accent: Color, pub(super) muted: Color, + /// What's selected in visual mode. + pub(super) visual: Color, /// The border of the cursor's box: the accent, or the typing color while /// its title is typed. cursor_color: Color, @@ -100,12 +106,17 @@ impl Scene { 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 picked: BTreeSet = match ctx.mode { + Mode::Visual(visual) => selection(visual, ctx, ViewKind::Canvas).into_iter().collect(), + _ => BTreeSet::new(), + }; let mut scene = Scene { size, zoom: camera.zoom, glyphs: theme.glyphs, accent: theme.accent, muted: theme.muted, + visual: theme.visual, cursor_color: if typing { theme.insert } else { theme.accent }, covered: vec![None; size.area() as usize], lines: LineLayer::new(size), @@ -120,8 +131,9 @@ impl Scene { Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width), None => shape(node, badge, style.border, camera, theme.glyphs, max_width), }; - let (color, faded, selected) = (style.color, style.faded, id == cursor); - scene.nodes.push(SceneNode { id, rect, shape, color, faded, selected }); + let (color, faded, selected, picked) = + (style.color, style.faded, id == cursor, picked.contains(&id)); + scene.nodes.push(SceneNode { id, rect, shape, color, faded, selected, picked }); } // The cursor's node goes last, on top of any it overlaps. if let Some(index) = scene.nodes.iter().position(|node| node.selected) { @@ -219,13 +231,17 @@ impl Scene { /// Adds the boxes' borders to the line layer, so lines joining them get /// the right characters. The cursor's box is thick, in the accent color - /// (the typing color while its title is typed). + /// (the typing color while its title is typed); the other selected + /// boxes are thick in the visual color. fn add_borders(&mut self) { - let Scene { nodes, covered, lines, size, cursor_color, .. } = self; + let Scene { nodes, covered, lines, size, cursor_color, visual, .. } = self; for (index, node) in nodes.iter().enumerate() { let Shape::Box { border, .. } = &node.shape else { continue }; - let (border, color) = - if node.selected { (Border::Thick, *cursor_color) } else { (*border, node.color) }; + let (border, color) = match (node.selected, node.picked) { + (true, _) => (Border::Thick, *cursor_color), + (false, true) => (Border::Thick, *visual), + (false, false) => (*border, node.color), + }; let paint = Paint { color, owner: LineOwner::Border(node.id) }; lines.add_border(node.rect, border, paint, |at| covered_at(covered, *size, at) == Some(index)); } diff --git a/src/view/canvas/view.rs b/src/view/canvas/view.rs index f99994f..d362711 100644 --- a/src/view/canvas/view.rs +++ b/src/view/canvas/view.rs @@ -4,6 +4,7 @@ use ratatui::Frame; use ratatui::layout::{Position, Rect}; +use ratatui::style::Style; use super::camera::{Camera, ScreenPos, ScreenRect, Zoom}; use super::navigate::nearest; @@ -39,6 +40,9 @@ enum Drag { Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, group: Group }, /// Panning the view: the cell the mouse was last on. Pan { last: ScreenPos }, + /// Selecting with Ctrl held: the rectangle from where the drag started + /// to where the mouse is. + Band { from: ScreenPos, to: ScreenPos }, } /// The canvas view. @@ -140,7 +144,7 @@ impl CanvasView { /// Move mode: hjkl moves the cursor's node, or its whole branch, and /// every move until the mode ends is one undo step. - fn move_command(&mut self, typed: &Typed, grab: Grab, ctx: &ViewContext<'_>) -> Option> { + fn move_command(&mut self, typed: &Typed, grab: &Grab, ctx: &ViewContext<'_>) -> Option> { let step = cells(typed); let (cols, rows) = match typed.command { Command::Move(Motion::Left) => (-step, 0), @@ -156,10 +160,40 @@ impl CanvasView { _ => return None, }; self.reveal = true; - let op = EditOp::Shift { id: ctx.cursor, offset: self.camera.cells(cols, rows), branch: grab.whole }; + let ids = if grab.nodes.is_empty() { vec![ctx.cursor] } else { grab.nodes.clone() }; + let op = EditOp::Shift { ids, offset: self.camera.cells(cols, rows), branch: grab.whole }; Some(vec![Action::edit_in(op, grab.group)]) } + /// The outline of a Ctrl+drag's rectangle, in the visual color. + fn draw_band(&self, frame: &mut Frame, band: ScreenRect, ctx: &ViewContext<'_>) { + let set = ctx.theme.glyphs.frame(false); + let style = Style::new().fg(ctx.theme.visual); + let area = self.area; + let buf = frame.buffer_mut(); + let mut put = |col: i32, row: i32, symbol: &str| { + if let (Ok(col), Ok(row)) = (u16::try_from(col), u16::try_from(row)) + && col < area.width + && row < area.height + { + buf[(area.x + col, area.y + row)].set_symbol(symbol).set_style(style); + } + }; + let (left, top, right, bottom) = (band.left(), band.top(), band.right() - 1, band.bottom() - 1); + for col in left..=right { + put(col, top, set.horizontal_top); + put(col, bottom, set.horizontal_bottom); + } + for row in top..=bottom { + put(left, row, set.vertical_left); + put(right, row, set.vertical_right); + } + put(left, top, set.top_left); + put(right, top, set.top_right); + put(left, bottom, set.bottom_left); + put(right, bottom, set.bottom_right); + } + /// The view's cell under a terminal cell. fn screen(&self, cell: Cell) -> ScreenPos { ScreenPos::new( @@ -178,13 +212,17 @@ impl CanvasView { return Vec::new(); } *moved = total; - vec![Action::edit_in(EditOp::Shift { id: *id, offset: step, branch: true }, *group)] + vec![Action::edit_in(EditOp::Shift { ids: vec![*id], offset: step, branch: true }, *group)] } Some(Drag::Pan { last }) => { self.camera.pan(last.col - at.col, last.row - at.row); *last = at; Vec::new() } + Some(Drag::Band { to, .. }) => { + *to = at; + Vec::new() + } None => Vec::new(), } } @@ -222,7 +260,11 @@ impl View for CanvasView { } fn contexts(&self, mode: &Mode) -> &'static [Context] { - contexts_for(mode, &[Context::Canvas, Context::Normal, Context::Motions]) + contexts_for( + mode, + &[Context::Canvas, Context::Normal, Context::Motions], + &[Context::Visual, Context::Canvas, Context::Motions], + ) } fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) { @@ -250,8 +292,11 @@ impl View for CanvasView { } } - fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) { + fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) { frame.render_widget(&self.scene, self.area); + if let Some(Drag::Band { from, to }) = self.drag { + self.draw_band(frame, ScreenRect::spanning(from, to), ctx); + } if let Some(cell) = self.scene.text_cursor() && let (Ok(col), Ok(row)) = (u16::try_from(cell.col), u16::try_from(cell.row)) && col < self.area.width @@ -263,10 +308,10 @@ impl View for CanvasView { fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option> { match ctx.mode { - Mode::Move(grab) => return self.move_command(typed, *grab, ctx), + Mode::Move(grab) => return self.move_command(typed, grab, ctx), // Keys inside a title are the edit's, not the view's. Mode::Text(_) | Mode::Popup(_) => return None, - Mode::Normal | Mode::Command(_) => {} + Mode::Normal | Mode::Visual(_) | Mode::Command(_) => {} } let steps = cells(typed); let half_page = i32::from(self.area.height / 2); @@ -289,6 +334,18 @@ impl View for CanvasView { fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec { match gesture { + // Ctrl+click: a node into the selection or out of it; on empty + // space, Ctrl+drag selects what its rectangle touches. + Gesture::Press { at, ctrl: true } if ctx.mode.edit().is_none() => { + let at = self.screen(at); + match self.scene.node_at(at) { + Some(id) => vec![Action::App(AppAction::ToggleSelected(id))], + None => { + self.drag = Some(Drag::Band { from: at, to: at }); + Vec::new() + } + } + } Gesture::Press { at, .. } => { let at = self.screen(at); let Mode::Text(edit) = ctx.mode else { return self.press(at) }; @@ -317,10 +374,22 @@ impl View for CanvasView { actions } Gesture::Drag { at, .. } => self.drag_to(self.screen(at)), - Gesture::Release { .. } => { - self.drag = None; - Vec::new() - } + Gesture::Release { .. } => match self.drag.take() { + Some(Drag::Band { from, to }) => { + let band = ScreenRect::spanning(from, to); + let caught: Vec = self + .scene + .nodes() + .iter() + .filter(|node| node.rect.overlaps(band)) + .map(|node| node.id) + .collect(); + let ids: Vec = + ctx.doc.outline().into_iter().filter(|id| caught.contains(id)).collect(); + if ids.is_empty() { Vec::new() } else { vec![Action::App(AppAction::SelectAlso(ids))] } + } + _ => Vec::new(), + }, Gesture::Scroll { direction, .. } => { let (cols, rows) = match direction { Direction::Up => (0, -SCROLL_ROWS), @@ -418,7 +487,8 @@ mod tests { let mut buffer = Buffer::new(doc); buffer.apply(DocAction::MoveCursor { to: g.beds, jump: false }); for command in [Command::Move(Motion::Right), Command::FarDown] { - for action in view.command(&typed(command, Some(2)), &setup.ctx(buffer.doc(), g.beds)).unwrap() { + let ctx = setup.ctx(buffer.doc(), g.beds); + for action in view.command(&typed(command, Some(2)), &ctx).unwrap() { let Action::Doc(action) = action else { panic!("only edits") }; buffer.apply(action); } diff --git a/src/view/mod.rs b/src/view/mod.rs index ee0680a..a0ddfa1 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -96,10 +96,16 @@ 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] { +/// table first), `visual` in visual mode, and what every view reads in the +/// other modes. +pub fn contexts_for( + mode: &Mode, + normal: &'static [Context], + visual: &'static [Context], +) -> &'static [Context] { match mode { Mode::Normal => normal, + Mode::Visual(_) => visual, Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert], Mode::Text(_) => &[Context::Text], Mode::Move(_) => &[Context::Move], diff --git a/src/view/note.rs b/src/view/note.rs index 87a63ce..334d7cb 100644 --- a/src/view/note.rs +++ b/src/view/note.rs @@ -80,6 +80,8 @@ pub struct NoteView { children: Vec, /// The part being edited, as drawn. editing: Option, + /// A preview (the search's): no bar marks a part. + preview: bool, } /// One row of the note, ready to draw. @@ -107,6 +109,12 @@ impl NoteView { NoteView::default() } + /// A note to look at, not to move through: the search shows the note + /// of the match you're on this way. + pub fn preview() -> NoteView { + NoteView { preview: true, ..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) @@ -352,7 +360,11 @@ impl View for NoteView { } fn contexts(&self, mode: &Mode) -> &'static [Context] { - contexts_for(mode, &[Context::Note, Context::Normal, Context::Motions]) + contexts_for( + mode, + &[Context::Note, Context::Normal, Context::Motions], + &[Context::Visual, Context::Motions], + ) } fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) { @@ -394,7 +406,7 @@ impl View for NoteView { 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) { + if row.part == Some(self.part) && !self.preview { buf.set_string(self.area.x, y, &bar, bar_style); } buf.set_line(self.area.x + MARGIN, y, &row.line, width); @@ -406,8 +418,8 @@ impl View for NoteView { fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option> { match ctx.mode { - Mode::Move(grab) => return commands::reorder(typed, *grab, ctx), - Mode::Normal => {} + Mode::Move(grab) => return commands::reorder(typed, grab, ctx), + Mode::Normal | Mode::Visual(_) => {} Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None, } let times = isize::try_from(typed.times()).unwrap_or(1); diff --git a/src/view/outline.rs b/src/view/outline.rs index b6164b2..98409b4 100644 --- a/src/view/outline.rs +++ b/src/view/outline.rs @@ -25,6 +25,8 @@ //! Rows wider than the view (a deep tree, a long title) are cut at its right //! edge; `zh` and `zl` or the wheel sideways scroll them. +use std::collections::BTreeSet; + use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -36,6 +38,7 @@ use crate::app::commands; use crate::app::mode::Mode; use crate::app::popup::MenuFor; use crate::app::text_edit::{EditAt, StartEdit, TextEdit}; +use crate::app::visual::selection; use crate::input::bindings::Context; use crate::input::command::{Command, Motion}; use crate::input::engine::Typed; @@ -76,6 +79,8 @@ pub struct OutlineView { scrolled: bool, /// The title being edited, as drawn. editing: Option, + /// What's selected in visual mode. + picked: BTreeSet, } /// One row, ready to draw. @@ -117,6 +122,10 @@ impl OutlineView { 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(); + self.picked = match ctx.mode { + Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(), + _ => BTreeSet::new(), + }; // 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| { @@ -185,6 +194,8 @@ impl OutlineView { 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 if self.picked.contains(&id) { + Style::new().fg(Color::Black).bg(theme.visual) } else if style.faded { muted } else { @@ -418,7 +429,11 @@ impl View for OutlineView { } fn contexts(&self, mode: &Mode) -> &'static [Context] { - contexts_for(mode, &[Context::Outline, Context::Normal, Context::Motions]) + contexts_for( + mode, + &[Context::Outline, Context::Normal, Context::Motions], + &[Context::Visual, Context::Outline, Context::Motions], + ) } fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) { @@ -459,8 +474,8 @@ impl View for OutlineView { fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option> { match ctx.mode { - Mode::Move(grab) => return commands::reorder(typed, *grab, ctx), - Mode::Normal => {} + Mode::Move(grab) => return commands::reorder(typed, grab, ctx), + Mode::Normal | Mode::Visual(_) => {} Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None, } let rows = isize::try_from(typed.times()).unwrap_or(1);