commit 4bfa9692a14f18e48c36e8ed0908a1e9b1108619 Author: irrlicht Date: Sun Jul 26 23:13:53 2026 +0200 First Version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8fef76a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1669 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kanban" +version = "0.1.0" +dependencies = [ + "crossterm", + "ratatui", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..025fe24 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "kanban" +version = "0.1.0" +edition = "2024" + +[dependencies] +crossterm = "0.29.0" +ratatui = "0.30.2" diff --git a/board.md b/board.md new file mode 100644 index 0000000..e8d73cb --- /dev/null +++ b/board.md @@ -0,0 +1,7 @@ +# To Do + +# Active + +- Kekse backen + +# Done diff --git a/board2.md b/board2.md new file mode 100644 index 0000000..8d1fbab --- /dev/null +++ b/board2.md @@ -0,0 +1,7 @@ +# To Do + +# Active + +- Board 2!!! + +# Done diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..eb911b4 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,944 @@ +//! Anwendungszustand und Tastaturbelegung. +//! +//! Die eigentliche Arbeit passiert in [`crate::ops`] und [`crate::transfer`]; +//! hier wird nur zugeordnet, welche Taste was ausloest, und das Ergebnis in +//! Cursor-Position, Dirty-Flag und Statusmeldung uebersetzt. + +use std::path::PathBuf; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::history::{History, Snapshot}; +use crate::markdown; +use crate::model::{Area, AreaKind, Board, Item}; + +/// Normal- oder Edit-Modus. +pub enum Mode { + Normal, + Edit(Edit), +} + +/// Zustand des Edit-Modus. Der Text landet erst beim Bestaetigen im Modell. +pub struct Edit { + pub text: String, + /// Byte-Index in `text`. + pub cursor: usize, + /// Per `a`/`A` frisch angelegt — bei Abbruch wieder zu entfernen. + pub is_new: bool, + /// Zustand vor dem Anlegen bzw. vor der Bearbeitung. Anlegen und Benennen + /// gehoeren zusammen und bilden deshalb einen einzigen Undo-Schritt. + before: Snapshot, +} + +impl Edit { + fn new(text: String, is_new: bool, before: Snapshot) -> Self { + Edit { + cursor: text.len(), + text, + is_new, + before, + } + } + + fn insert(&mut self, c: char) { + self.text.insert(self.cursor, c); + self.cursor += c.len_utf8(); + } + + fn backspace(&mut self) { + if let Some(prev) = self.prev_boundary() { + self.text.remove(prev); + self.cursor = prev; + } + } + + fn delete(&mut self) { + if self.cursor < self.text.len() { + self.text.remove(self.cursor); + } + } + + fn left(&mut self) { + if let Some(prev) = self.prev_boundary() { + self.cursor = prev; + } + } + + fn right(&mut self) { + if let Some(c) = self.text[self.cursor..].chars().next() { + self.cursor += c.len_utf8(); + } + } + + /// Byte-Index des vorherigen Zeichens — `String::remove` braucht eine + /// Zeichengrenze, kein Byte-Offset. + fn prev_boundary(&self) -> Option { + let c = self.text[..self.cursor].chars().next_back()?; + Some(self.cursor - c.len_utf8()) + } +} + +/// Meldung in der Statuszeile. +pub struct Status { + pub text: String, + pub is_error: bool, +} + +pub struct App { + pub board: Board, + pub path: PathBuf, + pub focus: AreaKind, + pub mode: Mode, + pub status: Option, + /// Ungespeicherte Aenderungen. + pub dirty: bool, + /// `q` wurde bei ungespeicherten Aenderungen einmal gedrueckt. + quit_armed: bool, + pub should_quit: bool, + /// Terminal beherrscht das Kitty-Keyboard-Protocol, Shift+Enter ist also + /// von Enter unterscheidbar. + pub enhanced_keys: bool, + history: History, +} + +impl App { + pub fn new(board: Board, path: PathBuf, enhanced_keys: bool) -> Self { + App { + board, + path, + focus: AreaKind::Active, + mode: Mode::Normal, + status: None, + dirty: false, + quit_armed: false, + should_quit: false, + enhanced_keys, + history: History::default(), + } + } + + pub fn info(&mut self, text: impl Into) { + self.status = Some(Status { + text: text.into(), + is_error: false, + }); + } + + pub fn error(&mut self, text: impl Into) { + self.status = Some(Status { + text: text.into(), + is_error: true, + }); + } + + pub fn focused_area(&self) -> &Area { + self.board.area(self.focus) + } + + fn focused_area_mut(&mut self) -> &mut Area { + self.board.area_mut(self.focus) + } + + pub fn on_key(&mut self, key: KeyEvent) { + self.status = None; + match self.mode { + Mode::Normal => self.on_key_normal(key), + Mode::Edit(_) => self.on_key_edit(key), + } + } + + fn on_key_normal(&mut self, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + // Jede andere Taste entschaerft ein angefangenes Beenden wieder. + let was_armed = std::mem::take(&mut self.quit_armed); + + match key.code { + KeyCode::Char('q') if !ctrl => self.quit(was_armed), + KeyCode::Char('c') if ctrl => self.quit(was_armed), + KeyCode::Char('s') if ctrl => self.save(), + + // Fokus zwischen den Areas + KeyCode::Tab if !shift => self.focus = rotate(self.focus, true), + KeyCode::Tab | KeyCode::BackTab => self.focus = rotate(self.focus, false), + + // Cursor + KeyCode::Down | KeyCode::Char('j') if !shift => self.focused_area_mut().cursor_down(), + KeyCode::Up | KeyCode::Char('k') if !shift => self.focused_area_mut().cursor_up(), + + // Punkt verschieben + KeyCode::Down | KeyCode::Char('J') => self.reorder(true), + KeyCode::Up | KeyCode::Char('K') => self.reorder(false), + + // Ein- und Ausruecken + KeyCode::Right if shift => self.reparent(true), + KeyCode::Left if shift => self.reparent(false), + KeyCode::Char('L') => self.reparent(true), + KeyCode::Char('H') => self.reparent(false), + + // Auf- und Zuklappen + KeyCode::Right | KeyCode::Char('l') => self.fold(false), + KeyCode::Left | KeyCode::Char('h') => self.fold(true), + + // Markieren. Esc als Zweitbelegung, weil Shift+Space ohne + // Kitty-Protocol nicht von Space zu unterscheiden ist. + KeyCode::Esc => self.focused_area_mut().clear_selection(), + KeyCode::Char(' ') if shift => self.focused_area_mut().clear_selection(), + KeyCode::Char(' ') => { + let area = self.focused_area_mut(); + if let Some(i) = area.cursor_item() { + area.toggle_selection(i); + } + } + + // Zwischen den Areas verschieben + KeyCode::Enter if !shift => self.transfer(true), + KeyCode::Enter | KeyCode::Backspace => self.transfer(false), + + // Rueckgaengig + KeyCode::Char('u') if !ctrl => self.undo(), + KeyCode::Char('z') if ctrl => self.undo(), + KeyCode::Char('U') => self.redo(), + KeyCode::Char('y') if ctrl => self.redo(), + + // Bearbeiten + KeyCode::Char('i') => self.start_edit(), + KeyCode::Char('a') => self.add(false), + KeyCode::Char('A') => self.add(true), + KeyCode::Char('d') => self.delete(), + + _ => {} + } + } + + fn on_key_edit(&mut self, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('c') if ctrl => return self.abort_edit(), + KeyCode::Esc | KeyCode::Enter => return self.commit_edit(), + _ => {} + } + + let Mode::Edit(edit) = &mut self.mode else { + return; + }; + match key.code { + KeyCode::Backspace => edit.backspace(), + KeyCode::Delete => edit.delete(), + KeyCode::Left => edit.left(), + KeyCode::Right => edit.right(), + KeyCode::Home => edit.cursor = 0, + KeyCode::End => edit.cursor = edit.text.len(), + KeyCode::Char(c) if !ctrl => edit.insert(c), + _ => {} + } + } + + // --- Aktionen ------------------------------------------------------- + + fn quit(&mut self, was_armed: bool) { + if self.dirty && !was_armed { + self.quit_armed = true; + self.error("Ungespeicherte Änderungen — nochmal für Verwerfen, Strg+S zum Speichern"); + return; + } + self.should_quit = true; + } + + pub fn save(&mut self) { + match markdown::save(&self.path, &self.board) { + Ok(()) => { + self.dirty = false; + self.info(format!("Gespeichert: {}", self.path.display())); + } + Err(err) => self.error(format!("Speichern fehlgeschlagen: {err}")), + } + } + + /// Zustand fuer die Historie festhalten. + fn snapshot(&self) -> Snapshot { + Snapshot { + board: self.board.clone(), + focus: self.focus, + dirty: self.dirty, + } + } + + fn restore(&mut self, snapshot: Snapshot) { + self.board = snapshot.board; + self.focus = snapshot.focus; + self.dirty = snapshot.dirty; + } + + /// Traegt einen Rueckkehrpunkt ein — nach jeder Aenderung aufzurufen. + fn commit(&mut self, before: Snapshot) { + self.history.record(before); + self.dirty = true; + } + + fn undo(&mut self) { + let current = self.snapshot(); + match self.history.undo(current) { + Some(previous) => { + self.restore(previous); + // Der wiederhergestellte Zustand weicht von der Datei ab, auch + // wenn zwischendurch gespeichert wurde. + self.dirty = true; + self.info("Rückgängig"); + } + None => self.info("Nichts rückgängig zu machen"), + } + } + + fn redo(&mut self) { + let current = self.snapshot(); + match self.history.redo(current) { + Some(next) => { + self.restore(next); + self.dirty = true; + self.info("Wiederhergestellt"); + } + None => self.info("Nichts wiederherzustellen"), + } + } + + fn reorder(&mut self, down: bool) { + let before = self.snapshot(); + let area = self.focused_area_mut(); + let Some(i) = area.cursor_item() else { return }; + let moved = if down { area.move_down(i) } else { area.move_up(i) }; + if let Some(new) = moved { + area.cursor = new; + self.commit(before); + } + } + + fn reparent(&mut self, indent: bool) { + let before = self.snapshot(); + let area = self.focused_area_mut(); + let Some(i) = area.cursor_item() else { return }; + let result = if indent { area.indent(i) } else { area.outdent(i) }; + match result { + Ok(new) => { + area.cursor = new; + self.commit(before); + } + Err(err) => self.error(err.to_string()), + } + } + + fn fold(&mut self, collapse: bool) { + let area = self.focused_area_mut(); + let Some(i) = area.cursor_item() else { return }; + let next = if collapse { + area.collapse_or_parent(i) + } else { + area.expand_or_child(i) + }; + area.cursor = next; + } + + fn transfer(&mut self, down: bool) { + let target = if down { + self.focus.below() + } else { + self.focus.above() + }; + let Some(target) = target else { + let edge = if down { "unterste" } else { "oberste" }; + self.info(format!("{} ist bereits die {edge} Area", self.focus)); + return; + }; + + let before = self.snapshot(); + let report = self.board.move_selection(self.focus, target); + if report.is_empty() { + return; + } + self.commit(before); + + let mut msg = format!("{} → {target}", plural(report.moved)); + if !report.created.is_empty() { + msg.push_str(&format!(", Pfad angelegt: {}", report.created.join("/"))); + } + if !report.merged.is_empty() { + msg.push_str(&format!( + ", zusammengeführt: {}", + report.merged.join(", ") + )); + } + if !report.stubs.is_empty() { + msg.push_str(&format!( + ", zurückgeblieben: {}", + report.stubs.join(", ") + )); + } + self.info(msg); + } + + fn start_edit(&mut self) { + let area = self.focused_area(); + let Some(i) = area.cursor_item() else { + self.info("Kein Punkt zum Bearbeiten — mit a einen anlegen"); + return; + }; + let text = area.items[i].text.clone(); + let before = self.snapshot(); + self.focused_area_mut().cursor = i; + self.mode = Mode::Edit(Edit::new(text, false, before)); + } + + /// Legt einen leeren Punkt an und springt in den Edit-Modus. + /// + /// `as_child` haengt ihn als letztes Kind unter den Cursor, sonst wird er + /// dessen naechstes Geschwister. Beides landet an derselben Stelle in der + /// flachen Liste — nur die Tiefe unterscheidet sich. + fn add(&mut self, as_child: bool) { + let before = self.snapshot(); + let area = self.focused_area_mut(); + let (index, depth) = match area.cursor_item() { + None => (area.len(), 0), + Some(i) => { + if as_child { + area.items[i].collapsed = false; + (area.subtree_end(i), area.items[i].depth + 1) + } else { + (area.subtree_end(i), area.items[i].depth) + } + } + }; + area.items.insert(index, Item::new("", depth)); + area.cursor = index; + self.dirty = true; + self.mode = Mode::Edit(Edit::new(String::new(), true, before)); + } + + fn delete(&mut self) { + let before = self.snapshot(); + let area = self.focused_area_mut(); + let Some(i) = area.cursor_item() else { return }; + let removed = area.remove_subtree(i); + area.clamp_cursor(); + self.commit(before); + self.info(format!("{} gelöscht — u macht es rückgängig", plural(removed.len()))); + } + + fn commit_edit(&mut self) { + let Mode::Edit(edit) = &self.mode else { return }; + let text = edit.text.trim().to_string(); + let is_new = edit.is_new; + let area = self.board.area(self.focus); + let Some(i) = area.cursor_item() else { + self.mode = Mode::Normal; + return; + }; + + // Erst pruefen: bei einem Konflikt bleibt der Edit-Modus offen, der + // Undo-Punkt darf also noch nicht verbraucht werden. + if !text.is_empty() + && let Err(err) = area.validate_rename(i, &text) + { + self.error(format!("{err} — Strg+C bricht ab")); + return; + } + + let unchanged = !is_new && area.items[i].text == text; + let Mode::Edit(edit) = std::mem::replace(&mut self.mode, Mode::Normal) else { + unreachable!("oben schon geprueft"); + }; + let area = self.board.area_mut(self.focus); + + if text.is_empty() { + if area.has_children(i) { + // Ein Parent ohne Namen wuerde seine Kinder unauffindbar + // machen; der alte Name bleibt stehen. + self.error("Punkt mit Unterpunkten braucht einen Namen — unverändert"); + return; + } + area.remove_subtree(i); + area.clamp_cursor(); + if is_new { + // Anlegen und Abbrechen heben sich auf, kein Undo-Schritt. + self.restore(edit.before); + } else { + self.commit(edit.before); + self.info("Leerer Punkt gelöscht"); + } + return; + } + + if unchanged { + return; + } + area.items[i].text = text; + self.commit(edit.before); + } + + fn abort_edit(&mut self) { + let Mode::Edit(edit) = std::mem::replace(&mut self.mode, Mode::Normal) else { + return; + }; + // Der Schnappschuss macht sowohl einen frisch angelegten Punkt als auch + // eine begonnene Umbenennung zurueck. + self.restore(edit.before); + self.info("Abgebrochen"); + } +} + +/// Naechste bzw. vorherige Area im Kreis. +fn rotate(focus: AreaKind, forward: bool) -> AreaKind { + let order = AreaKind::ALL; + let i = focus.index(); + let next = if forward { + (i + 1) % order.len() + } else { + (i + order.len() - 1) % order.len() + }; + order[next] +} + +fn plural(n: usize) -> String { + if n == 1 { + "1 Punkt".to_string() + } else { + format!("{n} Punkte") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyEventKind, KeyEventState}; + + fn app(src: &str) -> App { + let board = markdown::parse(src).expect("parse").board; + let mut app = App::new(board, PathBuf::from("board.md"), true); + app.focus = AreaKind::ToDo; + app + } + + fn press(app: &mut App, code: KeyCode, mods: KeyModifiers) { + app.on_key(KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }); + } + + fn key(app: &mut App, c: char) { + let mods = if c.is_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + press(app, KeyCode::Char(c), mods); + } + + fn dump(app: &App, kind: AreaKind) -> String { + app.board + .area(kind) + .items + .iter() + .map(|i| format!("{}{}", " ".repeat(i.depth), i.text)) + .collect::>() + .join("\n") + } + + const BOARD: &str = "# To Do\n- a\n - a1\n - a2\n- b\n# Active\n# Done\n"; + + #[test] + fn navigation_and_reordering() { + let mut a = app(BOARD); + key(&mut a, 'j'); + assert_eq!(a.focused_area().cursor, 1); // "a1" + key(&mut a, 'J'); // hinter "a2" + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a2\n a1\nb"); + assert!(a.dirty); + } + + #[test] + fn tab_rotates_through_the_areas() { + let mut a = app(BOARD); + press(&mut a, KeyCode::Tab, KeyModifiers::NONE); + assert_eq!(a.focus, AreaKind::Active); + press(&mut a, KeyCode::BackTab, KeyModifiers::SHIFT); + assert_eq!(a.focus, AreaKind::ToDo); + press(&mut a, KeyCode::BackTab, KeyModifiers::SHIFT); + assert_eq!(a.focus, AreaKind::Done, "rotiert im Kreis"); + } + + #[test] + fn enter_moves_down_backspace_moves_up() { + let mut a = app(BOARD); + key(&mut a, 'j'); // "a1" + press(&mut a, KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::Active), "a\n a1"); + assert_eq!(a.focus, AreaKind::ToDo, "Fokus bleibt in der Quell-Area"); + + a.focus = AreaKind::Active; + press(&mut a, KeyCode::Backspace, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a2\n a1\nb"); + } + + #[test] + fn shift_enter_moves_up_as_well() { + let mut a = app(BOARD); + a.focus = AreaKind::Done; + a.board.area_mut(AreaKind::Done).items.push(Item::new("x", 0)); + press(&mut a, KeyCode::Enter, KeyModifiers::SHIFT); + assert_eq!(dump(&a, AreaKind::Active), "x"); + } + + #[test] + fn transfer_at_the_edge_only_reports() { + let mut a = app(BOARD); + press(&mut a, KeyCode::Backspace, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + assert!(!a.dirty); + assert!(a.status.is_some()); + } + + #[test] + fn space_selects_and_shift_space_clears() { + let mut a = app(BOARD); + key(&mut a, ' '); + assert_eq!(a.focused_area().selected_items(), vec![0, 1, 2]); + press(&mut a, KeyCode::Char(' '), KeyModifiers::SHIFT); + assert!(!a.focused_area().has_selection()); + + // Esc tut dasselbe, fuer Terminals ohne Kitty-Protocol. + key(&mut a, ' '); + assert!(a.focused_area().has_selection()); + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert!(!a.focused_area().has_selection()); + } + + #[test] + fn indent_and_outdent() { + let mut a = app(BOARD); + a.focused_area_mut().cursor = 3; // "b" + key(&mut a, 'L'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\n b"); + key(&mut a, 'H'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + } + + #[test] + fn indent_conflict_is_reported_without_changing_anything() { + let mut a = app("# To Do\n- p\n - x\n- x\n# Active\n# Done\n"); + a.focused_area_mut().cursor = 2; // "x" auf oberster Ebene + key(&mut a, 'L'); + assert_eq!(dump(&a, AreaKind::ToDo), "p\n x\nx"); + assert!(a.status.as_ref().unwrap().is_error); + } + + #[test] + fn h_and_l_fold_without_moving_items() { + let mut a = app(BOARD); + key(&mut a, 'h'); + assert!(a.focused_area().items[0].collapsed); + key(&mut a, 'j'); + assert_eq!(a.focused_area().cursor, 3, "Cursor springt ueber die Kinder"); + key(&mut a, 'k'); + key(&mut a, 'l'); + assert!(!a.focused_area().items[0].collapsed); + assert!(!a.dirty, "Falten aendert das Board nicht"); + } + + #[test] + fn edit_replaces_the_text() { + let mut a = app(BOARD); + key(&mut a, 'i'); + press(&mut a, KeyCode::Backspace, KeyModifiers::NONE); + key(&mut a, 'z'); + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "z\n a1\n a2\nb"); + assert!(matches!(a.mode, Mode::Normal)); + } + + #[test] + fn edit_rejects_a_duplicate_name_and_stays_open() { + let mut a = app(BOARD); + a.focused_area_mut().cursor = 1; // "a1" + key(&mut a, 'i'); + press(&mut a, KeyCode::Backspace, KeyModifiers::NONE); + key(&mut a, '2'); // -> "a2" + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert!(matches!(a.mode, Mode::Edit(_)), "bleibt im Edit-Modus"); + assert!(a.status.as_ref().unwrap().is_error); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + + press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL); + assert!(matches!(a.mode, Mode::Normal)); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + } + + #[test] + fn a_adds_a_sibling_after_the_whole_subtree() { + let mut a = app(BOARD); + key(&mut a, 'a'); // Cursor auf "a", das Kinder hat + for c in "neu".chars() { + key(&mut a, c); + } + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nneu\nb"); + } + + #[test] + fn shift_a_adds_a_child() { + let mut a = app(BOARD); + key(&mut a, 'A'); + for c in "a3".chars() { + key(&mut a, c); + } + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\n a3\nb"); + } + + #[test] + fn new_item_left_empty_disappears() { + let mut a = app(BOARD); + key(&mut a, 'a'); + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + + key(&mut a, 'a'); + press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + } + + #[test] + fn emptying_a_parent_keeps_its_name() { + let mut a = app(BOARD); + key(&mut a, 'i'); + press(&mut a, KeyCode::Backspace, KeyModifiers::NONE); + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + assert!(a.status.as_ref().unwrap().is_error); + } + + #[test] + fn add_into_an_empty_area() { + let mut a = app(BOARD); + a.focus = AreaKind::Active; + key(&mut a, 'a'); + key(&mut a, 'x'); + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::Active), "x"); + } + + #[test] + fn delete_removes_the_whole_subtree() { + let mut a = app(BOARD); + key(&mut a, 'd'); + assert_eq!(dump(&a, AreaKind::ToDo), "b"); + assert_eq!(a.focused_area().cursor, 0); + } + + #[test] + fn editing_unicode_text() { + let mut a = app("# To Do\n- ä\n# Active\n# Done\n"); + key(&mut a, 'i'); + key(&mut a, 'ö'); // "äö" + press(&mut a, KeyCode::Left, KeyModifiers::NONE); + press(&mut a, KeyCode::Backspace, KeyModifiers::NONE); + key(&mut a, 'ü'); // "üö" + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "üö"); + } + + // --- Undo / Redo ---------------------------------------------------- + + #[test] + fn u_undoes_a_deletion_and_shift_u_redoes_it() { + let mut a = app(BOARD); + key(&mut a, 'd'); + assert_eq!(dump(&a, AreaKind::ToDo), "b"); + + key(&mut a, 'u'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + key(&mut a, 'U'); + assert_eq!(dump(&a, AreaKind::ToDo), "b"); + } + + #[test] + fn ctrl_z_and_ctrl_y_work_as_well() { + let mut a = app(BOARD); + key(&mut a, 'd'); + press(&mut a, KeyCode::Char('z'), KeyModifiers::CONTROL); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + press(&mut a, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert_eq!(dump(&a, AreaKind::ToDo), "b"); + } + + #[test] + fn several_steps_unwind_in_order() { + let mut a = app(BOARD); + key(&mut a, 'J'); // "a" hinter "b" + key(&mut a, 'd'); // "a" samt Kindern weg + assert_eq!(dump(&a, AreaKind::ToDo), "b"); + + key(&mut a, 'u'); + assert_eq!(dump(&a, AreaKind::ToDo), "b\na\n a1\n a2"); + key(&mut a, 'u'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + assert!(!a.status.as_ref().unwrap().is_error); + } + + #[test] + fn undo_restores_both_areas_of_a_transfer() { + let mut a = app(BOARD); + a.focused_area_mut().cursor = 1; // "a1" + press(&mut a, KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::Active), "a\n a1"); + + key(&mut a, 'u'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + assert_eq!(dump(&a, AreaKind::Active), ""); + } + + #[test] + fn undo_returns_to_the_area_the_change_happened_in() { + let mut a = app(BOARD); + key(&mut a, 'd'); + press(&mut a, KeyCode::Tab, KeyModifiers::NONE); + assert_eq!(a.focus, AreaKind::Active); + + key(&mut a, 'u'); + assert_eq!(a.focus, AreaKind::ToDo, "Fokus zeigt, was sich aendert"); + } + + #[test] + fn adding_and_naming_is_a_single_undo_step() { + let mut a = app(BOARD); + key(&mut a, 'a'); + for c in "neu".chars() { + key(&mut a, c); + } + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nneu\nb"); + + key(&mut a, 'u'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + } + + #[test] + fn an_abandoned_new_item_leaves_no_undo_step() { + let mut a = app(BOARD); + key(&mut a, 'd'); // ein echter Schritt zum Zurueckgehen + key(&mut a, 'a'); + press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL); + + key(&mut a, 'u'); + assert_eq!( + dump(&a, AreaKind::ToDo), + "a\n a1\n a2\nb", + "das u muss beim Loeschen landen, nicht beim leeren Punkt" + ); + } + + #[test] + fn an_edit_that_changes_nothing_leaves_no_undo_step() { + let mut a = app(BOARD); + key(&mut a, 'd'); + key(&mut a, 'i'); + press(&mut a, KeyCode::Esc, KeyModifiers::NONE); + + key(&mut a, 'u'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + } + + #[test] + fn aborting_an_edit_restores_the_old_name() { + let mut a = app(BOARD); + key(&mut a, 'i'); + for c in "xyz".chars() { + key(&mut a, c); + } + press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + assert!(!a.dirty); + } + + #[test] + fn folding_and_selecting_are_not_undoable_steps() { + let mut a = app(BOARD); + key(&mut a, 'd'); + key(&mut a, ' '); + key(&mut a, 'h'); + + key(&mut a, 'u'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + } + + #[test] + fn a_new_change_discards_the_redo_chain() { + let mut a = app(BOARD); + key(&mut a, 'd'); + key(&mut a, 'u'); + key(&mut a, 'J'); // "a" hinter "b" — zweigt ab + key(&mut a, 'U'); + assert_eq!( + dump(&a, AreaKind::ToDo), + "b\na\n a1\n a2", + "nichts wiederherzustellen" + ); + } + + #[test] + fn undo_without_history_only_reports() { + let mut a = app(BOARD); + key(&mut a, 'u'); + assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb"); + assert!(a.status.is_some()); + assert!(!a.dirty); + } + + #[test] + fn quit_asks_once_when_there_are_unsaved_changes() { + let mut a = app(BOARD); + key(&mut a, 'd'); + assert!(a.dirty); + key(&mut a, 'q'); + assert!(!a.should_quit); + assert!(a.status.as_ref().unwrap().is_error); + key(&mut a, 'q'); + assert!(a.should_quit); + } + + #[test] + fn any_other_key_disarms_quitting() { + let mut a = app(BOARD); + key(&mut a, 'd'); + key(&mut a, 'q'); + key(&mut a, 'j'); + key(&mut a, 'q'); + assert!(!a.should_quit, "erneut nachfragen"); + } + + #[test] + fn clean_board_quits_immediately() { + let mut a = app(BOARD); + key(&mut a, 'q'); + assert!(a.should_quit); + } + + #[test] + fn ctrl_c_quits_in_normal_mode() { + let mut a = app(BOARD); + press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL); + assert!(a.should_quit); + } + + #[test] + fn keys_on_an_empty_area_do_nothing() { + let mut a = app("# To Do\n# Active\n# Done\n"); + for c in "jkJKhlHLd i".chars() { + key(&mut a, c); + } + press(&mut a, KeyCode::Enter, KeyModifiers::NONE); + assert!(a.board.is_empty()); + assert!(!a.should_quit); + } +} diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..23d7963 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,135 @@ +//! Undo/Redo ueber Schnappschuesse. +//! +//! Statt fuer jede Operation eine Inverse zu pflegen, wird vor jeder Aenderung +//! der komplette Zustand kopiert. Das ist bei einer Stichpunktliste billig und +//! spart die unangenehmen Faelle: die Inverse eines Cross-Area-Moves muesste +//! wissen, welche Ancestors neu entstanden und welche nur mitbenutzt wurden, +//! und die eines Merges, welche Kinder vorher wo hingen. +//! +//! Preis dieser Entscheidung: Undo stellt auch Cursor-Position und +//! Klapp-Zustand von damals wieder her, nicht nur die Struktur. + +use std::collections::VecDeque; + +use crate::model::{AreaKind, Board}; + +/// So viele Schritte lassen sich zurueckgehen. +const LIMIT: usize = 100; + +/// Vollstaendiger Zustand zu einem Zeitpunkt. +#[derive(Debug, Clone)] +pub struct Snapshot { + pub board: Board, + pub focus: AreaKind, + pub dirty: bool, +} + +#[derive(Debug, Default)] +pub struct History { + undo: VecDeque, + redo: Vec, +} + +impl History { + /// Merkt den Zustand *vor* einer Aenderung als Rueckkehrpunkt. + pub fn record(&mut self, before: Snapshot) { + // Ein neuer Zweig macht die alte Redo-Kette gegenstandslos. + self.redo.clear(); + self.undo.push_back(before); + while self.undo.len() > LIMIT { + self.undo.pop_front(); + } + } + + /// Tauscht den aktuellen Zustand gegen den davor. + pub fn undo(&mut self, current: Snapshot) -> Option { + let previous = self.undo.pop_back()?; + self.redo.push(current); + Some(previous) + } + + /// Macht ein Undo wieder rueckgaengig. + pub fn redo(&mut self, current: Snapshot) -> Option { + let next = self.redo.pop()?; + self.undo.push_back(current); + Some(next) + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Item; + + fn snap(name: &str) -> Snapshot { + let mut board = Board::new(); + board + .area_mut(AreaKind::ToDo) + .items + .push(Item::new(name, 0)); + Snapshot { + board, + focus: AreaKind::ToDo, + dirty: true, + } + } + + fn name(snap: &Snapshot) -> String { + snap.board.area(AreaKind::ToDo).items[0].text.clone() + } + + #[test] + fn undo_and_redo_walk_the_chain() { + let mut h = History::default(); + h.record(snap("a")); // a -> b + h.record(snap("b")); // b -> c + + let back = h.undo(snap("c")).expect("undo"); + assert_eq!(name(&back), "b"); + let back = h.undo(snap("b")).expect("undo"); + assert_eq!(name(&back), "a"); + assert!(!h.can_undo()); + + let forward = h.redo(snap("a")).expect("redo"); + assert_eq!(name(&forward), "b"); + let forward = h.redo(snap("b")).expect("redo"); + assert_eq!(name(&forward), "c"); + assert!(!h.can_redo()); + } + + #[test] + fn a_new_change_discards_the_redo_chain() { + let mut h = History::default(); + h.record(snap("a")); + h.undo(snap("b")); + assert!(h.can_redo()); + + h.record(snap("x")); + assert!(!h.can_redo(), "abgezweigt"); + } + + #[test] + fn nothing_to_undo_returns_none() { + let mut h = History::default(); + assert!(h.undo(snap("a")).is_none()); + assert!(h.redo(snap("a")).is_none()); + } + + #[test] + fn the_oldest_steps_fall_out_at_the_limit() { + let mut h = History::default(); + for i in 0..LIMIT + 10 { + h.record(snap(&i.to_string())); + } + assert_eq!(h.undo.len(), LIMIT); + assert_eq!(name(h.undo.front().unwrap()), "10", "aelteste verworfen"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bef15d1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,88 @@ +// Die Modell- und Historien-API ist bewusst vollstaendig; einzelne Zugriffe +// darauf braucht bisher nur die Testsuite. +#![allow(dead_code)] + +mod app; +mod history; +mod markdown; +mod model; +mod ops; +mod transfer; +mod ui; + +use std::io::{self, stdout}; +use std::path::PathBuf; +use std::process::ExitCode; + +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{ + self, Event, KeyEventKind, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, +}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::supports_keyboard_enhancement; + +use app::App; + +/// Board-Datei, wenn kein Pfad uebergeben wurde. +const DEFAULT_PATH: &str = "board.md"; + +fn main() -> ExitCode { + let path: PathBuf = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_PATH)); + + let parsed = match markdown::load(&path) { + Ok(parsed) => parsed, + Err(err) => { + eprintln!("Fehler: {err}"); + return ExitCode::FAILURE; + } + }; + + // Shift+Enter ist von Enter nur unterscheidbar, wenn das Terminal das + // Kitty-Keyboard-Protocol beherrscht. Backspace tut es sonst auch. + let enhanced = supports_keyboard_enhancement().unwrap_or(false); + let mut app = App::new(parsed.board, path, enhanced); + if let Some(warning) = parsed.warnings.first() { + app.error(warning.clone()); + } + + let mut terminal = ratatui::init(); + if enhanced { + let _ = execute!( + stdout(), + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + } + + let result = run(&mut terminal, &mut app); + + if enhanced { + let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + } + ratatui::restore(); + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("Fehler: {err}"); + ExitCode::FAILURE + } + } +} + +fn run(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> { + while !app.should_quit { + terminal.draw(|frame| ui::draw(frame, app))?; + // Nur Press auswerten: mit aktivem Kitty-Protocol liefert das Terminal + // sonst zusaetzlich Release-Events und jede Taste feuerte doppelt. + if let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + app.on_key(key); + } + } + Ok(()) +} diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..f7f8bcb --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,502 @@ +//! Markdown als Persistenzformat. +//! +//! Die drei Areas sind L1-Header, die Stichpunkte eine ungeordnete Liste, deren +//! Verschachtelung ueber die Einrueckung abgebildet wird: +//! +//! ```markdown +//! # To Do +//! +//! - Hausaufgaben +//! - Mathe +//! +//! # Active +//! +//! # Done +//! ``` +//! +//! Geschrieben wird immer mit [`INDENT`] Leerzeichen pro Ebene; gelesen wird +//! toleranter (beliebige, auch inkonsistente Einrueckung sowie Tabs), damit von +//! Hand editierte Dateien nicht verloren gehen. + +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::model::{Area, AreaKind, Board, Item}; + +/// Leerzeichen pro Verschachtelungsebene beim Schreiben. +pub const INDENT: usize = 2; + +/// Ein Tab zaehlt beim Lesen als so viele Spalten. +const TAB_WIDTH: usize = 4; + +/// Strukturfehler, die ein Laden unmoeglich machen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError { + /// 1-basierte Zeilennummer. + pub line: usize, + pub kind: ParseErrorKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseErrorKind { + /// L1-Header, der keiner der drei Areas entspricht. + UnknownHeader(String), + /// Dieselbe Area taucht ein zweites Mal auf. + DuplicateHeader(AreaKind), + /// Stichpunkt, bevor ein Header die Area festgelegt hat. + ItemBeforeHeader, + /// Weder Leerzeile, noch Header, noch Listenpunkt. + UnexpectedLine(String), +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Zeile {}: ", self.line)?; + match &self.kind { + ParseErrorKind::UnknownHeader(name) => { + write!(f, "unbekannter Header {name:?} (erwartet: To Do, Active, Done)") + } + ParseErrorKind::DuplicateHeader(kind) => write!(f, "Header {kind} kommt mehrfach vor"), + ParseErrorKind::ItemBeforeHeader => { + write!(f, "Stichpunkt ohne vorangehenden Area-Header") + } + ParseErrorKind::UnexpectedLine(text) => write!(f, "unerwarteter Inhalt: {text:?}"), + } + } +} + +impl std::error::Error for ParseError {} + +/// Ergebnis des Parsens: das Board plus nicht-fatale Auffaelligkeiten. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Parsed { + pub board: Board, + /// Z.B. Namensgleichheit unter Geschwistern in einer handeditierten Datei. + pub warnings: Vec, +} + +/// Liest ein Board aus Markdown. +pub fn parse(input: &str) -> Result { + let mut board = Board::new(); + let mut current: Option = None; + let mut seen: Vec = Vec::new(); + // Einrueckungsbreite je Ebene; `stack.len() - 1` ist die aktuelle Tiefe. + let mut indents: Vec = Vec::new(); + + for (idx, raw) in input.lines().enumerate() { + let line_no = idx + 1; + let line = raw.trim_end(); + if line.trim().is_empty() { + continue; + } + + if let Some(rest) = header_body(line) { + let kind = parse_header(rest).ok_or_else(|| ParseError { + line: line_no, + kind: ParseErrorKind::UnknownHeader(rest.to_string()), + })?; + if seen.contains(&kind) { + return Err(ParseError { + line: line_no, + kind: ParseErrorKind::DuplicateHeader(kind), + }); + } + seen.push(kind); + current = Some(kind); + indents.clear(); + continue; + } + + let Some((indent, text)) = split_bullet(line) else { + return Err(ParseError { + line: line_no, + kind: ParseErrorKind::UnexpectedLine(line.trim().to_string()), + }); + }; + let Some(kind) = current else { + return Err(ParseError { + line: line_no, + kind: ParseErrorKind::ItemBeforeHeader, + }); + }; + + let depth = depth_for(&mut indents, indent); + board.area_mut(kind).items.push(Item::new(text, depth)); + } + + let mut warnings = Vec::new(); + for (kind, area) in board.areas() { + for (_, name) in area.duplicate_siblings() { + warnings.push(format!( + "{kind}: {name:?} kommt mehrfach auf derselben Ebene vor" + )); + } + } + + Ok(Parsed { board, warnings }) +} + +/// Schreibt das Board als Markdown. Roundtrip-stabil zu [`parse`]. +pub fn render(board: &Board) -> String { + let mut out = String::new(); + for (kind, area) in board.areas() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("# "); + out.push_str(kind.title()); + out.push('\n'); + if !area.is_empty() { + out.push('\n'); + render_area(area, &mut out); + } + } + out +} + +fn render_area(area: &Area, out: &mut String) { + for item in &area.items { + for _ in 0..item.depth * INDENT { + out.push(' '); + } + out.push('-'); + if !item.text.is_empty() { + out.push(' '); + out.push_str(&item.text); + } + out.push('\n'); + } +} + +/// Fehler beim Laden von der Platte. +#[derive(Debug)] +pub enum LoadError { + Io { path: PathBuf, source: io::Error }, + Parse { path: PathBuf, source: ParseError }, +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LoadError::Io { path, source } => write!(f, "{}: {source}", path.display()), + LoadError::Parse { path, source } => write!(f, "{}: {source}", path.display()), + } + } +} + +impl std::error::Error for LoadError {} + +/// Laedt ein Board. Eine nicht existierende Datei ergibt ein leeres Board. +pub fn load(path: &Path) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Ok(Parsed { + board: Board::new(), + warnings: Vec::new(), + }); + } + Err(source) => { + return Err(LoadError::Io { + path: path.to_path_buf(), + source, + }); + } + }; + parse(&text).map_err(|source| LoadError::Parse { + path: path.to_path_buf(), + source, + }) +} + +/// Speichert das Board. Schreibt erst daneben und benennt dann um, damit ein +/// abgebrochener Schreibvorgang die bestehende Datei nicht zerstoert. +pub fn save(path: &Path, board: &Board) -> io::Result<()> { + let tmp = path.with_extension("md.tmp"); + std::fs::write(&tmp, render(board))?; + std::fs::rename(&tmp, path) +} + +/// Inhalt hinter einem L1-Header (`# ...`), sonst `None`. +fn header_body(line: &str) -> Option<&str> { + let rest = line.strip_prefix('#')?; + if rest.starts_with('#') { + return None; // L2+ interessiert uns nicht als Area-Header + } + Some(rest.trim()) +} + +fn parse_header(name: &str) -> Option { + let normalized: String = name + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + match normalized.as_str() { + "todo" => Some(AreaKind::ToDo), + "active" => Some(AreaKind::Active), + "done" => Some(AreaKind::Done), + _ => None, + } +} + +/// Zerlegt eine Listenzeile in (Einrueckungsbreite, Text). +fn split_bullet(line: &str) -> Option<(usize, &str)> { + let mut indent = 0; + let mut rest = line; + for (i, ch) in line.char_indices() { + match ch { + ' ' => indent += 1, + '\t' => indent += TAB_WIDTH - indent % TAB_WIDTH, + _ => { + rest = &line[i..]; + break; + } + } + } + + let marker = rest.chars().next()?; + if !matches!(marker, '-' | '*' | '+') { + return None; + } + let after = &rest[marker.len_utf8()..]; + match after.chars().next() { + None => Some((indent, "")), + Some(c) if c.is_whitespace() => Some((indent, after.trim())), + // "-foo" ist kein Listenpunkt, sondern Fliesstext. + Some(_) => None, + } +} + +/// Bildet eine Einrueckungsbreite auf eine Tiefe ab und pflegt dabei den Stack. +/// +/// Groessere Einrueckung als bisher oeffnet genau eine Ebene; kleinere schliesst +/// so viele, bis es wieder passt. Damit bleibt die Tiefen-Invariante des Modells +/// auch bei krummer Einrueckung erhalten. +fn depth_for(indents: &mut Vec, indent: usize) -> usize { + match indents.last() { + None => { + indents.push(indent); + 0 + } + Some(&top) if indent > top => { + indents.push(indent); + indents.len() - 1 + } + _ => { + while indents.len() > 1 && indent < indents[indents.len() - 1] { + indents.pop(); + } + let last = indents.len() - 1; + // Auf die tatsaechliche Breite dieser Ebene einschnappen. + indents[last] = indents[last].min(indent); + last + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn depths(area: &Area) -> Vec<(usize, &str)> { + area.items + .iter() + .map(|i| (i.depth, i.text.as_str())) + .collect() + } + + fn parse_ok(input: &str) -> Board { + let parsed = parse(input).expect("parse"); + assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings); + for (kind, area) in parsed.board.areas() { + area.check_invariants().unwrap_or_else(|e| panic!("{kind}: {e}")); + } + parsed.board + } + + const SAMPLE: &str = "\ +# To Do + +- Einkaufen + - Milch + - Brot + - Vollkorn +- Steuer + +# Active + +- Kanban-Board + - Datenmodell + +# Done + +- Rust lernen +"; + + #[test] + fn parses_areas_and_nesting() { + let board = parse_ok(SAMPLE); + assert_eq!( + depths(board.area(AreaKind::ToDo)), + vec![ + (0, "Einkaufen"), + (1, "Milch"), + (1, "Brot"), + (2, "Vollkorn"), + (0, "Steuer"), + ] + ); + assert_eq!( + depths(board.area(AreaKind::Active)), + vec![(0, "Kanban-Board"), (1, "Datenmodell")] + ); + assert_eq!(depths(board.area(AreaKind::Done)), vec![(0, "Rust lernen")]); + } + + #[test] + fn roundtrip_is_stable() { + let board = parse_ok(SAMPLE); + let rendered = render(&board); + assert_eq!(rendered, SAMPLE); + assert_eq!(parse_ok(&rendered), board); + } + + #[test] + fn empty_board_renders_all_headers() { + let rendered = render(&Board::new()); + assert_eq!(rendered, "# To Do\n\n# Active\n\n# Done\n"); + assert!(parse_ok(&rendered).is_empty()); + } + + #[test] + fn missing_areas_are_empty() { + let board = parse_ok("# Done\n- fertig\n"); + assert!(board.area(AreaKind::ToDo).is_empty()); + assert!(board.area(AreaKind::Active).is_empty()); + assert_eq!(depths(board.area(AreaKind::Done)), vec![(0, "fertig")]); + } + + #[test] + fn header_spellings() { + for (text, expected) in [ + ("To Do", AreaKind::ToDo), + ("todo", AreaKind::ToDo), + ("TO-DO", AreaKind::ToDo), + ("Active", AreaKind::Active), + ("DONE", AreaKind::Done), + ] { + assert_eq!(parse_header(text), Some(expected), "{text}"); + } + assert_eq!(parse_header("Backlog"), None); + } + + #[test] + fn tolerates_odd_indentation_and_markers() { + // Ein Tab zaehlt als 4 Spalten, "c" liegt also auf derselben Ebene wie "b". + let board = parse_ok("# To Do\n* a\n * b\n\t* c\n\t\t* d\n * e\n+ f\n"); + assert_eq!( + depths(board.area(AreaKind::ToDo)), + vec![(0, "a"), (1, "b"), (1, "c"), (2, "d"), (0, "e"), (0, "f")] + ); + } + + #[test] + fn ambiguous_indent_snaps_to_enclosing_level() { + // "b" liegt mit 3 Spalten zwischen den Ebenen 0 und 4 -> aeussere Ebene. + let board = parse_ok("# To Do\n- a\n - a1\n - b\n"); + assert_eq!( + depths(board.area(AreaKind::ToDo)), + vec![(0, "a"), (1, "a1"), (0, "b")] + ); + } + + #[test] + fn indent_never_jumps_more_than_one_level() { + let board = parse_ok("# To Do\n- a\n - b\n- c\n"); + assert_eq!( + depths(board.area(AreaKind::ToDo)), + vec![(0, "a"), (1, "b"), (0, "c")] + ); + } + + #[test] + fn indent_stack_resets_between_areas() { + let board = parse_ok("# To Do\n - a\n# Active\n- b\n - c\n"); + assert_eq!(depths(board.area(AreaKind::ToDo)), vec![(0, "a")]); + assert_eq!(depths(board.area(AreaKind::Active)), vec![(0, "b"), (1, "c")]); + } + + #[test] + fn empty_bullet_roundtrips() { + let board = parse_ok("# To Do\n-\n- x\n"); + assert_eq!(depths(board.area(AreaKind::ToDo)), vec![(0, ""), (0, "x")]); + assert_eq!(render(&board), "# To Do\n\n-\n- x\n\n# Active\n\n# Done\n"); + } + + #[test] + fn duplicate_siblings_warn_but_load() { + let parsed = parse("# To Do\n- a\n- a\n").expect("parse"); + assert_eq!(parsed.board.area(AreaKind::ToDo).len(), 2); + assert_eq!(parsed.warnings.len(), 1); + assert!(parsed.warnings[0].contains("To Do")); + } + + #[test] + fn rejects_unknown_header() { + let err = parse("# Backlog\n- a\n").unwrap_err(); + assert_eq!(err.line, 1); + assert!(matches!(err.kind, ParseErrorKind::UnknownHeader(_))); + } + + #[test] + fn rejects_duplicate_header() { + let err = parse("# To Do\n# To Do\n").unwrap_err(); + assert_eq!(err.kind, ParseErrorKind::DuplicateHeader(AreaKind::ToDo)); + } + + #[test] + fn rejects_item_before_header() { + let err = parse("- a\n").unwrap_err(); + assert_eq!(err.kind, ParseErrorKind::ItemBeforeHeader); + } + + #[test] + fn rejects_free_text() { + let err = parse("# To Do\nblabla\n").unwrap_err(); + assert_eq!(err.line, 2); + assert!(matches!(err.kind, ParseErrorKind::UnexpectedLine(_))); + } + + #[test] + fn text_with_markdown_chars_survives_roundtrip() { + let input = "# To Do\n\n- - nicht wirklich ein bullet\n- #1 mit Raute\n"; + let board = parse_ok(input); + assert_eq!( + depths(board.area(AreaKind::ToDo)), + vec![(0, "- nicht wirklich ein bullet"), (0, "#1 mit Raute")] + ); + } + + #[test] + fn load_of_missing_file_yields_empty_board() { + let path = Path::new("/nonexistent-kanban-dir-xyz/board.md"); + let parsed = load(path).expect("load"); + assert!(parsed.board.is_empty()); + } + + #[test] + fn save_then_load_roundtrips() { + let dir = std::env::temp_dir().join(format!("kanban-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("board.md"); + + let board = parse_ok(SAMPLE); + save(&path, &board).expect("save"); + let parsed = load(&path).expect("load"); + assert_eq!(parsed.board, board); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..f444fa1 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,429 @@ +//! Datenmodell des Boards. +//! +//! Der Baum wird bewusst *flach* gehalten: jede Area ist ein `Vec`, die +//! Verschachtelung steckt allein in `Item::depth`. Ein Subtree ist damit ein +//! zusammenhaengender Slice, was Reorder-, Indent- und Move-Operationen auf +//! simple Slice-Manipulationen reduziert. +//! +//! Invarianten (siehe [`Area::check_invariants`]): +//! - `items[0].depth == 0` +//! - `items[i].depth <= items[i - 1].depth + 1` + +use std::fmt; + +/// Die drei Areas, von oben nach unten so, wie sie dargestellt werden. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AreaKind { + ToDo, + Active, + Done, +} + +impl AreaKind { + pub const ALL: [AreaKind; 3] = [AreaKind::ToDo, AreaKind::Active, AreaKind::Done]; + + /// Titel, wie er als L1-Header in der Markdown-Datei steht. + pub fn title(self) -> &'static str { + match self { + AreaKind::ToDo => "To Do", + AreaKind::Active => "Active", + AreaKind::Done => "Done", + } + } + + pub fn index(self) -> usize { + match self { + AreaKind::ToDo => 0, + AreaKind::Active => 1, + AreaKind::Done => 2, + } + } + + /// Die Area eine Stufe weiter Richtung `Done`. + pub fn below(self) -> Option { + match self { + AreaKind::ToDo => Some(AreaKind::Active), + AreaKind::Active => Some(AreaKind::Done), + AreaKind::Done => None, + } + } + + /// Die Area eine Stufe zurueck Richtung `To Do`. + pub fn above(self) -> Option { + match self { + AreaKind::ToDo => None, + AreaKind::Active => Some(AreaKind::ToDo), + AreaKind::Done => Some(AreaKind::Active), + } + } +} + +impl fmt::Display for AreaKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.title()) + } +} + +/// Ein einzelner Stichpunkt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Item { + pub text: String, + pub depth: usize, + /// Per Leertaste markiert. Nicht persistiert. + pub selected: bool, + /// Subtree eingeklappt. Nicht persistiert. + pub collapsed: bool, +} + +impl Item { + pub fn new(text: impl Into, depth: usize) -> Self { + Item { + text: text.into(), + depth, + selected: false, + collapsed: false, + } + } +} + +/// Eine Area mit ihrer Stichpunktliste und dem zugehoerigen UI-Zustand. +/// +/// Cursor und Markierungen werden pro Area gehalten, bleiben beim Wechsel per +/// Tab also erhalten. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Area { + pub items: Vec, + /// Index in `items`. Bei leerer Liste bedeutungslos (0). + pub cursor: usize, + /// Index der obersten sichtbaren Zeile. + pub scroll: usize, +} + +impl Area { + pub fn new() -> Self { + Self::default() + } + + pub fn from_items(items: Vec) -> Self { + Area { + items, + cursor: 0, + scroll: 0, + } + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn len(&self) -> usize { + self.items.len() + } + + /// Exklusives Ende des Subtrees, der bei `i` beginnt. + /// + /// `[i, subtree_end(i))` umfasst den Punkt selbst und alle Nachfahren. + pub fn subtree_end(&self, i: usize) -> usize { + let depth = self.items[i].depth; + let mut end = i + 1; + while end < self.items.len() && self.items[end].depth > depth { + end += 1; + } + end + } + + /// Anzahl Zeilen des Subtrees bei `i` (>= 1). + pub fn subtree_len(&self, i: usize) -> usize { + self.subtree_end(i) - i + } + + pub fn has_children(&self, i: usize) -> bool { + self.subtree_len(i) > 1 + } + + /// Index des Parents von `i`, sofern `i` nicht auf oberster Ebene liegt. + pub fn parent_of(&self, i: usize) -> Option { + let depth = self.items[i].depth; + if depth == 0 { + return None; + } + (0..i).rev().find(|&j| self.items[j].depth < depth) + } + + /// Ancestor-Indizes von der Wurzel bis zum direkten Parent von `i`. + /// + /// Basis fuer den Cross-Area-Move: daraus entsteht der Pfad, der in der + /// Ziel-Area ggf. neu angelegt werden muss. + pub fn ancestors(&self, i: usize) -> Vec { + let mut path = Vec::new(); + let mut cur = i; + while let Some(parent) = self.parent_of(cur) { + path.push(parent); + cur = parent; + } + path.reverse(); + path + } + + /// Ancestor-Namen von der Wurzel bis zum direkten Parent von `i`. + pub fn ancestor_names(&self, i: usize) -> Vec { + self.ancestors(i) + .into_iter() + .map(|j| self.items[j].text.clone()) + .collect() + } + + /// Direkte Kinder von `i` (bzw. alle Wurzelpunkte fuer `None`). + pub fn children_of(&self, parent: Option) -> Vec { + let (start, end, depth) = match parent { + Some(p) => (p + 1, self.subtree_end(p), self.items[p].depth + 1), + None => (0, self.items.len(), 0), + }; + (start..end) + .filter(|&i| self.items[i].depth == depth) + .collect() + } + + /// Geschwister von `i` inklusive `i` selbst. + pub fn siblings_of(&self, i: usize) -> Vec { + self.children_of(self.parent_of(i)) + } + + /// Vorheriges Geschwister von `i`, falls vorhanden. + pub fn prev_sibling(&self, i: usize) -> Option { + let depth = self.items[i].depth; + (0..i) + .rev() + .take_while(|&j| self.items[j].depth >= depth) + .find(|&j| self.items[j].depth == depth) + } + + /// Naechstes Geschwister von `i`, falls vorhanden. + pub fn next_sibling(&self, i: usize) -> Option { + let end = self.subtree_end(i); + match self.items.get(end) { + Some(item) if item.depth == self.items[i].depth => Some(end), + _ => None, + } + } + + /// Sucht unter den direkten Kindern von `parent` einen Punkt mit `name`. + /// + /// `parent == None` sucht auf oberster Ebene. `ignore` blendet einen Index + /// aus, damit man beim Umbenennen nicht gegen sich selbst kollidiert. + pub fn find_child(&self, parent: Option, name: &str, ignore: Option) -> Option { + self.children_of(parent) + .into_iter() + .find(|&i| Some(i) != ignore && self.items[i].text == name) + } + + /// Prueft, ob `name` unter `parent` bereits vergeben ist. + pub fn name_taken(&self, parent: Option, name: &str, ignore: Option) -> bool { + self.find_child(parent, name, ignore).is_some() + } + + /// Alle Faelle von Namensgleichheit unter Geschwistern. + /// + /// Beim Laden einer von Hand editierten Datei wird das als Warnung + /// gemeldet statt hart abgelehnt. + pub fn duplicate_siblings(&self) -> Vec<(usize, String)> { + let mut dupes = Vec::new(); + for i in 0..self.items.len() { + let name = &self.items[i].text; + if let Some(prev) = self.prev_sibling(i) + && self + .siblings_of(i) + .into_iter() + .take_while(|&j| j <= prev) + .any(|j| &self.items[j].text == name) + { + dupes.push((i, name.clone())); + } + } + dupes + } + + /// Verletzt die Liste die Tiefen-Invariante? Nur fuer Tests/Debug. + pub fn check_invariants(&self) -> Result<(), String> { + for (i, item) in self.items.iter().enumerate() { + let max = if i == 0 { 0 } else { self.items[i - 1].depth + 1 }; + if item.depth > max { + return Err(format!( + "Zeile {i} ({:?}) hat depth {}, erlaubt waere hoechstens {max}", + item.text, item.depth + )); + } + } + Ok(()) + } +} + +/// Das gesamte Board. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Board { + areas: [Area; 3], +} + +impl Board { + pub fn new() -> Self { + Self::default() + } + + pub fn area(&self, kind: AreaKind) -> &Area { + &self.areas[kind.index()] + } + + pub fn area_mut(&mut self, kind: AreaKind) -> &mut Area { + &mut self.areas[kind.index()] + } + + /// Zwei Areas gleichzeitig ausleihen. Panics, wenn `a == b`. + pub fn area_pair_mut(&mut self, a: AreaKind, b: AreaKind) -> (&mut Area, &mut Area) { + assert_ne!(a, b, "area_pair_mut braucht zwei verschiedene Areas"); + let (ai, bi) = (a.index(), b.index()); + if ai < bi { + let (left, right) = self.areas.split_at_mut(bi); + (&mut left[ai], &mut right[0]) + } else { + let (left, right) = self.areas.split_at_mut(ai); + (&mut right[0], &mut left[bi]) + } + } + + pub fn areas(&self) -> impl Iterator { + AreaKind::ALL.into_iter().map(|kind| (kind, self.area(kind))) + } + + pub fn is_empty(&self) -> bool { + self.areas.iter().all(|a| a.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Baut eine Area aus `(depth, text)`-Paaren. + fn area(spec: &[(usize, &str)]) -> Area { + Area::from_items( + spec.iter() + .map(|&(depth, text)| Item::new(text, depth)) + .collect(), + ) + } + + /// ```text + /// 0 a + /// 1 a1 + /// 2 a1x + /// 1 a2 + /// 0 b + /// ``` + fn sample() -> Area { + area(&[ + (0, "a"), + (1, "a1"), + (2, "a1x"), + (1, "a2"), + (0, "b"), + ]) + } + + #[test] + fn subtree_spans_all_descendants() { + let a = sample(); + assert_eq!(a.subtree_end(0), 4); + assert_eq!(a.subtree_end(1), 3); + assert_eq!(a.subtree_end(2), 3); + assert_eq!(a.subtree_end(3), 4); + assert_eq!(a.subtree_end(4), 5); + assert_eq!(a.subtree_len(0), 4); + assert!(a.has_children(0)); + assert!(!a.has_children(4)); + } + + #[test] + fn parent_and_ancestors() { + let a = sample(); + assert_eq!(a.parent_of(0), None); + assert_eq!(a.parent_of(1), Some(0)); + assert_eq!(a.parent_of(2), Some(1)); + assert_eq!(a.parent_of(3), Some(0)); + assert_eq!(a.parent_of(4), None); + + assert_eq!(a.ancestors(2), vec![0, 1]); + assert_eq!(a.ancestor_names(2), vec!["a".to_string(), "a1".to_string()]); + assert!(a.ancestors(0).is_empty()); + } + + #[test] + fn children_and_siblings() { + let a = sample(); + assert_eq!(a.children_of(None), vec![0, 4]); + assert_eq!(a.children_of(Some(0)), vec![1, 3]); + assert_eq!(a.children_of(Some(1)), vec![2]); + assert!(a.children_of(Some(2)).is_empty()); + + assert_eq!(a.siblings_of(1), vec![1, 3]); + assert_eq!(a.prev_sibling(3), Some(1)); + assert_eq!(a.prev_sibling(1), None); + assert_eq!(a.next_sibling(1), Some(3)); + assert_eq!(a.next_sibling(3), None); + // Ueber Parent-Grenzen hinweg gibt es kein Geschwister. + assert_eq!(a.next_sibling(2), None); + assert_eq!(a.next_sibling(0), Some(4)); + } + + #[test] + fn name_lookup_is_scoped_to_siblings() { + let a = area(&[(0, "a"), (1, "x"), (0, "b"), (1, "x")]); + assert_eq!(a.find_child(Some(0), "x", None), Some(1)); + assert_eq!(a.find_child(Some(2), "x", None), Some(3)); + assert_eq!(a.find_child(None, "x", None), None); + assert!(a.name_taken(Some(0), "x", None)); + // Sich selbst ignorieren: kein Konflikt beim Umbenennen. + assert!(!a.name_taken(Some(0), "x", Some(1))); + // Gleiche Namen unter verschiedenen Parents sind erlaubt. + assert!(a.duplicate_siblings().is_empty()); + } + + #[test] + fn duplicate_siblings_are_detected() { + let a = area(&[(0, "a"), (1, "x"), (1, "y"), (1, "x"), (0, "a")]); + assert_eq!( + a.duplicate_siblings(), + vec![(3, "x".to_string()), (4, "a".to_string())] + ); + } + + #[test] + fn invariants() { + assert!(sample().check_invariants().is_ok()); + assert!(area(&[(1, "a")]).check_invariants().is_err()); + assert!(area(&[(0, "a"), (2, "b")]).check_invariants().is_err()); + assert!(Area::new().check_invariants().is_ok()); + } + + #[test] + fn area_order_and_navigation() { + assert_eq!(AreaKind::ToDo.below(), Some(AreaKind::Active)); + assert_eq!(AreaKind::Active.below(), Some(AreaKind::Done)); + assert_eq!(AreaKind::Done.below(), None); + assert_eq!(AreaKind::ToDo.above(), None); + assert_eq!(AreaKind::Done.above(), Some(AreaKind::Active)); + } + + #[test] + fn area_pair_mut_yields_both_orders() { + let mut board = Board::new(); + let (todo, done) = board.area_pair_mut(AreaKind::ToDo, AreaKind::Done); + todo.items.push(Item::new("t", 0)); + done.items.push(Item::new("d", 0)); + assert_eq!(board.area(AreaKind::ToDo).items[0].text, "t"); + assert_eq!(board.area(AreaKind::Done).items[0].text, "d"); + + let (done, todo) = board.area_pair_mut(AreaKind::Done, AreaKind::ToDo); + assert_eq!(done.items[0].text, "d"); + assert_eq!(todo.items[0].text, "t"); + } +} diff --git a/src/ops.rs b/src/ops.rs new file mode 100644 index 0000000..8148f68 --- /dev/null +++ b/src/ops.rs @@ -0,0 +1,694 @@ +//! Operationen auf einer einzelnen Area: Navigation, Umsortieren, Ein- und +//! Ausruecken, Markierungen, Collapse/Expand. +//! +//! Alle Funktionen arbeiten rein auf dem Modell und geben, wo sinnvoll, den +//! neuen Cursor-Index zurueck. Bewegungen enden immer an der Geschwister- bzw. +//! Area-Grenze: ein Punkt verlaesst seine Familie nur ueber Shift+H/L, und die +//! Area nur ueber Enter/Shift+Enter. + +use std::fmt; + +use crate::model::{Area, Item}; + +/// Gruende, aus denen eine Operation abgelehnt wird; landet als Meldung in der +/// Statuszeile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpError { + /// Einruecken ohne vorangehendes Geschwister — es gaebe keinen Parent. + NoPreviousSibling, + /// Ausruecken auf oberster Ebene. + AlreadyTopLevel, + /// Der Zielplatz hat bereits ein Geschwister dieses Namens. + DuplicateName(String), +} + +impl fmt::Display for OpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OpError::NoPreviousSibling => { + f.write_str("Kein Punkt darüber, unter den eingerückt werden könnte") + } + OpError::AlreadyTopLevel => f.write_str("Punkt ist bereits auf oberster Ebene"), + OpError::DuplicateName(name) => { + write!(f, "{name:?} existiert dort bereits auf derselben Ebene") + } + } + } +} + +impl std::error::Error for OpError {} + +/// Sichtbarkeit und Cursor-Navigation. +impl Area { + /// Oberster eingeklappter Ancestor von `i`, falls es einen gibt. + fn collapsed_ancestor(&self, i: usize) -> Option { + self.ancestors(i) + .into_iter() + .find(|&a| self.items[a].collapsed) + } + + /// Ist `i` sichtbar, d.h. steckt es in keinem eingeklappten Subtree? + pub fn is_visible(&self, i: usize) -> bool { + self.collapsed_ancestor(i).is_none() + } + + /// Alle sichtbaren Indizes in Darstellungsreihenfolge. + pub fn visible_items(&self) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < self.len() { + out.push(i); + i = if self.items[i].collapsed { + self.subtree_end(i) + } else { + i + 1 + }; + } + out + } + + /// Naechster sichtbarer Punkt unterhalb von `i`. + pub fn next_visible(&self, i: usize) -> Option { + let next = if self.items[i].collapsed { + self.subtree_end(i) + } else { + i + 1 + }; + (next < self.len()).then_some(next) + } + + /// Naechster sichtbarer Punkt oberhalb von `i`. + pub fn prev_visible(&self, i: usize) -> Option { + let prev = i.checked_sub(1)?; + // Liegt der Vorgaenger in einem eingeklappten Subtree, springen wir auf + // dessen sichtbare Wurzel. + Some(self.collapsed_ancestor(prev).unwrap_or(prev)) + } + + /// Bewegt den Cursor eine sichtbare Zeile nach unten. + pub fn cursor_down(&mut self) { + if let Some(next) = self.cursor_item().and_then(|i| self.next_visible(i)) { + self.cursor = next; + } + } + + /// Bewegt den Cursor eine sichtbare Zeile nach oben. + pub fn cursor_up(&mut self) { + if let Some(prev) = self.cursor_item().and_then(|i| self.prev_visible(i)) { + self.cursor = prev; + } + } + + /// Aktueller Cursor-Index, sofern die Area nicht leer ist. + pub fn cursor_item(&self) -> Option { + (!self.is_empty()).then(|| self.cursor.min(self.len() - 1)) + } + + /// Holt den Cursor zurueck in einen gueltigen, sichtbaren Zustand. + /// + /// Noetig nach Loeschen, Einfuegen oder Collapse, wo der Cursor aus dem + /// Bereich laufen oder in einem eingeklappten Subtree landen kann. + pub fn clamp_cursor(&mut self) { + if self.is_empty() { + self.cursor = 0; + return; + } + self.cursor = self.cursor.min(self.len() - 1); + if let Some(root) = self.collapsed_ancestor(self.cursor) { + self.cursor = root; + } + } +} + +/// Umsortieren innerhalb der Geschwister. +impl Area { + /// Schiebt den Subtree bei `i` hinter sein naechstes Geschwister. + /// + /// Gibt den neuen Index zurueck, oder `None` am Ende der Geschwisterliste — + /// der Punkt verlaesst seine Familie dabei nie. + pub fn move_down(&mut self, i: usize) -> Option { + let next = self.next_sibling(i)?; + let end = self.subtree_end(next); + let len = self.subtree_len(i); + // Der eigene Subtree wandert hinter den des Geschwisters. + self.items[i..end].rotate_left(len); + Some(end - len) + } + + /// Schiebt den Subtree bei `i` vor sein vorheriges Geschwister. + pub fn move_up(&mut self, i: usize) -> Option { + let prev = self.prev_sibling(i)?; + let end = self.subtree_end(i); + let len = self.subtree_len(i); + self.items[prev..end].rotate_right(len); + Some(prev) + } + + /// Macht `i` zum letzten Kind seines vorherigen Geschwisters. + /// + /// Der eigene Subtree kommt mit; ein eingeklappter neuer Parent wird + /// aufgeklappt, damit der Punkt nicht verschwindet. + pub fn indent(&mut self, i: usize) -> Result { + let parent = self.prev_sibling(i).ok_or(OpError::NoPreviousSibling)?; + let name = self.items[i].text.clone(); + if self.name_taken(Some(parent), &name, None) { + return Err(OpError::DuplicateName(name)); + } + + let end = self.subtree_end(i); + for item in &mut self.items[i..end] { + item.depth += 1; + } + self.items[parent].collapsed = false; + // Der Punkt steht bereits direkt hinter dem Subtree des Parents und ist + // damit automatisch dessen letztes Kind — nur die Tiefe musste sich + // aendern. + Ok(i) + } + + /// Macht `i` zum naechsten Geschwister seines Parents. + /// + /// Der Subtree wandert hinter den des bisherigen Parents; nachfolgende + /// Geschwister bleiben dort, wo sie sind, statt adoptiert zu werden. + pub fn outdent(&mut self, i: usize) -> Result { + let parent = self.parent_of(i).ok_or(OpError::AlreadyTopLevel)?; + let name = self.items[i].text.clone(); + let grandparent = self.parent_of(parent); + if self.name_taken(grandparent, &name, None) { + return Err(OpError::DuplicateName(name)); + } + + let end = self.subtree_end(i); + let len = end - i; + let parent_end = self.subtree_end(parent); + self.items[i..parent_end].rotate_left(len); + + let new_index = parent_end - len; + for item in &mut self.items[new_index..parent_end] { + item.depth -= 1; + } + Ok(new_index) + } + + /// Prueft, ob `i` auf `new_name` umbenannt werden darf. + /// + /// Wird beim Verlassen des Edit-Mode und beim Anlegen neuer Punkte genutzt. + pub fn validate_rename(&self, i: usize, new_name: &str) -> Result<(), OpError> { + let parent = self.parent_of(i); + if self.name_taken(parent, new_name, Some(i)) { + return Err(OpError::DuplicateName(new_name.to_string())); + } + Ok(()) + } + + /// Entfernt den Subtree bei `i` und gibt ihn zurueck. + /// + /// Basis fuer `d` und fuer den Move zwischen Areas. + pub fn remove_subtree(&mut self, i: usize) -> Vec { + let end = self.subtree_end(i); + self.items.drain(i..end).collect() + } +} + +/// Markierungen. +impl Area { + /// (Ent-)markiert `i` samt aller Nachfahren. + /// + /// Der neue Zustand ist die Umkehrung des Zustands von `i` selbst, sodass + /// ein Parent seine Kinder immer mitnimmt. Kinder lassen sich unabhaengig + /// davon einzeln markieren. + pub fn toggle_selection(&mut self, i: usize) { + let target = !self.items[i].selected; + let end = self.subtree_end(i); + for item in &mut self.items[i..end] { + item.selected = target; + } + } + + /// Hebt alle Markierungen dieser Area auf (Shift+Space). + pub fn clear_selection(&mut self) { + for item in &mut self.items { + item.selected = false; + } + } + + pub fn selected_items(&self) -> Vec { + (0..self.len()).filter(|&i| self.items[i].selected).collect() + } + + pub fn has_selection(&self) -> bool { + self.items.iter().any(|i| i.selected) + } +} + +/// Ein- und Ausklappen. +impl Area { + /// Klappt `i` ein, sofern es Kinder hat. Gibt zurueck, ob sich etwas aenderte. + pub fn collapse(&mut self, i: usize) -> bool { + let changed = self.has_children(i) && !self.items[i].collapsed; + if changed { + self.items[i].collapsed = true; + } + changed + } + + pub fn expand(&mut self, i: usize) -> bool { + let changed = self.items[i].collapsed; + self.items[i].collapsed = false; + changed + } + + /// `h` / Left: erst einklappen, sonst zum Parent springen. + pub fn collapse_or_parent(&mut self, i: usize) -> usize { + if self.collapse(i) { + i + } else { + self.parent_of(i).unwrap_or(i) + } + } + + /// `l` / Right: erst aufklappen, sonst zum ersten Kind springen. + pub fn expand_or_child(&mut self, i: usize) -> usize { + if self.expand(i) { + i + } else if self.has_children(i) { + i + 1 + } else { + i + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Baut eine Area aus `(depth, text)`-Paaren. + fn area(spec: &[(usize, &str)]) -> Area { + Area::from_items( + spec.iter() + .map(|&(d, t)| Item::new(t, d)) + .collect(), + ) + } + + /// Struktur als `(depth, text)` zum Vergleichen. + fn shape(a: &Area) -> Vec<(usize, &str)> { + a.items.iter().map(|i| (i.depth, i.text.as_str())).collect() + } + + fn names<'a>(a: &'a Area, idx: &[usize]) -> Vec<&'a str> { + idx.iter().map(|&i| a.items[i].text.as_str()).collect() + } + + /// ```text + /// a + /// a1 + /// a1x + /// a2 + /// b + /// b1 + /// c + /// ``` + fn sample() -> Area { + area(&[ + (0, "a"), + (1, "a1"), + (2, "a1x"), + (1, "a2"), + (0, "b"), + (1, "b1"), + (0, "c"), + ]) + } + + fn assert_valid(a: &Area) { + a.check_invariants().expect("Tiefen-Invariante"); + } + + // --- Umsortieren ---------------------------------------------------- + + #[test] + fn move_down_takes_children_along() { + let mut a = sample(); + assert_eq!(a.move_down(0), Some(2)); // "a" hinter "b" + assert_eq!( + shape(&a), + vec![ + (0, "b"), + (1, "b1"), + (0, "a"), + (1, "a1"), + (2, "a1x"), + (1, "a2"), + (0, "c"), + ] + ); + assert_valid(&a); + } + + #[test] + fn move_up_takes_children_along() { + let mut a = sample(); + assert_eq!(a.move_up(4), Some(0)); // "b" vor "a" + assert_eq!( + shape(&a), + vec![ + (0, "b"), + (1, "b1"), + (0, "a"), + (1, "a1"), + (2, "a1x"), + (1, "a2"), + (0, "c"), + ] + ); + assert_valid(&a); + } + + #[test] + fn children_reorder_within_their_parent() { + let mut a = sample(); + assert_eq!(a.move_down(1), Some(2)); // "a1" hinter "a2" + assert_eq!( + shape(&a), + vec![ + (0, "a"), + (1, "a2"), + (1, "a1"), + (2, "a1x"), + (0, "b"), + (1, "b1"), + (0, "c"), + ] + ); + assert_valid(&a); + } + + #[test] + fn move_stops_at_family_boundary() { + let mut a = sample(); + // "a2" ist das letzte Kind von "a" und rutscht nicht zu "b" hinueber. + assert_eq!(a.move_down(3), None); + // "a1" ist das erste Kind und verlaesst "a" auch nach oben nicht. + assert_eq!(a.move_up(1), None); + // Ebenso an den Raendern der Area. + assert_eq!(a.move_up(0), None); + assert_eq!(a.move_down(6), None); + assert_eq!(shape(&a), shape(&sample())); + } + + #[test] + fn move_skips_over_whole_sibling_subtree() { + let mut a = area(&[(0, "x"), (0, "y"), (1, "y1"), (1, "y2"), (0, "z")]); + assert_eq!(a.move_down(0), Some(3)); // "x" landet hinter "y2" + assert_eq!( + shape(&a), + vec![(0, "y"), (1, "y1"), (1, "y2"), (0, "x"), (0, "z")] + ); + } + + #[test] + fn move_is_reversible() { + let original = sample(); + for i in 0..original.len() { + let mut a = original.clone(); + if let Some(moved) = a.move_down(i) { + assert_eq!(a.move_up(moved), Some(i), "Index {i}"); + assert_eq!(shape(&a), shape(&original), "Index {i}"); + } + } + } + + // --- Ein-/Ausruecken ------------------------------------------------ + + #[test] + fn indent_makes_item_last_child_of_previous_sibling() { + let mut a = sample(); + assert_eq!(a.indent(4), Ok(4)); // "b" unter "a" + assert_eq!( + shape(&a), + vec![ + (0, "a"), + (1, "a1"), + (2, "a1x"), + (1, "a2"), + (1, "b"), + (2, "b1"), + (0, "c"), + ] + ); + assert_valid(&a); + } + + #[test] + fn indent_needs_a_previous_sibling() { + let mut a = sample(); + assert_eq!(a.indent(0), Err(OpError::NoPreviousSibling)); + assert_eq!(a.indent(1), Err(OpError::NoPreviousSibling)); // erstes Kind + assert_eq!(shape(&a), shape(&sample())); + } + + #[test] + fn indent_rejects_name_clash_in_new_parent() { + let mut a = area(&[(0, "a"), (1, "x"), (0, "x")]); + assert_eq!(a.indent(2), Err(OpError::DuplicateName("x".into()))); + assert_eq!(shape(&a), vec![(0, "a"), (1, "x"), (0, "x")]); + } + + #[test] + fn indent_expands_collapsed_new_parent() { + let mut a = sample(); + a.collapse(0); + assert!(a.items[0].collapsed); + a.indent(4).unwrap(); + assert!(!a.items[0].collapsed, "neuer Parent muss sichtbar werden"); + assert!(a.is_visible(4)); + } + + #[test] + fn outdent_places_item_after_its_parent() { + let mut a = sample(); + assert_eq!(a.outdent(1), Ok(2)); // "a1" wird Geschwister von "a" + assert_eq!( + shape(&a), + vec![ + (0, "a"), + (1, "a2"), + (0, "a1"), + (1, "a1x"), + (0, "b"), + (1, "b1"), + (0, "c"), + ] + ); + assert_valid(&a); + } + + #[test] + fn outdent_does_not_adopt_following_siblings() { + let mut a = area(&[(0, "p"), (1, "x"), (1, "y"), (1, "z")]); + assert_eq!(a.outdent(1), Ok(3)); // "x" raus, "y"/"z" bleiben bei "p" + assert_eq!(shape(&a), vec![(0, "p"), (1, "y"), (1, "z"), (0, "x")]); + assert_valid(&a); + } + + #[test] + fn outdent_needs_a_parent() { + let mut a = sample(); + assert_eq!(a.outdent(0), Err(OpError::AlreadyTopLevel)); + assert_eq!(shape(&a), shape(&sample())); + } + + #[test] + fn outdent_rejects_name_clash_with_new_siblings() { + let mut a = area(&[(0, "p"), (1, "q"), (0, "q")]); + assert_eq!(a.outdent(1), Err(OpError::DuplicateName("q".into()))); + assert_eq!(shape(&a), vec![(0, "p"), (1, "q"), (0, "q")]); + } + + #[test] + fn indent_and_outdent_are_inverse_when_last_child() { + let mut a = sample(); + let i = a.indent(4).unwrap(); + assert_eq!(a.outdent(i), Ok(4)); + assert_eq!(shape(&a), shape(&sample())); + } + + #[test] + fn validate_rename_ignores_the_item_itself() { + let a = area(&[(0, "a"), (1, "x"), (1, "y")]); + assert_eq!(a.validate_rename(1, "x"), Ok(())); + assert_eq!(a.validate_rename(1, "z"), Ok(())); + assert_eq!(a.validate_rename(1, "y"), Err(OpError::DuplicateName("y".into()))); + // Gleicher Name unter anderem Parent ist erlaubt. + assert_eq!(a.validate_rename(0, "x"), Ok(())); + } + + #[test] + fn remove_subtree_returns_the_whole_family() { + let mut a = sample(); + let removed = a.remove_subtree(1); + assert_eq!( + removed.iter().map(|i| i.text.as_str()).collect::>(), + vec!["a1", "a1x"] + ); + assert_eq!( + shape(&a), + vec![(0, "a"), (1, "a2"), (0, "b"), (1, "b1"), (0, "c")] + ); + assert_valid(&a); + } + + // --- Markierungen --------------------------------------------------- + + #[test] + fn selecting_a_parent_selects_its_children() { + let mut a = sample(); + a.toggle_selection(0); + assert_eq!(names(&a, &a.selected_items()), vec!["a", "a1", "a1x", "a2"]); + a.toggle_selection(0); + assert!(!a.has_selection()); + } + + #[test] + fn children_can_be_selected_on_their_own() { + let mut a = sample(); + a.toggle_selection(2); + assert_eq!(names(&a, &a.selected_items()), vec!["a1x"]); + a.toggle_selection(5); + assert_eq!(names(&a, &a.selected_items()), vec!["a1x", "b1"]); + } + + #[test] + fn toggling_a_parent_overrides_partial_child_selection() { + let mut a = sample(); + a.toggle_selection(2); // nur "a1x" + a.toggle_selection(0); // "a" war nicht markiert -> alles markieren + assert_eq!(names(&a, &a.selected_items()), vec!["a", "a1", "a1x", "a2"]); + } + + #[test] + fn clear_selection_empties_the_area() { + let mut a = sample(); + a.toggle_selection(0); + a.toggle_selection(4); + a.clear_selection(); + assert!(!a.has_selection()); + } + + #[test] + fn selection_survives_reordering() { + let mut a = sample(); + a.toggle_selection(4); // "b" + "b1" + let moved = a.move_up(4).unwrap(); + assert_eq!(a.items[moved].text, "b"); + assert_eq!(names(&a, &a.selected_items()), vec!["b", "b1"]); + } + + // --- Collapse / Expand ---------------------------------------------- + + #[test] + fn collapse_hides_descendants() { + let mut a = sample(); + assert!(a.collapse(0)); + assert_eq!(names(&a, &a.visible_items()), vec!["a", "b", "b1", "c"]); + assert!(!a.is_visible(1)); + assert!(a.is_visible(4)); + // Zweites Einklappen aendert nichts. + assert!(!a.collapse(0)); + } + + #[test] + fn leaves_cannot_be_collapsed() { + let mut a = sample(); + assert!(!a.collapse(6)); + assert!(!a.items[6].collapsed); + } + + #[test] + fn nested_collapse_is_remembered() { + let mut a = sample(); + a.collapse(1); // "a1" einklappen + a.collapse(0); // "a" einklappen + assert_eq!(names(&a, &a.visible_items()), vec!["a", "b", "b1", "c"]); + a.expand(0); + // "a1" ist weiterhin eingeklappt. + assert_eq!(names(&a, &a.visible_items()), vec!["a", "a1", "a2", "b", "b1", "c"]); + } + + #[test] + fn navigation_skips_collapsed_subtrees() { + let mut a = sample(); + a.collapse(0); + a.cursor = 0; + a.cursor_down(); + assert_eq!(a.items[a.cursor].text, "b"); + a.cursor_up(); + assert_eq!(a.items[a.cursor].text, "a"); + } + + #[test] + fn prev_visible_jumps_to_collapsed_root() { + let mut a = sample(); + a.collapse(0); + // Von "b" aus ist der Vorgaenger nicht "a2", sondern "a". + assert_eq!(a.prev_visible(4), Some(0)); + } + + #[test] + fn cursor_stops_at_the_ends() { + let mut a = sample(); + a.cursor = 0; + a.cursor_up(); + assert_eq!(a.cursor, 0); + a.cursor = a.len() - 1; + a.cursor_down(); + assert_eq!(a.cursor, a.len() - 1); + } + + #[test] + fn cursor_helpers_handle_empty_area() { + let mut a = Area::new(); + assert_eq!(a.cursor_item(), None); + a.cursor_down(); + a.cursor_up(); + a.clamp_cursor(); + assert_eq!(a.cursor, 0); + assert!(a.visible_items().is_empty()); + } + + #[test] + fn clamp_cursor_pulls_it_out_of_hidden_and_out_of_range() { + let mut a = sample(); + a.collapse(0); + a.cursor = 2; // in einem eingeklappten Subtree + a.clamp_cursor(); + assert_eq!(a.cursor, 0); + + a.cursor = 99; + a.clamp_cursor(); + assert_eq!(a.cursor, a.len() - 1); + } + + #[test] + fn h_collapses_then_walks_to_parent() { + let mut a = sample(); + assert_eq!(a.collapse_or_parent(0), 0); // klappt "a" ein + assert!(a.items[0].collapsed); + assert_eq!(a.collapse_or_parent(0), 0); // ohne Parent bleibt es stehen + + assert_eq!(a.collapse_or_parent(2), 1); // Blatt -> Parent "a1" + assert_eq!(a.collapse_or_parent(1), 1); // "a1" hat Kinder -> einklappen + assert_eq!(a.collapse_or_parent(1), 0); // dann zum Parent "a" + } + + #[test] + fn l_expands_then_walks_to_first_child() { + let mut a = sample(); + a.collapse(0); + assert_eq!(a.expand_or_child(0), 0); // klappt auf + assert_eq!(a.expand_or_child(0), 1); // dann zum ersten Kind "a1" + assert_eq!(a.expand_or_child(6), 6); // Blatt bleibt stehen + } +} diff --git a/src/transfer.rs b/src/transfer.rs new file mode 100644 index 0000000..a23c9f9 --- /dev/null +++ b/src/transfer.rs @@ -0,0 +1,432 @@ +//! Verschieben von Punkten zwischen Areas (Enter / Shift+Enter / Backspace). +//! +//! Kernregel: Punkte behalten ihren Pfad. Wird ein Child ohne seinen Parent +//! verschoben, entsteht der Ancestor-Pfad in der Ziel-Area neu; existiert er +//! dort schon, wird der Punkt in diese Family eingesetzt. Umgekehrt bleibt in +//! der Quelle stehen, was fuer die zurueckbleibenden Punkte noch gebraucht wird +//! — und Parents bleiben ohnehin immer stehen, weil sie eigene Punkte sind. +//! +//! Zusammengefuehrt wird ueber Namensgleichheit auf derselben Ebene. Dadurch +//! kann die Operation nie scheitern und die Eindeutigkeit von Geschwisternamen +//! bleibt in der Ziel-Area erhalten. + +use crate::model::{Area, AreaKind, Board, Item}; + +/// Was ein Move bewirkt hat — Grundlage fuer die Statuszeile. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MoveReport { + /// Anzahl tatsaechlich verschobener Punkte (ohne angelegte Ancestors). + pub moved: usize, + /// Namen, die in der Ziel-Area als Ancestor-Pfad neu angelegt wurden. + pub created: Vec, + /// Namen, die mit einem dort bereits vorhandenen Punkt verschmolzen sind. + pub merged: Vec, + /// Namen, die in der Quelle als Struktur-Stub zurueckbleiben mussten, weil + /// noch unverschobene Kinder an ihnen haengen. + pub stubs: Vec, + /// Index des ersten verschobenen Punkts in der Ziel-Area. + pub focus: Option, +} + +impl MoveReport { + pub fn is_empty(&self) -> bool { + self.moved == 0 + } +} + +/// Pfad-Operationen auf einer Area. +impl Area { + /// Index des Punkts, auf den der Namenspfad zeigt. + pub fn find_path(&self, path: &[String]) -> Option { + let mut parent = None; + for name in path { + parent = Some(self.find_child(parent, name, None)?); + } + parent + } + + /// Voller Namenspfad von der Wurzel bis `i` einschliesslich. + pub fn path_of(&self, i: usize) -> Vec { + let mut path = self.ancestor_names(i); + path.push(self.items[i].text.clone()); + path + } + + /// Liefert das Kind `name` von `parent` und legt es an, falls es fehlt. + /// + /// Neue Punkte werden ans Ende der Kinderliste gehaengt. Rueckgabe ist der + /// Index und ob der Punkt neu entstanden ist. + fn ensure_child(&mut self, parent: Option, name: &str) -> (usize, bool) { + if let Some(existing) = self.find_child(parent, name, None) { + return (existing, false); + } + let (pos, depth) = match parent { + // Hinter den letzten Nachfahren des Parents = letztes Kind. + Some(p) => (self.subtree_end(p), self.items[p].depth + 1), + None => (self.len(), 0), + }; + self.items.insert(pos, Item::new(name, depth)); + (pos, true) + } +} + +impl Board { + /// Verschiebt die markierten Punkte von `from` nach `to`. + /// + /// Ohne Markierung wandert der Punkt unter dem Cursor samt seiner Kinder. + pub fn move_selection(&mut self, from: AreaKind, to: AreaKind) -> MoveReport { + if from == to { + return MoveReport::default(); + } + let mask = move_mask(self.area(from)); + let (src, dst) = self.area_pair_mut(from, to); + let mut report = MoveReport::default(); + let mut first_path: Option> = None; + + // Quell-Reihenfolge ist Pre-Order: ein mitverschobener Ancestor ist + // immer schon angelegt, bevor seine Kinder drankommen. + for i in 0..src.len() { + if !mask[i] { + continue; + } + + let mut parent = None; + for ancestor in src.ancestors(i) { + let name = src.items[ancestor].text.clone(); + let (idx, created) = dst.ensure_child(parent, &name); + // Ein Ancestor, der selbst mitwandert, behaelt seinen + // Klapp-Zustand; ein fremder wird aufgeklappt, damit der Punkt + // nicht unsichtbar ankommt. + if !mask[ancestor] { + dst.items[idx].collapsed = false; + } + if created { + report.created.push(name); + } + parent = Some(idx); + } + + let name = src.items[i].text.clone(); + let (idx, created) = dst.ensure_child(parent, &name); + if created { + dst.items[idx].collapsed = src.items[i].collapsed; + } else { + report.merged.push(name.clone()); + } + dst.items[idx].selected = false; + report.moved += 1; + + if first_path.is_none() { + first_path = Some(src.path_of(i)); + } + } + + // Ein verschobener Punkt bleibt als Stub stehen, wenn unter ihm noch + // etwas Unverschobenes haengt — sonst verloere das seinen Pfad. + let keep: Vec = (0..src.len()) + .map(|i| !mask[i] || (i + 1..src.subtree_end(i)).any(|j| !mask[j])) + .collect(); + for i in 0..src.len() { + if mask[i] && keep[i] { + report.stubs.push(src.items[i].text.clone()); + } + } + let mut i = 0; + src.items.retain(|_| { + let keep = keep[i]; + i += 1; + keep + }); + + src.clear_selection(); + src.clamp_cursor(); + dst.clamp_cursor(); + report.focus = first_path.and_then(|path| dst.find_path(&path)); + report + } +} + +/// Welche Punkte wandern? Die Markierung, sonst der Subtree unter dem Cursor. +fn move_mask(area: &Area) -> Vec { + if area.has_selection() { + return area.items.iter().map(|item| item.selected).collect(); + } + match area.cursor_item() { + Some(cursor) => { + let end = area.subtree_end(cursor); + (0..area.len()).map(|i| i >= cursor && i < end).collect() + } + None => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::markdown; + + /// Baut ein Board aus Markdown. + fn board(src: &str) -> Board { + markdown::parse(src).expect("parse").board + } + + /// Stellt eine Area als eingerueckten Text dar, damit Erwartungen lesbar + /// bleiben. + fn dump(area: &Area) -> String { + area.items + .iter() + .map(|i| format!("{}{}", " ".repeat(i.depth), i.text)) + .collect::>() + .join("\n") + } + + fn assert_area(board: &Board, kind: AreaKind, expected: &str) { + let area = board.area(kind); + area.check_invariants() + .unwrap_or_else(|e| panic!("{kind}: {e}")); + assert!( + area.duplicate_siblings().is_empty(), + "{kind}: doppelte Geschwister {:?}", + area.duplicate_siblings() + ); + assert_eq!(dump(area), expected.trim_matches('\n'), "Area {kind}"); + } + + const EINKAUF: &str = "\ +# To Do +- Einkaufen + - Milch + - Brot +- Steuer +# Active +# Done +"; + + #[test] + fn moving_a_child_recreates_its_parent() { + let mut b = board(EINKAUF); + b.area_mut(AreaKind::ToDo).cursor = 1; // "Milch" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert_eq!(report.moved, 1); + assert_eq!(report.created, vec!["Einkaufen"]); + assert_area(&b, AreaKind::ToDo, "Einkaufen\n Brot\nSteuer"); + assert_area(&b, AreaKind::Active, "Einkaufen\n Milch"); + } + + #[test] + fn second_child_joins_the_existing_family() { + let mut b = board(EINKAUF); + b.area_mut(AreaKind::ToDo).cursor = 1; // "Milch" + b.move_selection(AreaKind::ToDo, AreaKind::Active); + b.area_mut(AreaKind::ToDo).cursor = 1; // jetzt "Brot" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert!(report.created.is_empty(), "Einkaufen existiert dort schon"); + assert_eq!(report.moved, 1); + // Der kinderlose Parent bleibt als eigener Punkt stehen. + assert_area(&b, AreaKind::ToDo, "Einkaufen\nSteuer"); + assert_area(&b, AreaKind::Active, "Einkaufen\n Milch\n Brot"); + } + + #[test] + fn moving_a_parent_takes_its_children() { + let mut b = board(EINKAUF); + b.area_mut(AreaKind::ToDo).cursor = 0; // "Einkaufen" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert_eq!(report.moved, 3); + assert!(report.stubs.is_empty()); + assert_area(&b, AreaKind::ToDo, "Steuer"); + assert_area(&b, AreaKind::Active, "Einkaufen\n Milch\n Brot"); + } + + #[test] + fn deep_ancestor_paths_are_recreated_completely() { + let mut b = board("# To Do\n- a\n - b\n - c\n# Active\n# Done\n"); + b.area_mut(AreaKind::ToDo).cursor = 2; // "c" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert_eq!(report.created, vec!["a", "b"]); + assert_area(&b, AreaKind::ToDo, "a\n b"); + assert_area(&b, AreaKind::Active, "a\n b\n c"); + } + + #[test] + fn existing_ancestors_are_reused_at_every_level() { + let mut b = board( + "# To Do\n- a\n - b\n - c2\n# Active\n- a\n - b\n - c1\n# Done\n", + ); + b.area_mut(AreaKind::ToDo).cursor = 2; // "c2" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert!(report.created.is_empty()); + assert_area(&b, AreaKind::Active, "a\n b\n c1\n c2"); + } + + #[test] + fn same_name_under_different_parents_does_not_merge() { + let mut b = board("# To Do\n- p\n - x\n# Active\n- x\n- q\n - x\n# Done\n"); + b.area_mut(AreaKind::ToDo).cursor = 1; // "x" unter "p" + b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert_area(&b, AreaKind::Active, "x\nq\n x\np\n x"); + } + + #[test] + fn colliding_subtrees_merge_by_name() { + let mut b = board("# To Do\n- a\n - y\n# Active\n- a\n - x\n# Done\n"); + b.area_mut(AreaKind::ToDo).cursor = 0; // ganzes "a" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert_eq!(report.merged, vec!["a"]); + assert_area(&b, AreaKind::ToDo, ""); + assert_area(&b, AreaKind::Active, "a\n x\n y"); + } + + #[test] + fn selection_moves_instead_of_cursor() { + let mut b = board(EINKAUF); + let todo = b.area_mut(AreaKind::ToDo); + todo.cursor = 0; + todo.toggle_selection(3); // "Steuer" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert_eq!(report.moved, 1); + assert_area(&b, AreaKind::ToDo, "Einkaufen\n Milch\n Brot"); + assert_area(&b, AreaKind::Active, "Steuer"); + } + + #[test] + fn multiple_roots_keep_their_order() { + let mut b = board("# To Do\n- a\n- b\n- c\n# Active\n# Done\n"); + let todo = b.area_mut(AreaKind::ToDo); + todo.toggle_selection(2); // "c" zuerst markiert ... + todo.toggle_selection(0); // ... "a" danach + b.move_selection(AreaKind::ToDo, AreaKind::Active); + + // Ausschlaggebend ist die Reihenfolge im Board, nicht die des Markierens. + assert_area(&b, AreaKind::Active, "a\nc"); + assert_area(&b, AreaKind::ToDo, "b"); + } + + #[test] + fn partially_selected_parent_leaves_a_stub() { + let mut b = board("# To Do\n- a\n - a1\n - a2\n# Active\n# Done\n"); + let todo = b.area_mut(AreaKind::ToDo); + todo.toggle_selection(0); // "a" samt Kindern + todo.toggle_selection(1); // "a1" wieder abwaehlen + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert_eq!(report.moved, 2); + assert_eq!(report.stubs, vec!["a"]); + // "a" bleibt zurueck, damit "a1" seinen Pfad behaelt. + assert_area(&b, AreaKind::ToDo, "a\n a1"); + assert_area(&b, AreaKind::Active, "a\n a2"); + } + + #[test] + fn moving_back_up_works_the_same_way() { + let mut b = board("# To Do\n# Active\n# Done\n- a\n - b\n - c\n"); + b.area_mut(AreaKind::Done).cursor = 1; // "b" + b.move_selection(AreaKind::Done, AreaKind::Active); + + assert_area(&b, AreaKind::Done, "a\n c"); + assert_area(&b, AreaKind::Active, "a\n b"); + } + + #[test] + fn round_trip_through_all_areas_preserves_the_tree() { + let original = board(EINKAUF); + let mut b = original.clone(); + for (from, to) in [ + (AreaKind::ToDo, AreaKind::Active), + (AreaKind::Active, AreaKind::Done), + ] { + b.area_mut(from).toggle_selection(0); // "Einkaufen" samt Kindern + b.area_mut(from).toggle_selection(3); // "Steuer" + b.move_selection(from, to); + } + assert_area(&b, AreaKind::Done, "Einkaufen\n Milch\n Brot\nSteuer"); + + b.area_mut(AreaKind::Done).toggle_selection(0); + b.area_mut(AreaKind::Done).toggle_selection(3); + b.move_selection(AreaKind::Done, AreaKind::Active); + b.area_mut(AreaKind::Active).toggle_selection(0); + b.area_mut(AreaKind::Active).toggle_selection(3); + b.move_selection(AreaKind::Active, AreaKind::ToDo); + + assert_eq!(dump(b.area(AreaKind::ToDo)), dump(original.area(AreaKind::ToDo))); + assert!(b.area(AreaKind::Active).is_empty()); + assert!(b.area(AreaKind::Done).is_empty()); + } + + #[test] + fn empty_source_is_a_no_op() { + let mut b = board("# To Do\n# Active\n- a\n# Done\n"); + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + assert!(report.is_empty()); + assert_area(&b, AreaKind::Active, "a"); + } + + #[test] + fn moving_into_itself_is_a_no_op() { + let mut b = board(EINKAUF); + let report = b.move_selection(AreaKind::ToDo, AreaKind::ToDo); + assert!(report.is_empty()); + assert_area(&b, AreaKind::ToDo, "Einkaufen\n Milch\n Brot\nSteuer"); + } + + #[test] + fn target_ancestors_are_expanded_so_the_item_stays_visible() { + let mut b = board("# To Do\n- a\n - y\n# Active\n- a\n - x\n# Done\n"); + b.area_mut(AreaKind::Active).collapse(0); + b.area_mut(AreaKind::ToDo).cursor = 1; // "y" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + let active = b.area(AreaKind::Active); + assert!(!active.items[0].collapsed); + assert!(active.is_visible(report.focus.unwrap())); + } + + #[test] + fn moved_items_arrive_unselected() { + let mut b = board(EINKAUF); + b.area_mut(AreaKind::ToDo).toggle_selection(0); + b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert!(!b.area(AreaKind::Active).has_selection()); + assert!(!b.area(AreaKind::ToDo).has_selection()); + } + + #[test] + fn focus_points_at_the_first_moved_item() { + let mut b = board(EINKAUF); + b.area_mut(AreaKind::ToDo).cursor = 2; // "Brot" + let report = b.move_selection(AreaKind::ToDo, AreaKind::Active); + + let active = b.area(AreaKind::Active); + assert_eq!(active.items[report.focus.unwrap()].text, "Brot"); + } + + #[test] + fn source_cursor_stays_valid_after_the_move() { + let mut b = board(EINKAUF); + b.area_mut(AreaKind::ToDo).cursor = 3; // "Steuer", letzter Punkt + b.move_selection(AreaKind::ToDo, AreaKind::Active); + + let todo = b.area(AreaKind::ToDo); + assert!(todo.cursor < todo.len()); + assert!(todo.is_visible(todo.cursor)); + } + + #[test] + fn collapsed_state_travels_with_the_item() { + let mut b = board("# To Do\n- a\n - b\n# Active\n# Done\n"); + b.area_mut(AreaKind::ToDo).collapse(0); + b.area_mut(AreaKind::ToDo).cursor = 0; + b.move_selection(AreaKind::ToDo, AreaKind::Active); + + assert!(b.area(AreaKind::Active).items[0].collapsed); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..5ca6824 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,399 @@ +//! Darstellung. +//! +//! Vertikales Layout: To Do oben, Active in der Mitte, Done unten. Das bildet +//! den Weg eines Akteurs ab — von vorne kommen die Huerden, in der Mitte steht +//! er selbst, hinter ihm liegt das Erledigte. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use crate::app::{App, Mode}; +use crate::model::{Area, AreaKind}; + +const ACCENT: Color = Color::Cyan; +const SELECTED: Color = Color::Yellow; + +pub fn draw(frame: &mut Frame, app: &mut App) { + let [top, middle, bottom, status] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(frame.area()); + + let mut cursor = None; + for (kind, rect) in [ + (AreaKind::ToDo, top), + (AreaKind::Active, middle), + (AreaKind::Done, bottom), + ] { + if let Some(pos) = draw_area(frame, app, kind, rect) { + cursor = Some(pos); + } + } + draw_status(frame, app, status); + + // Nur im Edit-Modus zeigt das Terminal einen Cursor. + if let Some(pos) = cursor { + frame.set_cursor_position(pos); + } +} + +/// Zeichnet eine Area und liefert im Edit-Modus die Cursor-Position. +fn draw_area(frame: &mut Frame, app: &mut App, kind: AreaKind, rect: Rect) -> Option { + let focused = app.focus == kind; + let editing = focused && matches!(app.mode, Mode::Edit(_)); + + let border = if focused { + Style::new().fg(ACCENT).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::DarkGray) + }; + let count = app.board.area(kind).len(); + let block = Block::bordered() + .border_style(border) + .title(Line::from(vec![ + Span::raw(" "), + Span::styled( + kind.title(), + if focused { + Style::new().fg(ACCENT).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::Gray) + }, + ), + Span::styled(format!(" {count} "), Style::new().fg(Color::DarkGray)), + ])); + let inner = block.inner(rect); + frame.render_widget(block, rect); + if inner.height == 0 || inner.width == 0 { + return None; + } + + let area = app.board.area_mut(kind); + let visible = area.visible_items(); + scroll_into_view(area, &visible, inner.height as usize); + + let area = app.board.area(kind); + let edit = match &app.mode { + Mode::Edit(edit) if editing => Some(edit), + _ => None, + }; + + let mut cursor = None; + let mut lines = Vec::new(); + for (row, &i) in visible + .iter() + .skip(area.scroll) + .take(inner.height as usize) + .enumerate() + { + let on_cursor = i == area.cursor; + let prefix = prefix_spans(area, i); + let text = match edit { + Some(edit) if on_cursor => { + let width: u16 = prefix.iter().map(|s| s.width() as u16).sum(); + let before = display_width(&edit.text[..edit.cursor]) as u16; + cursor = Some(Position::new( + inner.x + width + before, + inner.y + row as u16, + )); + Span::raw(edit.text.clone()) + } + _ => Span::raw(area.items[i].text.clone()), + }; + + let mut spans = prefix; + spans.push(text); + let mut line = Line::from(spans); + if on_cursor && (focused || editing) { + line = line.style(Style::new().add_modifier(Modifier::REVERSED)); + } else if on_cursor { + line = line.style(Style::new().bg(Color::DarkGray)); + } + lines.push(line); + } + + frame.render_widget(Paragraph::new(lines), inner); + cursor +} + +/// Baut Markierungs-Gutter, Baum-Glyphen und Falt-Symbol vor dem Text. +/// +/// Das Falt-Symbol sitzt dort, wo bei kinderlosen Punkten der Querstrich der +/// Glyphe steht — beides an dieselbe Stelle zu setzen wuerde doppelt einruecken. +fn prefix_spans(area: &Area, i: usize) -> Vec> { + let item = &area.items[i]; + let gutter = if item.selected { + Span::styled("▌", Style::new().fg(SELECTED)) + } else { + Span::raw(" ") + }; + + let (marker, marker_style) = if !area.has_children(i) { + // Auf oberster Ebene gibt es keine Glyphe, die fortzusetzen waere. + let filler = if item.depth == 0 { " " } else { "─" }; + (filler, Style::new().fg(Color::DarkGray)) + } else if item.collapsed { + ("▸", Style::new().fg(ACCENT)) + } else { + ("▾", Style::new().fg(Color::DarkGray)) + }; + + vec![ + gutter, + Span::styled(tree_glyphs(area, i), Style::new().fg(Color::DarkGray)), + Span::styled(marker, marker_style), + Span::raw(" "), + ] +} + +/// Einrueckung im Stil von `tree`: eine Rinne `│` pro Vorfahr, der noch +/// Geschwister unter sich hat, und `├`/`└` fuer den Punkt selbst. +fn tree_glyphs(area: &Area, i: usize) -> String { + let depth = area.items[i].depth; + if depth == 0 { + return String::new(); + } + let mut out = String::with_capacity(depth * 3); + for (level, ancestor) in area.ancestors(i).into_iter().enumerate() { + out.push(if area.next_sibling(ancestor).is_some() { + '│' + } else { + ' ' + }); + // Ebene 0 belegt nur Falt-Symbol und Leerzeichen, tiefere zusaetzlich + // die Astglyphe. + out.push_str(if level == 0 { " " } else { " " }); + } + out.push(if area.next_sibling(i).is_some() { + '├' + } else { + '└' + }); + out +} + +/// Haelt den Cursor im sichtbaren Fenster. +fn scroll_into_view(area: &mut Area, visible: &[usize], height: usize) { + let Some(pos) = visible.iter().position(|&i| i == area.cursor) else { + area.scroll = 0; + return; + }; + if pos < area.scroll { + area.scroll = pos; + } else if height > 0 && pos >= area.scroll + height { + area.scroll = pos + 1 - height; + } + area.scroll = area.scroll.min(visible.len().saturating_sub(height)); +} + +fn draw_status(frame: &mut Frame, app: &App, rect: Rect) { + let line = match &app.status { + Some(status) if status.is_error => Line::from(status.text.clone()).fg(Color::Red), + Some(status) => Line::from(status.text.clone()).fg(Color::Green), + None => Line::from(vec![ + Span::styled( + if app.dirty { "● " } else { " " }, + Style::new().fg(SELECTED), + ), + Span::styled(hints(app), Style::new().fg(Color::DarkGray)), + ]), + }; + frame.render_widget(Paragraph::new(line), rect); +} + +fn hints(app: &App) -> String { + if matches!(app.mode, Mode::Edit(_)) { + return "Esc/Enter übernehmen · Strg+C abbrechen".into(); + } + // Ohne Kitty-Protocol ist Shift+Enter nicht von Enter zu unterscheiden. + let up = if app.enhanced_keys { + "Shift+Enter bzw. Backspace ↑" + } else { + "Backspace ↑" + }; + format!( + "Tab Area · Space markieren · Enter ↓ · {up} · i/a/A/d bearbeiten · u/U zurück · Strg+S speichern · q Ende" + ) +} + +/// Darstellungsbreite in Terminalspalten. +fn display_width(text: &str) -> usize { + Line::raw(text).width() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::markdown; + + fn area(src: &str) -> Area { + markdown::parse(&format!("# To Do\n{src}")) + .expect("parse") + .board + .area(AreaKind::ToDo) + .clone() + } + + /// Prefix und Text einer Zeile als reiner Text. + fn row(a: &Area, i: usize) -> String { + let prefix: String = prefix_spans(a, i) + .iter() + .map(|s| s.content.as_ref()) + .collect(); + format!("{prefix}{}", a.items[i].text) + } + + #[test] + fn tree_glyphs_match_the_tree_layout() { + let a = area("- a\n - a1\n - x\n - y\n - a2\n- b\n"); + let rendered: Vec = (0..a.len()).map(|i| row(&a, i)).collect(); + assert_eq!( + rendered, + vec![ + " ▾ a", + " │ ├▾ a1", + " │ │ ├─ x", + " │ │ └─ y", + " │ └─ a2", + " b", + ] + ); + } + + #[test] + fn a_last_child_ends_its_ancestors_channel() { + let a = area("- a\n - a1\n - x\n- b\n"); + assert_eq!(row(&a, 2), " │ └─ x"); + } + + #[test] + fn collapsed_items_show_a_closed_marker() { + let mut a = area("- a\n - a1\n- b\n"); + a.collapse(0); + assert_eq!(row(&a, 0), " ▸ a"); + } + + #[test] + fn scrolling_follows_the_cursor_in_both_directions() { + let mut a = area("- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n"); + let visible = a.visible_items(); + + a.cursor = 4; + scroll_into_view(&mut a, &visible, 3); + assert_eq!(a.scroll, 2, "Cursor am unteren Rand"); + + a.cursor = 1; + scroll_into_view(&mut a, &visible, 3); + assert_eq!(a.scroll, 1, "Cursor am oberen Rand"); + } + + #[test] + fn scrolling_does_not_run_past_the_end() { + let mut a = area("- 1\n- 2\n- 3\n"); + let visible = a.visible_items(); + a.scroll = 99; + a.cursor = 0; + scroll_into_view(&mut a, &visible, 10); + assert_eq!(a.scroll, 0, "alles passt, kein Scrollen noetig"); + } + + #[test] + fn scrolling_counts_visible_rows_not_items() { + let mut a = area("- a\n - a1\n - a2\n- b\n- c\n"); + a.collapse(0); + let visible = a.visible_items(); + a.cursor = 4; // "c" + scroll_into_view(&mut a, &visible, 2); + // Sichtbar sind nur a, b, c -> "c" steht auf der dritten Zeile. + assert_eq!(a.scroll, 1); + } + + /// Rendert das komplette Frame als Text — praktisch zum Nachsehen, wie es + /// wirklich aussieht (`cargo test -- --nocapture whole_frame`). + fn render(app: &mut App, width: u16, height: u16) -> String { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| draw(frame, app)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + } + + fn demo_app() -> App { + let board = markdown::parse( + "# To Do\n- Steuer\n - Belege\n - Formular\n- Urlaub\n\ + # Active\n- Kanban-Board\n - TUI\n\ + # Done\n- Rust lernen\n", + ) + .expect("parse") + .board; + let mut app = App::new(board, std::path::PathBuf::from("board.md"), true); + app.focus = AreaKind::Active; + app + } + + #[test] + fn whole_frame() { + let mut app = demo_app(); + app.board.area_mut(AreaKind::ToDo).toggle_selection(1); + app.board.area_mut(AreaKind::ToDo).cursor = 1; + let out = render(&mut app, 96, 19); + println!("{out}"); + + assert!(out.contains("├─ Belege"), "Baum-Glyphen fehlen:\n{out}"); + assert!(out.contains("└─ Formular")); + assert!(out.contains("To Do 4"), "Titel mit Anzahl fehlt:\n{out}"); + // Alle drei Areas plus Statuszeile sind da. + for title in ["To Do", "Active", "Done"] { + assert!(out.contains(title), "{title} fehlt:\n{out}"); + } + assert!(out.contains("Tab Area"), "Tastenhilfe fehlt:\n{out}"); + } + + #[test] + fn edit_mode_places_the_terminal_cursor() { + let mut app = demo_app(); + app.on_key(ratatui::crossterm::event::KeyEvent::new( + ratatui::crossterm::event::KeyCode::Char('i'), + ratatui::crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(58, 19)).unwrap(); + terminal.draw(|frame| draw(frame, &mut app)).unwrap(); + + use ratatui::backend::Backend; + let pos = terminal.backend_mut().get_cursor_position().unwrap(); + // Hinter dem letzten Zeichen von "Kanban-Board" in der Active-Area, + // also weder am Zeilenanfang noch in der ersten Area. + assert!(pos.x > 12, "Cursor steht bei {pos:?}"); + assert!(pos.y > 6, "Cursor steht bei {pos:?}"); + } + + #[test] + fn selection_and_fold_markers_appear_in_the_gutter() { + let mut a = area("- a\n - a1\n- b\n"); + a.toggle_selection(0); + a.collapse(0); + let spans = prefix_spans(&a, 0); + assert_eq!(spans[0].content, "▌", "Markierung"); + assert_eq!(spans[2].content, "▸", "eingeklappt"); + + let spans = prefix_spans(&a, 2); + assert_eq!(spans[0].content, " "); + assert_eq!(spans[2].content, " ", "Blatt auf oberster Ebene ohne Symbol"); + } +}