Add visual mode, node registers, search and the jump list (stage 8c)
The rest of vim, on nodes. Visual mode takes in what lies between where it started and the cursor: on the canvas, every node 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 whole branches, Ctrl+click and Ctrl+drag pick with the mouse, and the operators take exactly what's selected: d, y, > and <, M, and the pickers on every node at once. Registers now hold text or nodes, so yy, dd and x keep what they take and p, P and ]p paste it, layout and links included. "+ is the system clipboard through arboard: nodes go there as Markdown in the user's format (## headings, a --- tag list, the body), and come back whole while the clipboard still holds them. clipboard = "unnamedplus" makes plain yanks use it, as in vim. . repeats the last change made with keys, with the text typed after it. / opens a Telescope-style search: fuzzy matches over titles, tags and bodies, best first, with the note of the one you're on beside them; n and N go on with it, and :find searches straight away. Each pane keeps a jump list for Ctrl-o and Ctrl-i that crosses documents and reopens closed ones. Capital marks lead into any document and, like the small ones, are kept in memory.ron. gd follows a cross-link, and :marks, :jumps and :registers list what they hold. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
abe3dc1d84
commit
7bec5bf3fc
45 files changed
+3922
-272
No files matched your search
Generated
+429
-6
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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 · `<Space>` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel |
|
||||
| **Visual** | `d y > < M C` and `<Space>c` `<Space>s` `<Space>t` act on the whole selection · `o` swaps ends |
|
||||
| **Visual** | `d` `x` `y` `>` `<` `M` and `<Space>c` `<Space>s` `<Space>B` `<Space>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
|
||||
|
||||
@@ -58,6 +58,8 @@ what's wrong).
|
||||
a theme can say just `accent = "magenta"`.
|
||||
- The built-in theme has named styles (`<Space>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 `<Space>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 |
|
||||
| `<Space>` | 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`, `<Space>`…), 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
|
||||
|
||||
|
||||
+77
-3
@@ -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<String>) -> 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<NodeId>),
|
||||
/// Move mode: hjkl moves the cursor's branch, or these nodes (a
|
||||
/// selection's).
|
||||
EnterMoveMode(Vec<NodeId>),
|
||||
/// 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<Key>),
|
||||
/// Keep nodes in a register (`yy`, and before a delete): exactly
|
||||
/// `nodes`, or (`branches`) each with its whole branch.
|
||||
Yank {
|
||||
register: Option<Register>,
|
||||
nodes: Vec<NodeId>,
|
||||
branches: bool,
|
||||
},
|
||||
/// Paste what a register holds (`p`, `P`, `]p`): its nodes, or its text
|
||||
/// made into nodes, `times` copies.
|
||||
Paste {
|
||||
register: Option<Register>,
|
||||
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 {
|
||||
|
||||
+14
-7
@@ -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<char, NodeId> {
|
||||
&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<Item = (char, NodeId)>) {
|
||||
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<Message> {
|
||||
@@ -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::*;
|
||||
|
||||
+4
-41
@@ -62,13 +62,13 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
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<NodeId>) -> Vec<Action> {
|
||||
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<Action> {
|
||||
let doc = ctx.doc;
|
||||
let wanted = text.to_lowercase();
|
||||
let order = doc.outline();
|
||||
let matches: Vec<usize> = (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\"")));
|
||||
}
|
||||
}
|
||||
@@ -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<arboard::Clipboard>,
|
||||
#[cfg(test)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
+100
-21
@@ -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<Vec<Action>> {
|
||||
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<Vec<Action>> {
|
||||
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<Vec<Action>> {
|
||||
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),
|
||||
// `<Space>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<Group>) -> 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<Vec<Action>> {
|
||||
pub fn reorder(typed: &Typed, grab: &Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
let (doc, id) = (ctx.doc, ctx.cursor);
|
||||
let steps = match typed.command {
|
||||
Command::FarDown | Command::FarUp => FAR,
|
||||
@@ -251,7 +280,7 @@ fn make_room(
|
||||
fn shift_to(doc: &Document, id: NodeId, pos: CanvasPos, group: Group) -> Option<Action> {
|
||||
let offset = pos - doc.node(id)?.pos;
|
||||
let moved = offset != Default::default();
|
||||
moved.then(|| Action::edit_in(EditOp::Shift { id, offset, branch: true }, group))
|
||||
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<Node
|
||||
}
|
||||
}
|
||||
|
||||
/// `x`: deletes just the cursor's node; its children take its place.
|
||||
fn delete_node(ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
/// `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<Action> {
|
||||
let cursor = ctx.cursor;
|
||||
let others: Vec<NodeId> = 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<Register>) -> Vec<Action> {
|
||||
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<Action> {
|
||||
/// What's deleted goes in the register.
|
||||
fn delete_branches(ctx: &ViewContext<'_>, count: u32, register: Option<Register>) -> Vec<Action> {
|
||||
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<Action> {
|
||||
.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<Action> {
|
||||
fn delete_children(ctx: &ViewContext<'_>, register: Option<Register>) -> Vec<Action> {
|
||||
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<EditOp> {
|
||||
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))
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
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();
|
||||
}
|
||||
|
||||
+21
-5
@@ -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.
|
||||
|
||||
+36
-16
@@ -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);
|
||||
|
||||
@@ -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<Place>,
|
||||
/// 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<Place> {
|
||||
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<Place> {
|
||||
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<usize>) {
|
||||
(&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<Message> {
|
||||
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<Message> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<Message> {
|
||||
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<Message> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<Message> {
|
||||
let from = self.place_here();
|
||||
let said = self.go_to(place);
|
||||
if self.place_here() != from {
|
||||
self.jumping(from);
|
||||
}
|
||||
said
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -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<Message>,
|
||||
/// The last change, for `.`.
|
||||
last_change: Option<repeat::Repeat>,
|
||||
/// A text edit started by a key command, while it goes on.
|
||||
recording: Option<repeat::Recording>,
|
||||
/// `.` is making a change again: it isn't recorded again.
|
||||
repeating: bool,
|
||||
/// The last search, for `n` and `N`.
|
||||
last_search: Option<String>,
|
||||
screen: Screen,
|
||||
/// The pane a mouse drag started in: it gets the whole drag.
|
||||
dragging: Option<PaneId>,
|
||||
@@ -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<dyn View>,
|
||||
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<Camera>) {
|
||||
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(),
|
||||
|
||||
+40
-3
@@ -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<NodeId>,
|
||||
/// Nodes a Ctrl+click took out.
|
||||
pub removed: BTreeSet<NodeId>,
|
||||
}
|
||||
|
||||
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<NodeId>,
|
||||
}
|
||||
|
||||
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<NodeId>) -> 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(),
|
||||
|
||||
+186
-26
@@ -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<NodeId>,
|
||||
values: Vec<Pick>,
|
||||
/// 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<Popup, Message> {
|
||||
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
|
||||
let node = doc.node(ctx.cursor).ok_or_else(|| Message::problem("that node is gone"))?;
|
||||
let 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<Span<'static>>, 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<Tag> = 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<usize>, 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Action> {
|
||||
let id = self.node;
|
||||
let op = match self.values.get(index) {
|
||||
None => return Vec::new(),
|
||||
Some(Pick::Color(color)) => EditOp::SetColor { id, color: color.clone() },
|
||||
Some(Pick::Border(border)) => EditOp::SetBorder { id, border: *border },
|
||||
Some(Pick::Style(style)) => EditOp::SetStyle { id, style: style.clone() },
|
||||
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<Action> {
|
||||
let mut actions: Vec<Action> = 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<Action> {
|
||||
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()))],
|
||||
}
|
||||
|
||||
+25
-9
@@ -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!(
|
||||
|
||||
+163
-32
@@ -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<String> =
|
||||
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<Clip> {
|
||||
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<Yank>,
|
||||
named: BTreeMap<char, Yank>,
|
||||
clipboard: Option<Yank>,
|
||||
unnamed: Option<Kept>,
|
||||
named: BTreeMap<char, Kept>,
|
||||
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<Register>, 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<Register>, 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<Register>) -> 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<Register>) -> Result<Option<Kept>, 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<Kept, String> {
|
||||
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"))));
|
||||
}
|
||||
}
|
||||
+31
-7
@@ -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 · <Space>c color · Esc stop"
|
||||
}
|
||||
(Mode::Visual(_), ViewKind::Note) => {
|
||||
"h/l the siblings too · o other end · d delete · y yank · > < in/out · <Space>c color · Esc stop"
|
||||
}
|
||||
(Mode::Visual(_), _) => {
|
||||
"j/k more · o other end · d delete · y yank · > < in/out · <Space>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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Key> },
|
||||
/// 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<ColorName>),
|
||||
Border(Option<Border>),
|
||||
Style(Option<StyleName>),
|
||||
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<Key>,
|
||||
/// 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<u32>) {
|
||||
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<Typed> {
|
||||
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 })
|
||||
}
|
||||
@@ -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<usize>,
|
||||
}
|
||||
|
||||
/// 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<Found> {
|
||||
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<Found>,
|
||||
/// 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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Span<'static>> {
|
||||
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<Rect>) {
|
||||
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<Action> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<NodeId> = 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");
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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<CR>");
|
||||
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<CR>");
|
||||
assert_eq!(h.app.settings.completion_popup, PopupMode::OnTab);
|
||||
h.type_keys(":frobnicate<CR>");
|
||||
@@ -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("<Space>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");
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> = 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("gli<Esc>yiw<Esc>ggp");
|
||||
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 <Esc>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<CR>");
|
||||
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<Esc>");
|
||||
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("glvjj<Space>c/teal<CR>");
|
||||
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("glvj<Space>t/wood<CR>");
|
||||
assert_eq!((h.tags("Soil").as_str(), h.tags("Raised beds").as_str()), ("#todo #wood", "#wood"));
|
||||
// M moves them all.
|
||||
h.type_keys("<Esc>ggglvjMll<CR>");
|
||||
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("<Space>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("ggaMulch<Esc><Esc>gg.");
|
||||
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!<Esc><Esc>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("<Space>c/teal<CR>gg.");
|
||||
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("<CR>");
|
||||
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<Esc>");
|
||||
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("<CR>");
|
||||
assert_eq!(h.cursor(), "Watering");
|
||||
// n and N go through the same matches, going round.
|
||||
h.type_keys(":find e<CR>");
|
||||
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<CR>");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
h.type_keys("<C-o>");
|
||||
assert_eq!(h.cursor(), last);
|
||||
h.type_keys("<C-o>");
|
||||
assert_eq!(h.cursor(), "Garden plans");
|
||||
h.type_keys("<C-o>");
|
||||
assert_eq!(h.message(), "no older jump: that's where the jump list starts");
|
||||
h.type_keys("2<Tab>");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
h.type_keys("<C-i>");
|
||||
assert_eq!(h.message(), "no newer jump: Ctrl-o goes back");
|
||||
// :jumps lists them; Enter goes to one.
|
||||
h.type_keys(":jumps<CR>");
|
||||
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<CR>");
|
||||
assert_eq!(h.app.shared.docs.count(), 1);
|
||||
// Ctrl-o: back to the kitchen, opened again from its file.
|
||||
h.type_keys("<C-o>");
|
||||
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("<Tab>");
|
||||
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("<C-o>");
|
||||
assert!(h.screen().contains("canvas · Kitchen"), "'A was a jump");
|
||||
h.type_keys(":marks<CR>");
|
||||
let screen = h.screen();
|
||||
assert!(screen.contains("A Soil Garden plans"), "{screen}");
|
||||
h.type_keys("<Esc>");
|
||||
// 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 seeds<CR>gd");
|
||||
assert_eq!(h.cursor(), "ask about compost");
|
||||
h.type_keys("<C-o>");
|
||||
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<CR>:link soil<CR>gd");
|
||||
assert_eq!(h.label(), "MENU");
|
||||
let screen = h.screen();
|
||||
assert!(screen.contains("→ Soil") && screen.contains("← Seeds to order needs"), "{screen}");
|
||||
h.type_keys("<CR>");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_list_and_paste() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gl\"ayygj\"byy:registers<CR>");
|
||||
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<CR>");
|
||||
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");
|
||||
}
|
||||
+14
-3
@@ -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 => {}
|
||||
}
|
||||
|
||||
+54
-8
@@ -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<Message> {
|
||||
/// 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<Message> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NodeId> {
|
||||
let doc = ctx.doc;
|
||||
let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus);
|
||||
let mut chosen: BTreeSet<NodeId> = match kind {
|
||||
ViewKind::Canvas | ViewKind::Board => {
|
||||
let rects = ctx.box_rects();
|
||||
let ends: Vec<CanvasRect> =
|
||||
[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<NodeId> = 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<Vec<NodeId>> {
|
||||
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<NodeId>) -> Option<Message> {
|
||||
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<CanvasRect> {
|
||||
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<Vec<Action>> {
|
||||
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<Register>,
|
||||
ctx: &ViewContext<'_>,
|
||||
) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let nodes: Vec<NodeId> = 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<NodeId> = 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<NodeId> {
|
||||
let set: BTreeSet<NodeId> = selected.iter().copied().collect();
|
||||
selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| !ctx.doc.ancestors(id).any(|ancestor| set.contains(&ancestor)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn app(action: AppAction) -> Vec<Action> {
|
||||
vec![Action::App(action)]
|
||||
}
|
||||
|
||||
fn say(text: &str) -> Vec<Action> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<Register>,
|
||||
nodes: &[NodeId],
|
||||
branches: bool,
|
||||
) -> Option<Message> {
|
||||
let doc = self.current().doc();
|
||||
let clip = if branches {
|
||||
Clip::branches(doc, nodes)
|
||||
} else {
|
||||
Clip::take(doc, &nodes.iter().copied().collect::<BTreeSet<NodeId>>())
|
||||
};
|
||||
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<Register>, at: PasteAt, times: u32) -> Option<Message> {
|
||||
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)))))
|
||||
}
|
||||
}
|
||||
+39
-5
@@ -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<i32> {
|
||||
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<usize>)> {
|
||||
let wanted: Vec<char> = if ignore_case {
|
||||
query.chars().flat_map(char::to_lowercase).collect()
|
||||
} else {
|
||||
query.chars().collect()
|
||||
};
|
||||
let chars: Vec<char> = 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<usize>)> {
|
||||
let mut wanted = wanted.iter().copied().peekable();
|
||||
let mut score = 0;
|
||||
let mut previous: Option<char> = None;
|
||||
let mut found = Vec::new();
|
||||
let mut previous: Option<char> = 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<i32> {
|
||||
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<i32> {
|
||||
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
|
||||
|
||||
+46
-2
@@ -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");
|
||||
|
||||
|
||||
@@ -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<DocUid>,
|
||||
/// The top nodes, in order, each with what's under it.
|
||||
pub roots: Vec<ClipNode>,
|
||||
/// The cross-links touching the clipped nodes, by their old ids.
|
||||
pub links: Vec<Link>,
|
||||
/// 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<ClipNode>,
|
||||
}
|
||||
|
||||
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<NodeId>) -> Clip {
|
||||
let order: Vec<NodeId> = doc.outline().into_iter().filter(|id| ids.contains(id)).collect();
|
||||
// Each clipped node's nearest clipped ancestor, if it has one.
|
||||
let above: BTreeMap<NodeId, NodeId> = order
|
||||
.iter()
|
||||
.filter_map(|&id| Some((id, doc.ancestors(id).find(|ancestor| ids.contains(ancestor))?)))
|
||||
.collect();
|
||||
let tops: Vec<NodeId> = 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<CanvasRect> {
|
||||
let rects: Vec<CanvasRect> = 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<NodeId, NodeId>,
|
||||
) -> Option<ClipNode> {
|
||||
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<NodeId, EditError> {
|
||||
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<NodeId, NodeId> = 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<NodeId, NodeId>,
|
||||
) -> Result<NodeId, EditError> {
|
||||
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::<Vec<_>>();
|
||||
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<CanvasPos> = 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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
+38
-10
@@ -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<NodeId>),
|
||||
/// 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<NodeId>),
|
||||
/// 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<NodeId>,
|
||||
offset: CanvasOffset,
|
||||
branch: bool,
|
||||
},
|
||||
@@ -81,6 +86,13 @@ pub enum EditOp {
|
||||
},
|
||||
Link(Link),
|
||||
Unlink(Vec<LinkKey>),
|
||||
/// 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<CanvasOffset>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<Applied, EditError> {
|
||||
}
|
||||
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<NodeId> = 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<Applied, EditError> {
|
||||
}
|
||||
Ok(Applied::Changed)
|
||||
}
|
||||
EditOp::Paste { clip, spot, offsets } => edit.paste(clip, *spot, offsets).map(Applied::Added),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Clip> {
|
||||
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<String>> = 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<ClipNode> {
|
||||
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<ClipNode>, 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>) -> 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<ClipNode>) -> Clip {
|
||||
Clip { from: None, roots, links: Vec::new(), placed: false }
|
||||
}
|
||||
|
||||
/// Titles with their depth, each node before its children.
|
||||
fn outline(clip: &Clip) -> Vec<String> {
|
||||
fn walk(nodes: &[ClipNode], depth: usize, out: &mut Vec<String>) {
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -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<Recent>,
|
||||
/// Command lines typed, the oldest first.
|
||||
pub history: Vec<String>,
|
||||
/// The capital marks (`mA`), which lead into any document.
|
||||
pub marks: BTreeMap<char, FileMark>,
|
||||
}
|
||||
|
||||
/// 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<char, u32>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<NodeId> = 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));
|
||||
}
|
||||
|
||||
+82
-12
@@ -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<Vec<Action>> {
|
||||
fn move_command(&mut self, typed: &Typed, grab: &Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
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<Vec<Action>> {
|
||||
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<Action> {
|
||||
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<NodeId> = self
|
||||
.scene
|
||||
.nodes()
|
||||
.iter()
|
||||
.filter(|node| node.rect.overlaps(band))
|
||||
.map(|node| node.id)
|
||||
.collect();
|
||||
let ids: Vec<NodeId> =
|
||||
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);
|
||||
}
|
||||
|
||||
+8
-2
@@ -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],
|
||||
|
||||
+16
-4
@@ -80,6 +80,8 @@ pub struct NoteView {
|
||||
children: Vec<NodeId>,
|
||||
/// The part being edited, as drawn.
|
||||
editing: Option<Editing>,
|
||||
/// 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<Vec<Action>> {
|
||||
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);
|
||||
|
||||
+18
-3
@@ -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<Editing>,
|
||||
/// What's selected in visual mode.
|
||||
picked: BTreeSet<NodeId>,
|
||||
}
|
||||
|
||||
/// 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<Vec<Action>> {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user