diff --git a/Cargo.lock b/Cargo.lock index f6aa20d..ab921ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,27 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.13.2" @@ -11,13 +32,366 @@ dependencies = [ "serde_core", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[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 = "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", + "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 = "cumulus" version = "0.1.0" dependencies = [ + "ratatui", "ron", "serde", "thiserror", + "unicode-width", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[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 = "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.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "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", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags", +] + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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]] @@ -26,6 +400,74 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[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 = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[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" @@ -44,6 +486,82 @@ dependencies = [ "proc-macro2", ] +[[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-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror", + "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-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags", + "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", +] + [[package]] name = "ron" version = "0.12.2" @@ -58,6 +576,52 @@ dependencies = [ "unicode-ident", ] +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "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" @@ -85,7 +649,88 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", +] + +[[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 = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[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 = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -116,9 +761,30 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +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 = "typeid" version = "1.0.3" @@ -130,3 +796,69 @@ name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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", +] diff --git a/Cargo.toml b/Cargo.toml index fb519e3..be3cee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,12 @@ serde = { version = "1", features = ["derive"] } ron = "0.12" # Error types with readable messages, for the parts that report problems. thiserror = "2" +# Terminal user interface. The crossterm backend works on Windows, Linux and +# macOS. Default features are off to skip widgets cumulus doesn't use. +ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache"] } +# How many terminal columns text takes (accents, Asian scripts, emoji), for +# sizing node boxes. +unicode-width = "0.2" [lints.rust] unsafe_code = "forbid" diff --git a/DESIGN.md b/DESIGN.md index 1518f27..6c252f4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -66,9 +66,11 @@ lines. - Folding: each branch is Open, Auto (open while the cursor is inside) or Closed (a jump inside opens it until you leave). New branches start Open; a focus toggle makes Open branches act like Auto. -- Zoom levels: Normal (boxes), Far (one line per node, half the spacing), - Overview (colored dots). Body text never shows on the canvas: `K` pops it up, - and the Note view shows all of it. +- Zoom levels: Normal (boxes), Far (one line per node: the same width, half + the height) and Overview (colored dots: a quarter of the width, a third of + the height). Far keeps the width because one-line titles collided when + distances were halved both ways (changed during stage 2). Body text never + shows on the canvas: `K` pops it up, and the Note view shows all of it. - Right-angle lines with proper junctions (`├ ┬ ┼`); lines pass under nodes. - A node's color goes on its border. Each node can pick its border, and the theme's named styles and tag rules can set color and border together. diff --git a/README.md b/README.md index 5fbc608..7794e8f 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,10 @@ cargo test # run the tests cargo lint # clippy, treating warnings as errors cargo fmt # format the code ``` + +While the app is being built, the examples try out each part: + +```sh +cargo run --example canvas # the canvas, on a sample document +cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text +``` diff --git a/examples/canvas.rs b/examples/canvas.rs new file mode 100644 index 0000000..09d5b8b --- /dev/null +++ b/examples/canvas.rs @@ -0,0 +1,443 @@ +//! A playground for the canvas engine (stage 2 of the build): a sample +//! document you can move around, zoom, fold and rearrange. The real keys, +//! modes and command line come in later stages; this has just enough to try +//! the canvas. +//! +//! Run it with `cargo run --example canvas`. +//! +//! | Keys | What they do | +//! |----------------|-----------------------------------------------------| +//! | `hjkl`, arrows | move to the nearest node that way | +//! | `gh gl gj gk` | parent, first child, next / previous sibling | +//! | `gg` | the root | +//! | `HJKL` | pan the view | +//! | `+` `-` | zoom in / out | +//! | `za` `zf` `zi` | fold or unfold, auto-fold, focus | +//! | `zz` | center the view on the cursor | +//! | `a` `o` | add a child / a sibling below ("new idea") | +//! | `x` `dd` | delete the node (its children move up) / the branch | +//! | `M` | move mode: hjkl moves the branch, Enter or Esc ends | +//! | `u` `Ctrl-r` | undo / redo | +//! | `q` | quit | +//! +//! Mouse: click selects, dragging a node moves its branch, dragging empty +//! space pans, the wheel scrolls. + +use std::io; + +use cumulus::model::{ + CanvasOffset, Change, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title, Visible, +}; +use cumulus::storage::document_file; +use cumulus::ui::glyphs::Direction; +use cumulus::ui::theme::Theme; +use cumulus::view::canvas::boxes::{NodeBox, box_rects}; +use cumulus::view::canvas::navigate::nearest; +use cumulus::view::canvas::placement::{below_spot, child_spot}; +use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos}; +use ratatui::crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, + MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::crossterm::execute; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::{DefaultTerminal, Frame}; + +fn main() -> io::Result<()> { + let doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?; + let mut terminal = ratatui::try_init()?; + execute!(io::stdout(), EnableMouseCapture)?; + // On a crash, turn the mouse off too; ratatui's own hook restores the rest. + let restore_the_rest = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = execute!(io::stdout(), DisableMouseCapture); + restore_the_rest(info); + })); + let result = Playground::new(doc).run(&mut terminal); + let _ = execute!(io::stdout(), DisableMouseCapture); + ratatui::restore(); + result +} + +/// A mouse drag in progress. +enum Drag { + /// Moving a node's branch: where the drag started, how far the branch has + /// moved so far, and the undo step for the whole drag. + Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, change: Option }, + /// Panning the view: the cell the mouse was last on. + Pan { last: ScreenPos }, +} + +struct Playground { + doc: Document, + history: History, + cursor: NodeId, + camera: Camera, + options: SceneOptions, + theme: Theme, + /// The first key of a two-key command (`g`, `z`, `d`), waiting for the + /// second. + pending: Option, + /// In move mode: the undo step for the whole move so far. + moving: Option>, + drag: Option, + /// The last frame drawn, to match clicks against. + scene: Scene, + canvas_area: Rect, + /// Set when the cursor moved, so the next frame brings it into view. + follow: bool, + message: String, +} + +impl Playground { + fn new(doc: Document) -> Playground { + Playground { + cursor: doc.root(), + doc, + history: History::default(), + camera: Camera::default(), + options: SceneOptions::default(), + theme: Theme::default(), + pending: None, + moving: None, + drag: None, + scene: Scene::default(), + canvas_area: Rect::default(), + follow: true, + message: String::new(), + } + } + + fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + loop { + terminal.draw(|frame| self.draw(frame))?; + match event::read()? { + // Windows reports key releases too: only presses and repeats count. + Event::Key(key) if key.kind != KeyEventKind::Release => { + if !self.key(key) { + return Ok(()); + } + } + Event::Mouse(mouse) => self.mouse(mouse), + _ => {} + } + } + } + + fn draw(&mut self, frame: &mut Frame) { + let [canvas, status] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area()); + self.canvas_area = canvas; + self.scene = self.build_scene(canvas); + if self.follow { + self.follow = false; + if let Some(rect) = self.scene.rect_of(self.cursor) + && self.camera.reveal(rect, canvas.as_size(), 3) + { + self.scene = self.build_scene(canvas); + } + } + frame.render_widget(&self.scene, canvas); + frame.render_widget(self.status_line(), status); + } + + fn build_scene(&self, area: Rect) -> Scene { + Scene::build(&self.doc, self.cursor, self.camera, area.as_size(), &self.theme, self.options) + } + + fn status_line(&self) -> Line<'static> { + let mode = if self.moving.is_some() { " MOVE " } else { " CANVAS " }; + let title = self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default(); + let focus = if self.options.focus { " · focus" } else { "" }; + let hints = if self.message.is_empty() { + "hjkl move · gh/gl tree · +/- zoom · za/zf/zi fold · a/o add · x/dd delete · M move · u undo · q quit" + .to_string() + } else { + self.message.clone() + }; + let pending = self.pending.map(|key| format!(" {key}")).unwrap_or_default(); + Line::from(vec![ + mode.black().on_cyan().bold(), + format!(" {}{focus} │ {title} │ ", self.camera.zoom.name()).into(), + hints.dark_gray(), + pending.cyan().bold(), + ]) + } + + /// Handles a key. Returns false to quit. + fn key(&mut self, key: KeyEvent) -> bool { + self.message.clear(); + if self.moving.is_some() { + self.move_key(key); + return true; + } + if let Some(first) = self.pending.take() { + if let KeyCode::Char(second) = key.code { + self.sequence(first, second); + } + return true; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('q') => return false, + KeyCode::Char('c') if ctrl => return false, + KeyCode::Char('r') if ctrl => self.redo(), + KeyCode::Char('h') | KeyCode::Left => self.go(Direction::Left), + KeyCode::Char('j') | KeyCode::Down => self.go(Direction::Down), + KeyCode::Char('k') | KeyCode::Up => self.go(Direction::Up), + KeyCode::Char('l') | KeyCode::Right => self.go(Direction::Right), + KeyCode::Char('H') => self.camera.pan(-8, 0), + KeyCode::Char('J') => self.camera.pan(0, 4), + KeyCode::Char('K') => self.camera.pan(0, -4), + KeyCode::Char('L') => self.camera.pan(8, 0), + KeyCode::Char('+' | '=') => self.zoom(true), + KeyCode::Char('-') => self.zoom(false), + KeyCode::Char('a') => self.add_child(), + KeyCode::Char('o') => self.add_below(), + KeyCode::Char('x') => self.delete_node(), + KeyCode::Char('u') => self.undo(), + KeyCode::Char('M') => { + self.moving = Some(None); + self.message = "hjkl moves the branch (HJKL by 5) · Enter or Esc ends".into(); + } + KeyCode::Char(first @ ('g' | 'z' | 'd')) => self.pending = Some(first), + _ => {} + } + true + } + + /// The second key of `g…`, `z…` and `d…`. + fn sequence(&mut self, first: char, second: char) { + match (first, second) { + ('g', 'g') => self.jump(Some(self.doc.root())), + ('g', 'h') => self.jump(self.doc.parent(self.cursor)), + ('g', 'l') => self.jump(self.doc.children(self.cursor).first().copied()), + ('g', 'j') => self.jump(self.doc.next_sibling(self.cursor)), + ('g', 'k') => self.jump(self.doc.previous_sibling(self.cursor)), + ('z', 'a') => self.toggle_fold(), + ('z', 'f') => self.set_fold(Fold::Auto), + ('z', 'i') => { + self.options.focus = !self.options.focus; + self.message = format!("focus {}", if self.options.focus { "on" } else { "off" }); + } + ('z', 'z') => self.center(), + ('d', 'd') => self.delete_branch(), + _ => self.message = format!("{first}{second} does nothing here"), + } + } + + fn move_key(&mut self, key: KeyEvent) { + let (cols, rows) = match key.code { + KeyCode::Char('h') | KeyCode::Left => (-1, 0), + KeyCode::Char('l') | KeyCode::Right => (1, 0), + KeyCode::Char('j') | KeyCode::Down => (0, 1), + KeyCode::Char('k') | KeyCode::Up => (0, -1), + KeyCode::Char('H') => (-5, 0), + KeyCode::Char('L') => (5, 0), + KeyCode::Char('J') => (0, 5), + KeyCode::Char('K') => (0, -5), + KeyCode::Enter | KeyCode::Esc | KeyCode::Char('M') => { + if let Some(Some(change)) = self.moving.take() { + self.history.record(change); + } + return; + } + _ => return, + }; + let (id, offset) = (self.cursor, self.camera.cells(cols, rows)); + if let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, offset)) { + let so_far = self.moving.take().flatten(); + self.moving = Some(Some(match so_far { + Some(change) => change.then(next), + None => next, + })); + self.follow = true; + } + } + + fn mouse(&mut self, mouse: MouseEvent) { + let area = self.canvas_area; + let at = ScreenPos::new( + i32::from(mouse.column) - i32::from(area.x), + i32::from(mouse.row) - i32::from(area.y), + ); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + self.drag = Some(match self.scene.node_at(at) { + Some(id) => { + self.cursor = id; + Drag::Node { id, from: at, moved: CanvasOffset::default(), change: None } + } + None => Drag::Pan { last: at }, + }); + } + MouseEventKind::Drag(MouseButton::Left) => match &mut self.drag { + Some(Drag::Node { id, from, moved, change }) => { + let total = self.camera.cells(at.col - from.col, at.row - from.row); + let step = CanvasOffset::new(total.dx - moved.dx, total.dy - moved.dy); + let id = *id; + if step != CanvasOffset::default() + && let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, step)) + { + *moved = total; + *change = Some(match change.take() { + Some(so_far) => so_far.then(next), + None => next, + }); + } + } + Some(Drag::Pan { last }) => { + self.camera.pan(last.col - at.col, last.row - at.row); + *last = at; + } + None => {} + }, + MouseEventKind::Up(MouseButton::Left) => { + if let Some(Drag::Node { change: Some(change), .. }) = self.drag.take() { + self.history.record(change); + } + } + MouseEventKind::ScrollDown => self.camera.pan(0, 3), + MouseEventKind::ScrollUp => self.camera.pan(0, -3), + MouseEventKind::ScrollRight => self.camera.pan(6, 0), + MouseEventKind::ScrollLeft => self.camera.pan(-6, 0), + _ => {} + } + } + + /// Moves the cursor to the nearest node on show in a direction. + fn go(&mut self, direction: Direction) { + let visible = Visible::new(&self.doc, self.cursor, self.options.focus); + let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs); + let Some(&from) = rects.get(&self.cursor) else { return }; + let candidates = visible.order.iter().filter_map(|id| Some((*id, *rects.get(id)?))); + match nearest(from, direction, candidates) { + Some(id) => self.jump(Some(id)), + None => self.message = "nothing that way".into(), + } + } + + fn jump(&mut self, target: Option) { + match target { + Some(id) => { + self.cursor = id; + self.follow = true; + } + None => self.message = "nothing there".into(), + } + } + + fn zoom(&mut self, closer: bool) { + let zoom = if closer { self.camera.zoom.closer() } else { self.camera.zoom.further() }; + if let Some(node) = self.doc.node(self.cursor) { + self.camera.set_zoom(zoom, node.pos); + } + self.follow = true; + } + + fn center(&mut self) { + if let Some(node) = self.doc.node(self.cursor) { + self.camera.center_on(node.pos, self.canvas_area.as_size()); + } + } + + fn toggle_fold(&mut self) { + let Some(node) = self.doc.node(self.cursor) else { return }; + let fold = match node.fold { + Fold::Closed => Fold::Open, + Fold::Open | Fold::Auto => Fold::Closed, + }; + self.set_fold(fold); + } + + fn set_fold(&mut self, fold: Fold) { + let id = self.cursor; + self.edit(|edit| edit.update(id, |node| node.fold = fold)); + self.message = format!("fold: {fold:?}"); + } + + fn add_child(&mut self) { + let Some(node) = self.new_idea() else { return }; + let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs); + let size = NodeBox::new(&node, String::new(), self.options.max_width, self.theme.glyphs).rect.size; + let parent = self.cursor; + let node = Node { pos: child_spot(&self.doc, parent, size, &rects), ..node }; + let added = self.edit(|edit| edit.add(node, Spot::LastChildOf(parent))); + self.jump(added); + } + + fn add_below(&mut self) { + let Some(node) = self.new_idea() else { return }; + let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs); + let size = NodeBox::new(&node, String::new(), self.options.max_width, self.theme.glyphs).rect.size; + let sibling = self.cursor; + let node = Node { pos: below_spot(sibling, size, &rects), ..node }; + let added = self.edit(|edit| edit.add(node, Spot::After(sibling))); + self.jump(added); + } + + fn new_idea(&self) -> Option { + Title::parse("new idea").ok().map(|title| Node::idea(title, Default::default())) + } + + fn delete_node(&mut self) { + let id = self.cursor; + let next = self.doc.children(id).first().copied().or_else(|| self.doc.parent(id)); + if self.edit(|edit| edit.remove_keeping_children(id)).is_some() { + self.cursor = next.unwrap_or(self.doc.root()); + } + } + + fn delete_branch(&mut self) { + let id = self.cursor; + let next = self.doc.parent(id).or_else(|| self.doc.previous_sibling(id)); + if let Some(removed) = self.edit(|edit| edit.remove_branch(id)) { + self.cursor = next.unwrap_or(self.doc.root()); + self.message = format!("deleted {} node(s) · u to undo", removed.len()); + } + } + + fn undo(&mut self) { + let touched: Vec = match self.history.undo(&mut self.doc) { + Some(change) => change.nodes().collect(), + None => { + self.message = "nothing to undo".into(); + return; + } + }; + self.land_on(&touched); + } + + fn redo(&mut self) { + let touched: Vec = match self.history.redo(&mut self.doc) { + Some(change) => change.nodes().collect(), + None => { + self.message = "nothing to redo".into(); + return; + } + }; + self.land_on(&touched); + } + + /// After undo or redo: the cursor goes where the change happened. + fn land_on(&mut self, touched: &[NodeId]) { + let target = touched.iter().copied().find(|id| self.doc.contains(*id)).unwrap_or(self.doc.root()); + self.jump(Some(target)); + } + + /// Runs an edit and records it for undo; shows the error if it's refused. + fn edit(&mut self, steps: impl FnOnce(&mut Edit<'_>) -> Result) -> Option { + match self.doc.edit(steps) { + Ok((value, change)) => { + self.history.record(change); + Some(value) + } + Err(error) => { + self.message = error.to_string(); + None + } + } + } +} diff --git a/examples/garden.cum b/examples/garden.cum new file mode 100644 index 0000000..3076cab --- /dev/null +++ b/examples/garden.cum @@ -0,0 +1,118 @@ +// cumulus document. Hand edits are fine: cumulus reloads it when it changes. +( + format: 1, + uid: "7f3c9a1e52b04d18", + root: 1, + nodes: [ + ( + id: 1, + title: "Garden plans", + pos: (0, 12), + color: "green", + ), + ( + id: 2, + parent: 1, + title: "Soil", + pos: (22, 2), + tags: "#todo", + ), + ( + id: 3, + parent: 1, + title: "Raised beds", + pos: (22, 7), + color: "orange", + fold: Auto, + ), + ( + id: 4, + parent: 3, + title: "Cedar, untreated", + pos: (43, 5), + ), + ( + id: 5, + parent: 3, + title: "40 cm high", + pos: (43, 9), + ), + ( + id: 6, + parent: 1, + title: "Watering", + pos: (22, 12), + color: "teal", + branch: ( + color: "node", + ), + ), + ( + id: 7, + parent: 6, + title: "Rain barrel", + pos: (40, 12), + ), + ( + id: 8, + parent: 6, + title: "Drip line", + pos: (40, 16), + fold: Closed, + ), + ( + id: 9, + parent: 8, + title: "Timer on the tap", + pos: (59, 16), + border: Double, + ), + ( + id: 10, + parent: 1, + portal: ( + path: "kitchen.cum", + uid: "c01d5e7aa0f3b912", + title: "Kitchen", + ), + pos: (22, 17), + ), + ( + id: 11, + title: "Seeds to order", + pos: (0, 26), + color: "purple", + ), + ( + id: 12, + parent: 11, + title: "Tomatoes", + pos: (24, 24), + ), + ( + id: 13, + parent: 11, + title: "Basil", + pos: (24, 28), + branch: ( + line: Dotted, + ), + ), + ( + id: 14, + title: "ask about compost", + pos: (52, 26), + color: "pink", + border: Dashed, + body: "The neighbor has a bin. +Bring a bucket.", + ), + ], + links: [ + ( + from: 11, + to: 14, + label: "needs", + ), + ], +) diff --git a/src/lib.rs b/src/lib.rs index bb650af..2fc8627 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,9 +8,13 @@ //! |-------------|--------------------------------------------------------------| //! | [`model`] | the data and its rules: documents, nodes, links, undo, folds | //! | [`storage`] | files: documents (RON) | +//! | [`view`] | the views, one module each: so far, the canvas | +//! | [`ui`] | what views share: glyphs, the theme | pub mod model; pub mod storage; +pub mod ui; +pub mod view; /// The line `cumulus --version` prints: the version, and the git commit it was /// built from (recorded by `build.rs`). diff --git a/src/model/edit.rs b/src/model/edit.rs index dce4b68..91443b9 100644 --- a/src/model/edit.rs +++ b/src/model/edit.rs @@ -84,6 +84,27 @@ impl Change { self.before.entries.keys().copied() } + /// This change followed by `next`, as one undo step: for anything both + /// touched, the oldest value from `self` and the newest from `next`. A + /// mouse drag is many small moves but undoes in one go. + pub fn then(mut self, next: Change) -> Change { + for (id, entry) in next.before.entries { + self.before.entries.entry(id).or_insert(entry); + } + for (key, link) in next.before.links { + self.before.links.entry(key).or_insert(link); + } + if self.before.loose.is_none() { + self.before.loose = next.before.loose; + } + self.after.entries.extend(next.after.entries); + self.after.links.extend(next.after.links); + if next.after.loose.is_some() { + self.after.loose = next.after.loose; + } + self + } + pub(super) fn before(&self) -> &Snapshot { &self.before } @@ -538,6 +559,22 @@ mod tests { assert_eq!(pos(g.soil), CanvasPos::new(22, 0), "outside the branch: untouched"); } + #[test] + fn two_changes_make_one_undo_step() { + let (mut doc, g) = garden(); + let start = doc.clone(); + let (_, first) = doc.edit(|e| e.shift_branch(g.beds, CanvasOffset::new(1, 0))).unwrap(); + let (_, second) = doc.edit(|e| e.shift_branch(g.beds, CanvasOffset::new(1, 1))).unwrap(); + let (_, third) = doc.edit(|e| e.add(idea("x"), Spot::Loose)).unwrap(); + let end = doc.clone(); + let mut history = crate::model::History::default(); + history.record(first.then(second).then(third)); + history.undo(&mut doc); + assert_eq!(doc, start); + history.redo(&mut doc); + assert_eq!(doc, end); + } + #[test] fn branch_looks_belong_to_children() { let (mut doc, g) = garden(); diff --git a/src/model/geometry.rs b/src/model/geometry.rs index cfc74b1..17f2552 100644 --- a/src/model/geometry.rs +++ b/src/model/geometry.rs @@ -1,4 +1,4 @@ -//! Places on the canvas. +//! Places and sizes on the canvas. //! //! The canvas is a grid of terminal cells (at Normal zoom) with no edges, so //! coordinates can be negative. `x` grows to the right and `y` downward, as in @@ -64,3 +64,67 @@ impl Neg for CanvasOffset { CanvasOffset::new(-self.dx, -self.dy) } } + +/// A width and a height on the canvas, in cells. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct CanvasSize { + pub width: u16, + pub height: u16, +} + +impl CanvasSize { + /// A size of `width` by `height` cells. + pub const fn new(width: u16, height: u16) -> CanvasSize { + CanvasSize { width, height } + } +} + +/// A rectangle on the canvas: its top-left corner and its size. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct CanvasRect { + pub pos: CanvasPos, + pub size: CanvasSize, +} + +impl CanvasRect { + /// The rectangle with its top-left corner at `pos`. + pub const fn new(pos: CanvasPos, size: CanvasSize) -> CanvasRect { + CanvasRect { pos, size } + } + + /// The first column inside. + pub fn left(self) -> i32 { + self.pos.x + } + + /// The first row inside. + pub fn top(self) -> i32 { + self.pos.y + } + + /// The first column past the right edge. + pub fn right(self) -> i32 { + self.pos.x + i32::from(self.size.width) + } + + /// The first row past the bottom edge. + pub fn bottom(self) -> i32 { + self.pos.y + i32::from(self.size.height) + } + + /// The middle cell (rounded toward the top left). + pub fn center(self) -> CanvasPos { + CanvasPos::new( + self.pos.x + i32::from(self.size.width) / 2, + self.pos.y + i32::from(self.size.height) / 2, + ) + } + + /// Whether the two rectangles share at least one cell. + pub fn overlaps(self, other: CanvasRect) -> bool { + self.left() < other.right() + && other.left() < self.right() + && self.top() < other.bottom() + && other.top() < self.bottom() + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index b877dac..42cbdbf 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -25,7 +25,7 @@ mod text; pub use document::{Document, NodeRecord, Place, StructureError}; pub use edit::{Change, Edit, EditError, Spot}; pub use fold::Visible; -pub use geometry::{CanvasOffset, CanvasPos}; +pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize}; pub use history::{History, StateId}; pub use ids::{DocUid, NodeId}; pub use link::{Link, LinkKey}; diff --git a/src/ui/glyphs.rs b/src/ui/glyphs.rs new file mode 100644 index 0000000..f0b3e75 --- /dev/null +++ b/src/ui/glyphs.rs @@ -0,0 +1,349 @@ +//! The characters cumulus draws with: lines that join properly where they +//! meet (`├ ┬ ┼ ┨`), arrowheads and bullets. Unicode box-drawing by default, +//! and plain ASCII for fonts that don't have it. + +use std::sync::LazyLock; + +use crate::model::Stroke; + +/// How heavy one arm of a line is, in one cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Weight { + Light, + Heavy, + Double, +} + +impl Weight { + /// The weight a stroke is drawn with. Dashed and dotted lines are light; + /// their dashes are drawn separately (see [`Dash`]). + pub fn of(stroke: Stroke) -> Weight { + match stroke { + Stroke::Solid | Stroke::Dashed | Stroke::Dotted => Weight::Light, + Stroke::Thick => Weight::Heavy, + Stroke::Double => Weight::Double, + } + } +} + +/// The four directions on screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Direction { + Up, + Right, + Down, + Left, +} + +impl Direction { + /// The direction pointing the other way. + pub fn opposite(self) -> Direction { + match self { + Direction::Up => Direction::Down, + Direction::Right => Direction::Left, + Direction::Down => Direction::Up, + Direction::Left => Direction::Right, + } + } +} + +/// The arms of the lines that meet in one cell. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct Arms { + pub up: Option, + pub right: Option, + pub down: Option, + pub left: Option, +} + +impl Arms { + /// Adds an arm. Where there's one already, the heavier of the two stays. + pub fn add(&mut self, direction: Direction, weight: Weight) { + let arm = match direction { + Direction::Up => &mut self.up, + Direction::Right => &mut self.right, + Direction::Down => &mut self.down, + Direction::Left => &mut self.left, + }; + *arm = (*arm).max(Some(weight)); + } + + /// Whether no line runs through the cell. + pub fn is_empty(self) -> bool { + self == Arms::default() + } + + /// Whether this is a plain run from left to right (or top to bottom), + /// the only shape that can be drawn dashed. + fn straight(self) -> Option<(bool, Weight)> { + match (self.up, self.right, self.down, self.left) { + (None, Some(a), None, Some(b)) if a == b => Some((true, a)), + (Some(a), None, Some(b), None) if a == b => Some((false, a)), + _ => None, + } + } + + /// The same arms with some weights swapped (to find a character Unicode + /// has, when it has none for the exact mix). + fn with(self, swap: impl Fn(Weight) -> Weight) -> Arms { + Arms { + up: self.up.map(&swap), + right: self.right.map(&swap), + down: self.down.map(&swap), + left: self.left.map(&swap), + } + } + + /// A number from 0 to 255 naming this combination (two bits per arm). + fn key(self) -> usize { + let bits = |arm: Option| match arm { + None => 0, + Some(Weight::Light) => 1, + Some(Weight::Heavy) => 2, + Some(Weight::Double) => 3, + }; + bits(self.up) | bits(self.right) << 2 | bits(self.down) << 4 | bits(self.left) << 6 + } +} + +/// How a straight run of line is broken up. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Dash { + /// `╌╌╌` + Dashed, + /// `┈┈┈` + Dotted, +} + +/// Which characters to draw with. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum Glyphs { + /// Box-drawing characters: `╭─┬─╮ ┃ ►`. + #[default] + Unicode, + /// Plain ASCII, for fonts without box-drawing characters: `+-+-+ | >`. + Ascii, +} + +impl Glyphs { + /// The character for the lines meeting in a cell. `dash` applies to + /// straight runs, and `rounded` to light corners. + pub fn line(self, arms: Arms, dash: Option, rounded: bool) -> char { + match self { + Glyphs::Unicode => unicode_line(arms, dash, rounded), + Glyphs::Ascii => ascii_line(arms, dash), + } + } + + /// An arrowhead pointing `direction`. + pub fn arrow(self, direction: Direction) -> char { + match (self, direction) { + (Glyphs::Unicode, Direction::Up) => '▲', + (Glyphs::Unicode, Direction::Right) => '►', + (Glyphs::Unicode, Direction::Down) => '▼', + (Glyphs::Unicode, Direction::Left) => '◄', + (Glyphs::Ascii, Direction::Up) => '^', + (Glyphs::Ascii, Direction::Right) => '>', + (Glyphs::Ascii, Direction::Down) => 'v', + (Glyphs::Ascii, Direction::Left) => '<', + } + } + + /// The dot before a node's title at Far zoom, and a node at Overview. + pub fn bullet(self) -> char { + match self { + Glyphs::Unicode => '●', + Glyphs::Ascii => 'o', + } + } + + /// The mark in front of a portal's title. + pub fn portal(self) -> char { + match self { + Glyphs::Unicode => '»', + Glyphs::Ascii => '>', + } + } + + /// The mark at the end of text that was cut short. + pub fn ellipsis(self) -> char { + match self { + Glyphs::Unicode => '…', + Glyphs::Ascii => '~', + } + } +} + +fn unicode_line(arms: Arms, dash: Option, rounded: bool) -> char { + if let (Some(dash), Some((horizontal, weight))) = (dash, arms.straight()) + && weight != Weight::Double + { + let heavy = weight == Weight::Heavy; + return match (dash, horizontal, heavy) { + (Dash::Dashed, true, false) => '╌', + (Dash::Dashed, true, true) => '╍', + (Dash::Dashed, false, false) => '╎', + (Dash::Dashed, false, true) => '╏', + (Dash::Dotted, true, false) => '┈', + (Dash::Dotted, true, true) => '┉', + (Dash::Dotted, false, false) => '┊', + (Dash::Dotted, false, true) => '┋', + }; + } + if rounded && let Some(corner) = rounded_corner(arms) { + return corner; + } + // Unicode has every mix of light and heavy, and most mixes of light and + // double. For anything else, draw it lighter. + let attempts = [ + arms, + arms.with(|w| if w == Weight::Heavy { Weight::Light } else { w }), + arms.with(|w| if w == Weight::Double { Weight::Light } else { w }), + arms.with(|_| Weight::Light), + ]; + attempts.into_iter().map(|arms| JUNCTIONS[arms.key()]).find(|&c| c != '\0').unwrap_or(' ') +} + +fn rounded_corner(arms: Arms) -> Option { + let light = Some(Weight::Light); + match (arms.up, arms.right, arms.down, arms.left) { + (None, r, d, None) if r == light && d == light => Some('╭'), + (None, None, d, l) if d == light && l == light => Some('╮'), + (u, None, None, l) if u == light && l == light => Some('╯'), + (u, r, None, None) if u == light && r == light => Some('╰'), + _ => None, + } +} + +fn ascii_line(arms: Arms, dash: Option) -> char { + let horizontal = arms.left.is_some() || arms.right.is_some(); + let vertical = arms.up.is_some() || arms.down.is_some(); + let double = [arms.up, arms.right, arms.down, arms.left].contains(&Some(Weight::Double)); + match (horizontal, vertical) { + (true, false) => match dash { + Some(Dash::Dotted) => '.', + _ if double => '=', + _ => '-', + }, + (false, true) => match dash { + Some(_) => ':', + None => '|', + }, + (true, true) => '+', + (false, false) => ' ', + } +} + +/// Every box-drawing character, by the arms it draws. Each pattern lists the +/// arms up, right, down and left: `.` none, `L` light, `H` heavy, `D` double. +#[rustfmt::skip] +const JUNCTION_PATTERNS: &[(&str, char)] = &[ + // Line ends. + ("L...", '╵'), (".L..", '╶'), ("..L.", '╷'), ("...L", '╴'), + ("H...", '╹'), (".H..", '╺'), ("..H.", '╻'), ("...H", '╸'), + // Straight runs. + (".L.L", '─'), (".H.H", '━'), (".H.L", '╼'), (".L.H", '╾'), + ("L.L.", '│'), ("H.H.", '┃'), ("L.H.", '╽'), ("H.L.", '╿'), + // Corners. + (".LL.", '┌'), (".HL.", '┍'), (".LH.", '┎'), (".HH.", '┏'), + ("..LL", '┐'), ("..LH", '┑'), ("..HL", '┒'), ("..HH", '┓'), + ("LL..", '└'), ("LH..", '┕'), ("HL..", '┖'), ("HH..", '┗'), + ("L..L", '┘'), ("L..H", '┙'), ("H..L", '┚'), ("H..H", '┛'), + // Tees. + ("LLL.", '├'), ("LHL.", '┝'), ("HLL.", '┞'), ("LLH.", '┟'), + ("HLH.", '┠'), ("HHL.", '┡'), ("LHH.", '┢'), ("HHH.", '┣'), + ("L.LL", '┤'), ("L.LH", '┥'), ("H.LL", '┦'), ("L.HL", '┧'), + ("H.HL", '┨'), ("H.LH", '┩'), ("L.HH", '┪'), ("H.HH", '┫'), + (".LLL", '┬'), (".LLH", '┭'), (".HLL", '┮'), (".HLH", '┯'), + (".LHL", '┰'), (".LHH", '┱'), (".HHL", '┲'), (".HHH", '┳'), + ("LL.L", '┴'), ("LL.H", '┵'), ("LH.L", '┶'), ("LH.H", '┷'), + ("HL.L", '┸'), ("HL.H", '┹'), ("HH.L", '┺'), ("HH.H", '┻'), + // Crossings. + ("LLLL", '┼'), ("LLLH", '┽'), ("LHLL", '┾'), ("LHLH", '┿'), + ("HLLL", '╀'), ("LLHL", '╁'), ("HLHL", '╂'), ("HLLH", '╃'), + ("HHLL", '╄'), ("LLHH", '╅'), ("LHHL", '╆'), ("HHLH", '╇'), + ("LHHH", '╈'), ("HLHH", '╉'), ("HHHL", '╊'), ("HHHH", '╋'), + // Double lines, alone and with light ones. + (".D.D", '═'), ("D.D.", '║'), + (".DL.", '╒'), (".LD.", '╓'), (".DD.", '╔'), + ("..LD", '╕'), ("..DL", '╖'), ("..DD", '╗'), + ("LD..", '╘'), ("DL..", '╙'), ("DD..", '╚'), + ("L..D", '╛'), ("D..L", '╜'), ("D..D", '╝'), + ("LDL.", '╞'), ("DLD.", '╟'), ("DDD.", '╠'), + ("L.LD", '╡'), ("D.DL", '╢'), ("D.DD", '╣'), + (".DLD", '╤'), (".LDL", '╥'), (".DDD", '╦'), + ("LD.D", '╧'), ("DL.L", '╨'), ("DD.D", '╩'), + ("LDLD", '╪'), ("DLDL", '╫'), ("DDDD", '╬'), +]; + +/// [`JUNCTION_PATTERNS`] as a table indexed by [`Arms::key`] (`'\0'` where +/// Unicode has no character). +static JUNCTIONS: LazyLock<[char; 256]> = LazyLock::new(|| { + let mut table = ['\0'; 256]; + for (pattern, glyph) in JUNCTION_PATTERNS { + let mut arms = [None; 4]; + for (arm, mark) in arms.iter_mut().zip(pattern.chars()) { + *arm = match mark { + 'L' => Some(Weight::Light), + 'H' => Some(Weight::Heavy), + 'D' => Some(Weight::Double), + _ => None, + }; + } + let [up, right, down, left] = arms; + table[Arms { up, right, down, left }.key()] = *glyph; + } + table +}); + +#[cfg(test)] +mod tests { + use super::*; + use Weight::{Double as D, Heavy as H, Light as L}; + + fn arms(up: Option, right: Option, down: Option, left: Option) -> Arms { + Arms { up, right, down, left } + } + + #[test] + fn every_light_and_heavy_mix_has_a_character() { + let weights = [None, Some(L), Some(H)]; + for up in weights { + for right in weights { + for down in weights { + for left in weights { + let mix = arms(up, right, down, left); + if !mix.is_empty() { + assert_ne!(JUNCTIONS[mix.key()], '\0', "{mix:?}"); + } + } + } + } + } + } + + #[test] + fn junctions_borders_and_dashes() { + let g = Glyphs::Unicode; + assert_eq!( + g.line(arms(Some(H), None, Some(H), Some(L)), None, false), + '┨', + "a line meeting a thick border" + ); + assert_eq!(g.line(arms(None, Some(L), Some(L), None), None, true), '╭'); + assert_eq!(g.line(arms(None, Some(L), Some(L), None), None, false), '┌'); + assert_eq!(g.line(arms(None, Some(L), None, Some(L)), Some(Dash::Dashed), false), '╌'); + assert_eq!( + g.line(arms(Some(L), Some(L), Some(L), None), Some(Dash::Dotted), false), + '├', + "only runs are dashed" + ); + assert_eq!(g.line(arms(Some(D), None, Some(D), Some(L)), None, false), '╢'); + assert_eq!( + g.line(arms(Some(D), Some(H), None, None), None, false), + '╙', + "no double/heavy mix: heavy goes light" + ); + assert_eq!(Glyphs::Ascii.line(arms(Some(L), Some(L), None, None), None, true), '+'); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..87e86c4 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,6 @@ +//! What the views share: the characters they draw with ([`glyphs`]) and the +//! colors and looks ([`theme`]). The which-key popup, the command line, the +//! bars and the other shared widgets come in later stages. + +pub mod glyphs; +pub mod theme; diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..c740905 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,222 @@ +//! Colors, and how looks are worked out. +//! +//! A node's or a line's look ([`NodeLook`], [`LineLook`]) only holds +//! overrides. The theme turns it into something drawable ([`NodeStyle`], +//! [`LineStyle`]) in layers: the theme's default, then the style a tag asks +//! for, then a named style, then the overrides themselves. +//! +//! Theme files (TOML) come with the config (DESIGN.md, stage 8). Until then +//! [`Theme::default`] is the theme. + +use std::collections::BTreeMap; + +use ratatui::style::Color; + +use super::glyphs::Glyphs; +use crate::model::{Arrows, Border, ColorName, LineColor, LineLook, Node, NodeLook, Stroke, StyleName, Tag}; + +/// How to draw a node, all worked out. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NodeStyle { + /// The color of its border (`Color::Reset` when it has none: the + /// terminal's normal text color). + pub color: Color, + pub border: Border, +} + +/// How to draw a line, all worked out. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LineStyle { + pub stroke: Stroke, + pub arrows: Arrows, + pub color: Color, +} + +/// Every color and look the views draw with. +#[derive(Debug, Clone)] +pub struct Theme { + pub glyphs: Glyphs, + /// The accent: the cursor, the focused pane, which-key, the command line. + pub accent: Color, + /// Quiet things: lines, hints, counts. + pub muted: Color, + /// Node colors, by name. + pub palette: BTreeMap, + /// How nodes look, unless something says otherwise. + pub node: NodeLook, + /// How the root looks, on top of `node`. + pub root: NodeLook, + /// How branch lines look, unless something says otherwise. + pub branch: LineLook, + /// How cross-links look, unless something says otherwise. + pub link: LineLook, + /// Named node styles. (A style's own `style` field is ignored: styles + /// don't point at other styles.) + pub node_styles: BTreeMap, + /// Named line styles. + pub line_styles: BTreeMap, + /// Tags that give their nodes a style, applied in this order. + pub tag_styles: Vec<(Tag, StyleName)>, +} + +impl Default for Theme { + /// cumulus's own theme. The palette uses mid-tone colors from the + /// standard 256, which read well on dark and light terminals alike; the + /// accent and the muted color are terminal colors, so they follow your + /// terminal's scheme. + fn default() -> Theme { + let palette = [ + ("red", 167), + ("orange", 172), + ("yellow", 178), + ("green", 71), + ("teal", 37), + ("blue", 68), + ("purple", 134), + ("pink", 169), + ("brown", 137), + ("gray", 245), + ] + .into_iter() + .filter_map(|(name, index)| Some((ColorName::parse(name).ok()?, Color::Indexed(index)))) + .collect(); + Theme { + glyphs: Glyphs::Unicode, + accent: Color::Cyan, + muted: Color::DarkGray, + palette, + node: NodeLook { border: Some(Border::Square), ..NodeLook::default() }, + root: NodeLook { border: Some(Border::Rounded), ..NodeLook::default() }, + branch: LineLook { line: Some(Stroke::Solid), arrows: Some(Arrows::Off), ..LineLook::default() }, + link: LineLook { line: Some(Stroke::Dashed), arrows: Some(Arrows::End), ..LineLook::default() }, + node_styles: BTreeMap::new(), + line_styles: BTreeMap::new(), + tag_styles: Vec::new(), + } + } +} + +impl Theme { + /// How to draw a node. + pub fn node_style(&self, node: &Node, is_root: bool) -> NodeStyle { + let mut color: Option = None; + let mut border = Border::Square; + let mut apply = |look: &NodeLook| { + if let Some(name) = &look.color { + color = Some(name.clone()); + } + if let Some(kind) = look.border { + border = kind; + } + }; + apply(&self.node); + if is_root { + apply(&self.root); + } + for (tag, style) in &self.tag_styles { + if node.tags.contains(tag) + && let Some(look) = self.node_styles.get(style) + { + apply(look); + } + } + if let Some(look) = node.look.style.as_ref().and_then(|style| self.node_styles.get(style)) { + apply(look); + } + apply(&node.look); + NodeStyle { color: color.map_or(Color::Reset, |name| self.color(&name)), border } + } + + /// How to draw a branch line, given the look it has and the color of + /// its child (for lines that follow their node's color). + pub fn branch_style(&self, look: &LineLook, child_color: Color) -> LineStyle { + self.line_style(&self.branch, look, child_color) + } + + /// How to draw a cross-link, given the look it has and the color of the + /// node it starts from. + pub fn link_style(&self, look: &LineLook, start_color: Color) -> LineStyle { + self.line_style(&self.link, look, start_color) + } + + /// A palette color by name. An unknown name (from a document made with + /// another theme) shows as the normal text color. + pub fn color(&self, name: &ColorName) -> Color { + self.palette.get(name).copied().unwrap_or(Color::Reset) + } + + fn line_style(&self, default: &LineLook, look: &LineLook, node_color: Color) -> LineStyle { + let mut style = LineStyle { stroke: Stroke::Solid, arrows: Arrows::Off, color: self.muted }; + let mut color: Option = None; + let mut apply = |layer: &LineLook| { + if let Some(stroke) = layer.line { + style.stroke = stroke; + } + if let Some(arrows) = layer.arrows { + style.arrows = arrows; + } + if let Some(layer_color) = &layer.color { + color = Some(layer_color.clone()); + } + }; + apply(default); + if let Some(named) = look.style.as_ref().and_then(|name| self.line_styles.get(name)) { + apply(named); + } + apply(look); + style.color = match color { + None => self.muted, + // A node without a color has nothing to follow: stay quiet. + Some(LineColor::FollowNode) if node_color == Color::Reset => self.muted, + Some(LineColor::FollowNode) => node_color, + Some(LineColor::Named(name)) => self.color(&name), + }; + style + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::idea; + + fn name(text: &str) -> StyleName { + StyleName::parse(text).unwrap() + } + + #[test] + fn looks_are_worked_out_in_layers() { + let mut theme = Theme::default(); + let yellow = ColorName::parse("yellow").unwrap(); + let red = ColorName::parse("red").unwrap(); + theme.node_styles.insert( + name("question"), + NodeLook { color: Some(yellow.clone()), border: Some(Border::Dashed), style: None }, + ); + theme.tag_styles.push((Tag::parse("question").unwrap(), name("question"))); + + let mut node = idea("why?"); + assert_eq!(theme.node_style(&node, false), NodeStyle { color: Color::Reset, border: Border::Square }); + assert_eq!(theme.node_style(&node, true).border, Border::Rounded, "the root has its own border"); + + node.tags.insert(Tag::parse("question").unwrap()); + assert_eq!( + theme.node_style(&node, false), + NodeStyle { color: theme.color(&yellow), border: Border::Dashed } + ); + + node.look.color = Some(red.clone()); + let style = theme.node_style(&node, false); + assert_eq!((style.color, style.border), (theme.color(&red), Border::Dashed), "own color wins"); + } + + #[test] + fn lines_can_follow_their_node() { + let theme = Theme::default(); + let follow = LineLook { color: Some(LineColor::FollowNode), ..LineLook::default() }; + assert_eq!(theme.branch_style(&follow, Color::Indexed(71)).color, Color::Indexed(71)); + assert_eq!(theme.branch_style(&follow, Color::Reset).color, theme.muted); + let link = theme.link_style(&LineLook::default(), Color::Reset); + assert_eq!((link.stroke, link.arrows), (Stroke::Dashed, Arrows::End)); + } +} diff --git a/src/view/canvas/boxes.rs b/src/view/canvas/boxes.rs new file mode 100644 index 0000000..3d1b255 --- /dev/null +++ b/src/view/canvas/boxes.rs @@ -0,0 +1,153 @@ +//! Node boxes at Normal zoom: how big each box is and what's written in it. +//! +//! ```text +//! ╭─────────────╮ ┌────────────┐ +//! │ Raised beds │ │ » Kitchen │ a portal starts with » +//! ╰─ #todo ─ +2 ╯ └───── 42 ───┘ tags on the left, a badge on the right +//! ``` + +use std::collections::BTreeMap; + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::model::{CanvasRect, CanvasSize, Content, Document, Node, NodeId}; +use crate::ui::glyphs::Glyphs; + +/// The width titles wrap at, unless the config says otherwise. +pub const DEFAULT_MAX_WIDTH: u16 = 30; + +/// What a node's box holds at Normal zoom. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeBox { + /// The box on the canvas, borders included. + pub rect: CanvasRect, + /// The title, wrapped. + pub lines: Vec, + /// The tags, written into the bottom border on the left. + pub tags: String, + /// Written into the bottom border on the right: `+3` for a folded + /// branch's hidden nodes, or a portal's node count. + pub badge: String, +} + +impl NodeBox { + /// Lays out a node's box. Titles wrap at `max_width` columns; the box + /// grows wider than its title, up to that, when the tags and badge need + /// the room. + pub fn new(node: &Node, badge: String, max_width: u16, glyphs: Glyphs) -> NodeBox { + let title = match &node.content { + Content::Idea(idea) => idea.title.to_string(), + Content::Portal(portal) => format!("{} {}", glyphs.portal(), portal.title), + }; + let max_width = usize::from(max_width.max(4)); + let lines = wrap(&title, max_width); + let tags = node.tags.to_string(); + let text_width = lines.iter().map(|line| line.width()).max().unwrap_or(0); + let inner = text_width.max(footer_width(&tags, &badge).saturating_sub(2)).min(max_width); + // A border and a space on each side of the text. + let width = clamp_u16(inner + 4); + let height = clamp_u16(lines.len() + 2); + NodeBox { rect: CanvasRect::new(node.pos, CanvasSize::new(width, height)), lines, tags, badge } + } +} + +/// The boxes of every node in a document at Normal zoom, without badges: +/// enough to tell where nodes are and how much room they take. +pub fn box_rects(doc: &Document, max_width: u16, glyphs: Glyphs) -> BTreeMap { + doc.outline() + .into_iter() + .filter_map(|id| Some((id, NodeBox::new(doc.node(id)?, String::new(), max_width, glyphs).rect))) + .collect() +} + +/// How many cells the bottom border needs for the tags and the badge, +/// between its corners: `─ #todo ─ +3 ─`. +fn footer_width(tags: &str, badge: &str) -> usize { + let part = |text: &str| if text.is_empty() { 0 } else { text.width() + 2 }; + let parts = part(tags) + part(badge); + if parts == 0 { 0 } else { parts + if tags.is_empty() || badge.is_empty() { 2 } else { 3 } } +} + +/// Breaks text into lines at most `width` columns wide, between words when +/// it can. A word longer than a line is cut. +pub fn wrap(text: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + for word in text.split(' ').filter(|word| !word.is_empty()) { + let needed = if line.is_empty() { word.width() } else { line.width() + 1 + word.width() }; + if needed <= width { + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + continue; + } + if !line.is_empty() { + lines.push(std::mem::take(&mut line)); + } + for c in word.chars() { + if line.width() + c.width().unwrap_or(0) > width { + lines.push(std::mem::take(&mut line)); + } + line.push(c); + } + } + if !line.is_empty() || lines.is_empty() { + lines.push(line); + } + lines +} + +/// Text cut to at most `width` columns, ending with `ellipsis` when it was +/// cut. +pub fn truncate(text: &str, width: usize, ellipsis: char) -> String { + if text.width() <= width { + return text.to_string(); + } + let mut cut = String::new(); + for c in text.chars() { + if cut.width() + c.width().unwrap_or(0) + 1 > width { + break; + } + cut.push(c); + } + if width > 0 { + cut.push(ellipsis); + } + cut +} + +fn clamp_u16(value: usize) -> u16 { + u16::try_from(value).unwrap_or(u16::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Tags; + use crate::model::test_support::idea; + + #[test] + fn titles_wrap_between_words() { + assert_eq!(wrap("a raised bed made of cedar", 10), vec!["a raised", "bed made", "of cedar"]); + assert_eq!(wrap("supercalifragilistic", 8), vec!["supercal", "ifragili", "stic"]); + assert_eq!(wrap("", 8), vec![""]); + assert_eq!(wrap("日本語の題名", 6), vec!["日本語", "の題名"], "wide characters take two columns"); + } + + #[test] + fn boxes_fit_their_title_and_footer() { + let mut node = idea("Soil"); + let plain = NodeBox::new(&node, String::new(), 30, Glyphs::Unicode); + assert_eq!((plain.rect.size.width, plain.rect.size.height), (8, 3)); + node.tags = Tags::parse("#todo").unwrap(); + let tagged = NodeBox::new(&node, "+3".into(), 30, Glyphs::Unicode); + assert_eq!(tagged.rect.size.width, 16, "╰─ #todo ─ +3 ─╯ fits"); + } + + #[test] + fn truncating_marks_the_cut() { + assert_eq!(truncate("Raised beds", 20, '…'), "Raised beds"); + assert_eq!(truncate("Raised beds", 7, '…'), "Raised…"); + } +} diff --git a/src/view/canvas/camera.rs b/src/view/canvas/camera.rs new file mode 100644 index 0000000..0b00a90 --- /dev/null +++ b/src/view/canvas/camera.rs @@ -0,0 +1,242 @@ +//! The camera: which part of the canvas a view shows, and how close. It +//! converts between canvas positions and screen cells. + +use ratatui::layout::Size; + +use crate::model::{CanvasOffset, CanvasPos}; + +/// A cell on screen, counted from the top-left corner of the canvas's area. +/// It can lie outside the area (negative, or past its size): nodes can be +/// partly off screen. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct ScreenPos { + pub col: i32, + pub row: i32, +} + +impl ScreenPos { + /// The cell at `col`, `row`. + pub const fn new(col: i32, row: i32) -> ScreenPos { + ScreenPos { col, row } + } +} + +/// A rectangle of screen cells (see [`ScreenPos`]). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct ScreenRect { + pub pos: ScreenPos, + pub width: i32, + pub height: i32, +} + +impl ScreenRect { + /// The rectangle with its top-left cell at `col`, `row`. + pub const fn new(col: i32, row: i32, width: i32, height: i32) -> ScreenRect { + ScreenRect { pos: ScreenPos::new(col, row), width, height } + } + + /// The first column inside. + pub fn left(self) -> i32 { + self.pos.col + } + + /// The first row inside. + pub fn top(self) -> i32 { + self.pos.row + } + + /// The first column past the right edge. + pub fn right(self) -> i32 { + self.pos.col + self.width + } + + /// The first row past the bottom edge. + pub fn bottom(self) -> i32 { + self.pos.row + self.height + } + + /// The middle column (the right one of two, for an even width). + pub fn mid_col(self) -> i32 { + self.pos.col + self.width / 2 + } + + /// The middle row. + pub fn mid_row(self) -> i32 { + self.pos.row + self.height / 2 + } + + /// Whether a cell is inside. + pub fn contains(self, cell: ScreenPos) -> bool { + (self.left()..self.right()).contains(&cell.col) && (self.top()..self.bottom()).contains(&cell.row) + } + + /// The rectangle grown by `cols` on the left and right. + pub fn widened(self, cols: i32) -> ScreenRect { + ScreenRect::new(self.left() - cols, self.top(), self.width + 2 * cols, self.height) + } +} + +/// How far away the canvas is. A terminal can't shrink text, so zooming out +/// shows less of each node instead. +/// +/// A box is three rows tall but a label one, so zooming out mostly saves +/// rows. Far keeps the full width, so titles still fit between nodes, and +/// halves the height: two boxes stacked in a column always land on different +/// rows, and lines keep the gaps between rows. Overview squeezes harder, and +/// lets dots crowd. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum Zoom { + /// Boxes with their titles. + #[default] + Normal, + /// One line per node: the same width, half the height. + Far, + /// One dot per node: a quarter of the width, a third of the height. + Overview, +} + +impl Zoom { + /// One step closer. + pub fn closer(self) -> Zoom { + match self { + Zoom::Normal | Zoom::Far => Zoom::Normal, + Zoom::Overview => Zoom::Far, + } + } + + /// One step further away. + pub fn further(self) -> Zoom { + match self { + Zoom::Normal => Zoom::Far, + Zoom::Far | Zoom::Overview => Zoom::Overview, + } + } + + /// How many canvas cells one screen cell stands for: across, and down. + pub fn scale(self) -> (i32, i32) { + match self { + Zoom::Normal => (1, 1), + Zoom::Far => (1, 2), + Zoom::Overview => (4, 3), + } + } + + /// The zoom's name, for the status bar. + pub fn name(self) -> &'static str { + match self { + Zoom::Normal => "Normal", + Zoom::Far => "Far", + Zoom::Overview => "Overview", + } + } +} + +/// The part of the canvas a view shows. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct Camera { + /// The canvas point shown in the view's top-left cell. + pub origin: CanvasPos, + pub zoom: Zoom, +} + +impl Camera { + /// The screen cell showing a canvas point. + pub fn to_screen(self, pos: CanvasPos) -> ScreenPos { + let (across, down) = self.zoom.scale(); + ScreenPos::new((pos.x - self.origin.x).div_euclid(across), (pos.y - self.origin.y).div_euclid(down)) + } + + /// The canvas point a screen cell shows (its top-left one, when zoomed + /// out and a cell stands for several). + pub fn to_canvas(self, cell: ScreenPos) -> CanvasPos { + self.origin + self.cells(cell.col, cell.row) + } + + /// Moves the view by whole screen cells (to the right and down when + /// positive). + pub fn pan(&mut self, cols: i32, rows: i32) { + self.origin += self.cells(cols, rows); + } + + /// Puts a canvas point in the middle of a view of this size. + pub fn center_on(&mut self, pos: CanvasPos, size: Size) { + self.origin = pos + -self.cells(i32::from(size.width) / 2, i32::from(size.height) / 2); + } + + /// How far a number of screen columns and rows go on the canvas. + pub fn cells(self, cols: i32, rows: i32) -> CanvasOffset { + let (across, down) = self.zoom.scale(); + CanvasOffset::new(cols * across, rows * down) + } + + /// Pans just enough for `rect` to be on screen, `margin` cells from the + /// edges when there's room (like vim's `scrolloff`). Returns whether the + /// view moved. + pub fn reveal(&mut self, rect: ScreenRect, size: Size, margin: i32) -> bool { + let cols = shift_needed(rect.left(), rect.right(), size.width, margin); + let rows = shift_needed(rect.top(), rect.bottom(), size.height, margin); + self.pan(cols, rows); + (cols, rows) != (0, 0) + } + + /// Changes the zoom, keeping `anchor` on the same screen cell. + pub fn set_zoom(&mut self, zoom: Zoom, anchor: CanvasPos) { + let cell = self.to_screen(anchor); + self.zoom = zoom; + self.origin = anchor + -self.cells(cell.col, cell.row); + } +} + +/// How far to pan along one axis for the span `start..end` to fit in +/// `0..length`, `margin` cells from both ends. When it can't fit, its start +/// is the part that shows. +fn shift_needed(start: i32, end: i32, length: u16, margin: i32) -> i32 { + let length = i32::from(length); + let margin = margin.min((length - (end - start)) / 2).max(0); + if start < margin { + start - margin + } else if end > length - margin { + (end - (length - margin)).min(start - margin) + } else { + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canvas_and_screen_convert_both_ways() { + let mut camera = Camera { origin: CanvasPos::new(-10, 5), zoom: Zoom::Normal }; + assert_eq!(camera.to_screen(CanvasPos::new(0, 5)), ScreenPos::new(10, 0)); + assert_eq!(camera.to_canvas(ScreenPos::new(10, 0)), CanvasPos::new(0, 5)); + camera.zoom = Zoom::Overview; + assert_eq!(camera.to_screen(CanvasPos::new(-11, 5)), ScreenPos::new(-1, 0), "rounds toward minus"); + assert_eq!(camera.to_screen(CanvasPos::new(-6, 8)), ScreenPos::new(1, 1)); + assert_eq!(camera.to_canvas(ScreenPos::new(1, 1)), CanvasPos::new(-6, 8)); + } + + #[test] + fn zooming_keeps_the_anchor_in_place() { + let mut camera = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Normal }; + let anchor = CanvasPos::new(40, 20); + let before = camera.to_screen(anchor); + camera.set_zoom(Zoom::Overview, anchor); + assert_eq!(camera.to_screen(anchor), before); + assert_eq!(Zoom::Overview.further(), Zoom::Overview); + assert_eq!(Zoom::Overview.closer().closer(), Zoom::Normal); + } + + #[test] + fn revealing_moves_as_little_as_needed() { + let size = Size::new(40, 10); + let mut camera = Camera::default(); + assert!(!camera.reveal(ScreenRect::new(5, 3, 10, 3), size, 2), "already in view"); + assert!(camera.reveal(ScreenRect::new(35, 3, 10, 3), size, 2)); + assert_eq!(camera.origin.x, 7, "right edge now 2 cells from the view's edge"); + let mut camera = Camera::default(); + camera.reveal(ScreenRect::new(-20, 0, 80, 3), size, 2); + assert_eq!(camera.origin.x, -20, "too wide: its start shows"); + } +} diff --git a/src/view/canvas/lines.rs b/src/view/canvas/lines.rs new file mode 100644 index 0000000..51fb106 --- /dev/null +++ b/src/view/canvas/lines.rs @@ -0,0 +1,275 @@ +//! The line layer: where lines and box borders run on screen, cell by cell. +//! Each cell remembers the arms that meet in it, so where lines cross or join +//! they get the right character (`├ ┬ ┼ ┨`) instead of overwriting each other. + +use ratatui::layout::Size; +use ratatui::style::Color; + +use super::camera::{ScreenPos, ScreenRect}; +use crate::model::{Border, LinkKey, NodeId, Stroke}; +use crate::ui::glyphs::{Arms, Dash, Direction, Glyphs, Weight}; + +/// What a line cell belongs to, so a click on a line knows what it hit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineOwner { + /// The border of a node's box. + Border(NodeId), + /// The branch line from a node's parent down to the node. + Branch(NodeId), + /// A cross-link. + Link(LinkKey), +} + +/// The color and owner given to the cells a line or border passes through. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Paint { + pub color: Color, + pub owner: LineOwner, +} + +/// How the straight runs through a cell are dashed. Lines that disagree +/// make it solid. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum DashMix { + #[default] + Unset, + Solid, + Dashed(Dash), +} + +#[derive(Debug, Clone, Copy, Default)] +struct Cell { + arms: Arms, + dash: DashMix, + rounded: bool, + paint: Option, +} + +/// The lines and borders of one frame, over a view of a given size. Cells +/// outside the view are dropped as they're added. +#[derive(Debug, Clone, Default)] +pub struct LineLayer { + width: i32, + height: i32, + cells: Vec, +} + +impl LineLayer { + /// An empty layer for a view of this size. + pub fn new(size: Size) -> LineLayer { + let (width, height) = (i32::from(size.width), i32::from(size.height)); + LineLayer { width, height, cells: vec![Cell::default(); size.area() as usize] } + } + + /// Adds a box's border. `shown` tells which of its cells are visible: + /// another box drawn on top can hide part of it. + pub fn add_border( + &mut self, + rect: ScreenRect, + border: Border, + paint: Paint, + shown: impl Fn(ScreenPos) -> bool, + ) { + let stroke = match border { + Border::Rounded | Border::Square => Stroke::Solid, + Border::Double => Stroke::Double, + Border::Thick => Stroke::Thick, + Border::Dashed => Stroke::Dashed, + Border::Hidden => return, + }; + if rect.width < 2 || rect.height < 2 { + return; + } + let (left, top, right, bottom) = (rect.left(), rect.top(), rect.right() - 1, rect.bottom() - 1); + let corners = [ + (ScreenPos::new(left, top), Direction::Right, Direction::Down), + (ScreenPos::new(right, top), Direction::Left, Direction::Down), + (ScreenPos::new(left, bottom), Direction::Right, Direction::Up), + (ScreenPos::new(right, bottom), Direction::Left, Direction::Up), + ]; + for (at, one, other) in corners { + if shown(at) { + self.add_arm(at, one, stroke, paint); + self.add_arm(at, other, stroke, paint); + if border == Border::Rounded + && let Some(cell) = self.cell_mut(at) + { + cell.rounded = true; + } + } + } + for col in left + 1..right { + for row in [top, bottom] { + let at = ScreenPos::new(col, row); + if shown(at) { + self.add_arm(at, Direction::Left, stroke, paint); + self.add_arm(at, Direction::Right, stroke, paint); + } + } + } + for row in top + 1..bottom { + for col in [left, right] { + let at = ScreenPos::new(col, row); + if shown(at) { + self.add_arm(at, Direction::Up, stroke, paint); + self.add_arm(at, Direction::Down, stroke, paint); + } + } + } + } + + /// Adds a line through `points`, joined by straight runs (each pair of + /// points shares a row or a column). Cells for which `skip` is true are + /// left out: that's where the line passes under a box. + pub fn add_path( + &mut self, + points: &[ScreenPos], + stroke: Stroke, + paint: Paint, + skip: impl Fn(ScreenPos) -> bool, + ) { + for pair in points.windows(2) { + let [from, to] = [pair[0], pair[1]]; + let Some(direction) = direction_between(from, to) else { continue }; + let mut at = from; + loop { + if !skip(at) { + if at != to { + self.add_arm(at, direction, stroke, paint); + } + if at != from { + self.add_arm(at, direction.opposite(), stroke, paint); + } + } + if at == to { + break; + } + at = step(at, direction); + } + } + } + + /// Adds one arm to a cell, as where a line meets a border. A border's + /// color and owner stay: the line only adds its arm. + pub fn add_arm(&mut self, at: ScreenPos, direction: Direction, stroke: Stroke, paint: Paint) { + let Some(cell) = self.cell_mut(at) else { return }; + cell.arms.add(direction, Weight::of(stroke)); + let dash = match stroke { + Stroke::Dashed => DashMix::Dashed(Dash::Dashed), + Stroke::Dotted => DashMix::Dashed(Dash::Dotted), + Stroke::Solid | Stroke::Thick | Stroke::Double => DashMix::Solid, + }; + cell.dash = match cell.dash { + DashMix::Unset => dash, + same if same == dash => same, + _ => DashMix::Solid, + }; + let is_border = matches!(cell.paint, Some(Paint { owner: LineOwner::Border(_), .. })); + if !is_border { + cell.paint = Some(paint); + } + } + + /// Every cell a line runs through, with its character and color. + pub fn cells(&self, glyphs: Glyphs) -> impl Iterator + '_ { + self.cells.iter().enumerate().filter(|(_, cell)| !cell.arms.is_empty()).map(move |(index, cell)| { + let index = index as i32; + let at = ScreenPos::new(index % self.width, index / self.width); + let dash = match cell.dash { + DashMix::Dashed(dash) => Some(dash), + DashMix::Unset | DashMix::Solid => None, + }; + let color = cell.paint.map_or(Color::Reset, |paint| paint.color); + (at, glyphs.line(cell.arms, dash, cell.rounded), color) + }) + } + + /// What the line at a cell belongs to, if a line runs there. + pub fn owner(&self, at: ScreenPos) -> Option { + let cell = self.cell(at)?; + if cell.arms.is_empty() { None } else { cell.paint.map(|paint| paint.owner) } + } + + /// Whether any line runs through a cell. + pub fn is_used(&self, at: ScreenPos) -> bool { + self.cell(at).is_some_and(|cell| !cell.arms.is_empty()) + } + + fn index(&self, at: ScreenPos) -> Option { + let inside = (0..self.width).contains(&at.col) && (0..self.height).contains(&at.row); + inside.then(|| (at.row * self.width + at.col) as usize) + } + + fn cell(&self, at: ScreenPos) -> Option<&Cell> { + self.index(at).and_then(|index| self.cells.get(index)) + } + + fn cell_mut(&mut self, at: ScreenPos) -> Option<&mut Cell> { + self.index(at).and_then(|index| self.cells.get_mut(index)) + } +} + +/// The direction from one cell to another on the same row or column. +pub fn direction_between(from: ScreenPos, to: ScreenPos) -> Option { + match (to.col - from.col, to.row - from.row) { + (0, 0) => None, + (0, rows) if rows < 0 => Some(Direction::Up), + (0, _) => Some(Direction::Down), + (cols, 0) if cols < 0 => Some(Direction::Left), + (_, 0) => Some(Direction::Right), + _ => None, + } +} + +/// The next cell in a direction. +pub fn step(at: ScreenPos, direction: Direction) -> ScreenPos { + match direction { + Direction::Up => ScreenPos::new(at.col, at.row - 1), + Direction::Right => ScreenPos::new(at.col + 1, at.row), + Direction::Down => ScreenPos::new(at.col, at.row + 1), + Direction::Left => ScreenPos::new(at.col - 1, at.row), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(layer: &LineLayer, size: Size) -> Vec { + let mut rows = vec![vec![' '; size.width as usize]; size.height as usize]; + for (at, glyph, _) in layer.cells(Glyphs::Unicode) { + rows[at.row as usize][at.col as usize] = glyph; + } + rows.into_iter().map(|row| row.into_iter().collect()).collect() + } + + #[test] + fn lines_join_borders_and_each_other() { + let size = Size::new(12, 5); + let mut layer = LineLayer::new(size); + let paint = Paint { color: Color::Reset, owner: LineOwner::Border(NodeId::new(1)) }; + let box_rect = ScreenRect::new(0, 0, 5, 5); + layer.add_border(box_rect, Border::Thick, paint, |_| true); + let line = Paint { color: Color::Reset, owner: LineOwner::Branch(NodeId::new(2)) }; + let path = [ScreenPos::new(4, 2), ScreenPos::new(7, 2), ScreenPos::new(7, 4), ScreenPos::new(11, 4)]; + layer.add_path(&path, Stroke::Solid, line, |at| box_rect.contains(at)); + layer.add_arm(ScreenPos::new(4, 2), Direction::Right, Stroke::Solid, line); + layer.add_path(&[ScreenPos::new(9, 0), ScreenPos::new(9, 4)], Stroke::Dashed, line, |_| false); + assert_eq!( + text(&layer, size), + vec![ + "┏━━━┓ ╷ ", // + "┃ ┃ ╎ ", + "┃ ┠──┐ ╎ ", + "┃ ┃ │ ╎ ", + "┗━━━┛ └─┴─╴", + ] + ); + assert_eq!( + layer.owner(ScreenPos::new(4, 2)), + Some(LineOwner::Border(NodeId::new(1))), + "borders keep theirs" + ); + assert_eq!(layer.owner(ScreenPos::new(5, 2)), Some(LineOwner::Branch(NodeId::new(2)))); + } +} diff --git a/src/view/canvas/mod.rs b/src/view/canvas/mod.rs new file mode 100644 index 0000000..8771d32 --- /dev/null +++ b/src/view/canvas/mod.rs @@ -0,0 +1,24 @@ +//! The canvas view: nodes placed freely in 2D, branches and cross-links +//! drawn as right-angle lines, and three zoom levels. +//! +//! How a frame is made: +//! 1. [`Scene::build`] works out what's drawn where: [`boxes`] sizes each +//! node, the [`camera`] turns canvas positions into screen cells, +//! [`route`] finds a path for every line, and [`lines`] joins them cell by +//! cell. +//! 2. Rendering the scene (in `render.rs`) writes it into the terminal. +//! 3. Clicks are looked up in the same scene ([`Scene::node_at`]); hjkl uses +//! [`navigate`], and [`placement`] decides where new nodes go. + +pub mod boxes; +pub mod camera; +pub mod lines; +pub mod navigate; +pub mod placement; +mod render; +pub mod route; +pub mod scene; + +pub use camera::{Camera, ScreenPos, ScreenRect, Zoom}; +pub use lines::LineOwner; +pub use scene::{Scene, SceneNode, SceneOptions, Shape}; diff --git a/src/view/canvas/navigate.rs b/src/view/canvas/navigate.rs new file mode 100644 index 0000000..70ee6d2 --- /dev/null +++ b/src/view/canvas/navigate.rs @@ -0,0 +1,59 @@ +//! Moving between nodes with hjkl: the nearest node in a direction. + +use crate::model::{CanvasRect, NodeId}; +use crate::ui::glyphs::Direction; + +/// The node to go to from `from`, heading `direction`: the nearest one whose +/// middle lies that way. Nodes within 45° of straight ahead come first, and +/// being off to the side counts double against a node, so moving right +/// prefers what's straight ahead. Rows count double too, because a terminal +/// cell is about twice as tall as it is wide. +/// +/// `candidates` are the nodes on show, with their boxes at Normal zoom +/// (wherever the camera is, so the choice doesn't change with the zoom). +pub fn nearest( + from: CanvasRect, + direction: Direction, + candidates: impl IntoIterator, +) -> Option { + let origin = from.center(); + candidates + .into_iter() + .filter_map(|(id, rect)| { + let to = rect.center(); + let (across, down) = (to.x - origin.x, (to.y - origin.y) * 2); + let (ahead, aside) = match direction { + Direction::Right => (across, down), + Direction::Left => (-across, down), + Direction::Down => (down, across), + Direction::Up => (-down, across), + }; + let off_course = aside.abs() > ahead; + (ahead > 0).then_some((off_course, ahead + 2 * aside.abs(), id)) + }) + .min() + .map(|(_, _, id)| id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::garden; + use crate::ui::glyphs::Glyphs; + use crate::view::canvas::boxes::box_rects; + + #[test] + fn hjkl_goes_to_the_nearest_node_that_way() { + let (doc, g) = garden(); + let rects = box_rects(&doc, 30, Glyphs::Unicode); + let go = + |from, direction| nearest(rects[&from], direction, rects.iter().map(|(id, rect)| (*id, *rect))); + assert_eq!(go(g.root, Direction::Right), Some(g.beds), "straight ahead beats above or below"); + assert_eq!(go(g.beds, Direction::Up), Some(g.soil)); + assert_eq!(go(g.beds, Direction::Down), Some(g.watering)); + assert_eq!(go(g.beds, Direction::Right), Some(g.cedar)); + assert_eq!(go(g.beds, Direction::Left), Some(g.root)); + assert_eq!(go(g.root, Direction::Down), Some(g.compost)); + assert_eq!(go(g.cedar, Direction::Right), None); + } +} diff --git a/src/view/canvas/placement.rs b/src/view/canvas/placement.rs new file mode 100644 index 0000000..6f6f4f6 --- /dev/null +++ b/src/view/canvas/placement.rs @@ -0,0 +1,80 @@ +//! Where new nodes go on the canvas: next to their parent or sibling, in a +//! free spot. Nothing else moves to make room. + +use std::collections::BTreeMap; + +use crate::model::{CanvasPos, CanvasRect, CanvasSize, Document, NodeId}; + +/// Columns between a parent and its children: room for the branch lines. +const BRANCH_GAP: i32 = 6; +/// Rows kept free between boxes stacked one above another. +const ROW_GAP: i32 = 1; + +/// Where a new last child of `parent` goes: to the right of the parent, below +/// its last child (level with the parent for a first child), then further +/// down until it's clear of every other box. +pub fn child_spot( + doc: &Document, + parent: NodeId, + size: CanvasSize, + rects: &BTreeMap, +) -> CanvasPos { + let Some(parent_rect) = rects.get(&parent) else { return CanvasPos::default() }; + let start = match doc.children(parent).last().and_then(|child| rects.get(child)) { + Some(last) => CanvasPos::new(last.left(), last.bottom() + ROW_GAP), + None => CanvasPos::new(parent_rect.right() + BRANCH_GAP, parent_rect.top()), + }; + free_spot(start, size, rects) +} + +/// Where a new node just after `sibling` goes: right below it, then further +/// down until it's clear. +pub fn below_spot(sibling: NodeId, size: CanvasSize, rects: &BTreeMap) -> CanvasPos { + let Some(rect) = rects.get(&sibling) else { return CanvasPos::default() }; + free_spot(CanvasPos::new(rect.left(), rect.bottom() + ROW_GAP), size, rects) +} + +/// The first spot at `start` or below it where a box of `size` keeps clear of +/// every box in `rects` (by a row above and below). +pub fn free_spot(start: CanvasPos, size: CanvasSize, rects: &BTreeMap) -> CanvasPos { + let mut spot = start; + // Each step jumps below the box in the way, and no box is in the way + // twice, so there are at most as many steps as boxes. + for _ in 0..=rects.len() { + let wanted = CanvasRect::new( + CanvasPos::new(spot.x, spot.y - ROW_GAP), + CanvasSize::new(size.width, size.height + 2 * ROW_GAP as u16), + ); + match rects.values().filter(|rect| rect.overlaps(wanted)).map(|rect| rect.bottom()).max() { + Some(bottom) => spot.y = bottom + ROW_GAP, + None => break, + } + } + spot +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::garden; + use crate::ui::glyphs::Glyphs; + use crate::view::canvas::boxes::box_rects; + + #[test] + fn new_nodes_find_a_free_spot_nearby() { + let (doc, g) = garden(); + let rects = box_rects(&doc, 30, Glyphs::Unicode); + let size = CanvasSize::new(10, 3); + assert_eq!(child_spot(&doc, g.root, size, &rects), CanvasPos::new(22, 12), "below the last child"); + assert_eq!( + child_spot(&doc, g.cedar, size, &rects), + CanvasPos::new(55, 4), + "first child: level with it" + ); + assert_eq!( + below_spot(g.soil, size, &rects), + CanvasPos::new(22, 12), + "Raised beds and Watering are in the way" + ); + } +} diff --git a/src/view/canvas/render.rs b/src/view/canvas/render.rs new file mode 100644 index 0000000..1e16980 --- /dev/null +++ b/src/view/canvas/render.rs @@ -0,0 +1,291 @@ +//! Drawing a scene into the terminal's buffer: the lines first, then the +//! arrowheads and labels over them, then the nodes on top. + +use ratatui::buffer::{Buffer, Cell}; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::Widget; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::boxes::truncate; +use super::camera::ScreenPos; +use super::scene::{Scene, SceneNode, Shape}; + +impl Widget for &Scene { + fn render(self, area: Rect, buf: &mut Buffer) { + let mut painter = Painter { area, buf }; + for (at, glyph, color) in self.lines.cells(self.glyphs) { + painter.put(at, glyph, Style::new().fg(color)); + } + for mark in &self.marks { + painter.text(mark.at, &mark.text, Style::new().fg(mark.color), |_| true); + } + for (index, node) in self.nodes.iter().enumerate() { + // Only draw where this node is the topmost one. + let mine = |at: ScreenPos| self.covered_at(at) == Some(index); + match &node.shape { + Shape::Box { lines, tags, badge, .. } => { + self.draw_box(&mut painter, node, lines, tags, badge, mine) + } + Shape::Label(text) => self.draw_label(&mut painter, node, text), + Shape::Dot => { + let style = + if node.selected { self.selected_style() } else { Style::new().fg(node.color) }; + painter.put(node.rect.pos, self.glyphs.bullet(), style); + } + } + } + } +} + +impl Scene { + /// A box: the border is already drawn (it's in the line layer); this + /// clears the inside and writes the title, the tags and the badge. + fn draw_box( + &self, + painter: &mut Painter<'_>, + node: &SceneNode, + lines: &[String], + tags: &str, + badge: &str, + mine: impl Fn(ScreenPos) -> bool, + ) { + let rect = node.rect; + for row in rect.top() + 1..rect.bottom() - 1 { + for col in rect.left() + 1..rect.right() - 1 { + let at = ScreenPos::new(col, row); + if mine(at) { + painter.put(at, ' ', Style::reset()); + } + } + } + let title_style = + if node.selected { Style::new().add_modifier(Modifier::BOLD) } else { Style::new() }; + for (row, line) in (rect.top() + 1..).zip(lines) { + painter.text(ScreenPos::new(rect.left() + 2, row), line, title_style, &mine); + } + + // The bottom border: ─ #tags ─ … ─ badge ─ + let bottom = rect.bottom() - 1; + let badge_start = if badge.is_empty() { + rect.right() - 1 + } else { + let text = format!(" {badge} "); + let start = rect.right() - 2 - text.width() as i32; + painter.text(ScreenPos::new(start, bottom), &text, Style::new().fg(self.muted), &mine); + start - 1 + }; + if !tags.is_empty() { + let room = usize::try_from(badge_start - (rect.left() + 2) - 2).unwrap_or(0); + let text = format!(" {} ", truncate(tags, room, self.glyphs.ellipsis())); + let color = if node.color == Color::Reset { self.muted } else { node.color }; + painter.text(ScreenPos::new(rect.left() + 2, bottom), &text, Style::new().fg(color), &mine); + } + } + + /// Far zoom: a colored bullet and the title on one line. + fn draw_label(&self, painter: &mut Painter<'_>, node: &SceneNode, text: &str) { + if node.selected { + painter.text(node.rect.pos, text, self.selected_style(), |_| true); + return; + } + let mut chars = text.chars(); + if let Some(bullet) = chars.next() { + painter.put(node.rect.pos, bullet, Style::new().fg(node.color)); + let rest = ScreenPos::new(node.rect.left() + 1, node.rect.top()); + painter.text(rest, chars.as_str(), Style::new(), |_| true); + } + } + + /// The cursor's node when zoomed out: dark text on the accent color. + fn selected_style(&self) -> Style { + Style::new().fg(Color::Black).bg(self.accent) + } +} + +/// Writes into the part of the buffer the canvas covers, in screen cells +/// counted from its top-left corner; anything outside is dropped. +pub(super) struct Painter<'a> { + pub(super) area: Rect, + pub(super) buf: &'a mut Buffer, +} + +impl Painter<'_> { + fn cell(&mut self, at: ScreenPos) -> Option<&mut Cell> { + let col = u16::try_from(at.col).ok().filter(|&col| col < self.area.width)?; + let row = u16::try_from(at.row).ok().filter(|&row| row < self.area.height)?; + self.buf.cell_mut((self.area.x + col, self.area.y + row)) + } + + /// Puts one character in a cell. + pub(super) fn put(&mut self, at: ScreenPos, glyph: char, style: Style) { + if let Some(cell) = self.cell(at) { + cell.set_char(glyph).set_style(style); + } + } + + /// Writes text from `at` rightward, on the cells `allowed` lets through. + pub(super) fn text( + &mut self, + at: ScreenPos, + text: &str, + style: Style, + allowed: impl Fn(ScreenPos) -> bool, + ) { + let mut col = at.col; + let mut last: Option = None; + for c in text.chars() { + let width = c.width().unwrap_or(0) as i32; + if width == 0 { + // An accent written as its own character joins the previous cell. + if let Some(cell) = last.and_then(|previous| self.cell(previous)) { + let joined = format!("{}{c}", cell.symbol()); + cell.set_symbol(&joined); + } + continue; + } + let here = ScreenPos::new(col, at.row); + last = None; + if allowed(here) && self.cell(here).is_some() { + if let Some(cell) = self.cell(here) { + cell.set_char(c).set_style(style); + } + // A wide character covers the next cell too. + for extra in 1..width { + if let Some(cell) = self.cell(ScreenPos::new(col + extra, at.row)) { + cell.reset(); + } + } + last = Some(here); + } + col += width; + } + } +} + +#[cfg(test)] +mod tests { + use ratatui::layout::Size; + + use super::*; + use crate::model::test_support::garden; + use crate::model::{CanvasPos, Fold, Label, Link}; + use crate::ui::theme::Theme; + use crate::view::canvas::{Camera, SceneOptions, Zoom}; + + /// Prints the example document (`examples/garden.cum`) at each zoom, to + /// look at the canvas without a terminal: + /// `cargo test show_canvas -- --ignored --nocapture` + #[test] + #[ignore = "prints the canvas; run it by name"] + fn show_canvas() { + let doc = crate::storage::document_file::parse(include_str!("../../../examples/garden.cum")).unwrap(); + for zoom in [Zoom::Normal, Zoom::Far, Zoom::Overview] { + let camera = Camera { origin: CanvasPos::new(-1, 0), zoom }; + let scene = Scene::build( + &doc, + doc.root(), + camera, + Size::new(84, 36), + &Theme::default(), + SceneOptions::default(), + ); + println!("── {} {}", zoom.name(), "─".repeat(70)); + for row in snapshot(&scene) { + println!("{row}"); + } + } + } + + /// The scene as plain text, one string per row. + fn snapshot(scene: &Scene) -> Vec { + let area = Rect::new(0, 0, scene.size().width, scene.size().height); + let mut buf = Buffer::empty(area); + scene.render(area, &mut buf); + (0..area.height) + .map(|row| (0..area.width).map(|col| buf[(col, row)].symbol().to_string()).collect::()) + .map(|row| row.trim_end().to_string()) + .collect() + } + + #[test] + fn the_garden_at_normal_zoom() { + let (doc, g) = garden(); + let scene = Scene::build( + &doc, + g.beds, + Camera::default(), + Size::new(52, 17), + &Theme::default(), + SceneOptions::default(), + ); + assert_eq!( + snapshot(&scene), + vec![ + " ┌──────┐", + " ┌───┤ Soil │", + " │ └──────┘", + " │", + "╭──────────────╮ │ ┏━━━━━━━━━━━━━┓ ┌───────┐", + "│ Garden plans ├──┼───┨ Raised beds ┠───┤ Cedar │", + "╰──────────────╯ │ ┗━━━━━━━━━━━━━┛ └───────┘", + " │", + " │ ┌──────────┐", + " └───┤ Watering │", + " └──────────┘", + "", + "", + "", + "┌───────────────────┐", + "│ ask about compost │", + "└───────────────────┘", + ] + ); + assert_eq!(scene.node_at(crate::view::canvas::ScreenPos::new(30, 5)), Some(g.beds)); + } + + #[test] + fn folded_branches_links_and_far_zoom() { + let (mut doc, g) = garden(); + doc.edit(|e| { + e.update(g.beds, |node| node.fold = Fold::Closed)?; + let mut link = Link::new(g.compost, g.watering).unwrap(); + link.label = Some(Label::parse("feeds").unwrap()); + e.link(link)?; + e.update(g.compost, |node| node.pos = CanvasPos::new(0, 16)) + }) + .unwrap(); + let scene = Scene::build( + &doc, + g.root, + Camera::default(), + Size::new(52, 19), + &Theme::default(), + SceneOptions::default(), + ); + let text = snapshot(&scene); + assert!(text[6].ends_with("── +1 ─┘"), "the hidden Cedar shows as +1: {text:#?}"); + assert!(text.iter().any(|row| row.contains("feeds")), "the link's label: {text:#?}"); + assert_eq!(scene.node_at(crate::view::canvas::ScreenPos::new(42, 5)), None, "Cedar is folded away"); + + // Far zoom: one line per node, half the height. The link climbs from + // the compost note up into Watering, labeled where it runs level. + let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far }; + let scene = + Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), SceneOptions::default()); + assert_eq!( + snapshot(&scene), + vec![ + " ┌─── ● Soil", + " │", + "● Garden plans ──┼─── ● Raised beds +1", + " │", + " └─── ● Watering", + " ▲", + " ┌╌╌╌╌╌ feeds ╌╌╌╌╌╌┘", + " ╎", + "● ask about com…", + ] + ); + } +} diff --git a/src/view/canvas/route.rs b/src/view/canvas/route.rs new file mode 100644 index 0000000..aa2e264 --- /dev/null +++ b/src/view/canvas/route.rs @@ -0,0 +1,200 @@ +//! Routes for lines between two boxes on screen: straight runs joined by +//! right-angle bends, from an edge of one box to an edge of the other. +//! +//! ```text +//! ╭────────╮ ┌──────┐ A box to the right: leave by the right edge, +//! │ parent ├──┼──────┤ child bend once or twice, arrive by the left edge. +//! ╰────────╯ └──────┤ child Branches bend close to the parent, so siblings +//! share the bend like a bus. +//! ``` + +use super::camera::{ScreenPos, ScreenRect}; +use super::lines::{direction_between, step}; +use crate::ui::glyphs::Direction; + +/// Where a line that has to change rows (or columns) bends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bend { + /// Close to the start: a parent's branches leave together and share one + /// bend, like a bus. + NearStart, + /// Halfway between the boxes: for cross-links. + Halfway, +} + +/// A line between two boxes, as the cells where it starts, bends and ends. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Route { + /// The cell on the first box's edge where the line leaves. + pub start: ScreenPos, + /// The cells where the line turns. + pub bends: Vec, + /// The cell on the second box's edge where the line arrives. + pub end: ScreenPos, +} + +/// How far from its start a [`Bend::NearStart`] line bends, across and down. +const NEAR_START_COLS: i32 = 3; +const NEAR_START_ROWS: i32 = 2; + +/// The route from box `from` to box `to`, or `None` when the boxes touch or +/// overlap and no line fits between them. +/// +/// Branches leave sideways whenever there's a free column between the boxes, +/// so all of a parent's branches share their bend. Cross-links go through the +/// wider gap, sideways or up and down (rows count double: cells are tall). +pub fn route(from: ScreenRect, to: ScreenRect, bend: Bend) -> Option { + let cols_between = (to.left() - from.right()).max(from.left() - to.right()); + let rows_between = (to.top() - from.bottom()).max(from.top() - to.bottom()); + let go_sideways = cols_between > 0 + && match bend { + Bend::NearStart => true, + Bend::Halfway => rows_between <= 0 || cols_between >= rows_between * 2, + }; + if go_sideways { + let route = if to.left() > from.right() { + let start = ScreenPos::new(from.right() - 1, from.mid_row()); + sideways(start, ScreenPos::new(to.left(), to.mid_row()), bend) + } else { + let start = ScreenPos::new(from.left(), from.mid_row()); + sideways(start, ScreenPos::new(to.right() - 1, to.mid_row()), bend) + }; + Some(route) + } else if to.top() > from.bottom() { + let start = ScreenPos::new(from.mid_col(), from.bottom() - 1); + Some(upright(start, ScreenPos::new(to.mid_col(), to.top()), bend)) + } else if to.bottom() < from.top() { + let start = ScreenPos::new(from.mid_col(), from.top()); + Some(upright(start, ScreenPos::new(to.mid_col(), to.bottom() - 1), bend)) + } else { + None + } +} + +/// A route that leaves and arrives sideways, bending in a column between. +fn sideways(start: ScreenPos, end: ScreenPos, bend: Bend) -> Route { + if start.row == end.row { + return Route { start, bends: Vec::new(), end }; + } + let way = (end.col - start.col).signum(); + let near = start.col + way * NEAR_START_COLS; + let col = match bend { + Bend::NearStart if (end.col - near) * way > 0 => near, + Bend::NearStart | Bend::Halfway => (start.col + end.col) / 2, + }; + Route { start, bends: vec![ScreenPos::new(col, start.row), ScreenPos::new(col, end.row)], end } +} + +/// A route that leaves and arrives up or down, bending in a row between. +fn upright(start: ScreenPos, end: ScreenPos, bend: Bend) -> Route { + if start.col == end.col { + return Route { start, bends: Vec::new(), end }; + } + let way = (end.row - start.row).signum(); + let near = start.row + way * NEAR_START_ROWS; + let row = match bend { + Bend::NearStart if (end.row - near) * way > 0 => near, + Bend::NearStart | Bend::Halfway => (start.row + end.row) / 2, + }; + Route { start, bends: vec![ScreenPos::new(start.col, row), ScreenPos::new(end.col, row)], end } +} + +impl Route { + /// Every corner of the line, start and end included. + pub fn points(&self) -> Vec { + let mut points = vec![self.start]; + points.extend(&self.bends); + points.push(self.end); + points + } + + /// The way the line leaves its start. + pub fn start_direction(&self) -> Option { + direction_between(self.start, *self.bends.first().unwrap_or(&self.end)) + } + + /// The way the line is going when it arrives at its end. + pub fn end_direction(&self) -> Option { + direction_between(*self.bends.last().unwrap_or(&self.start), self.end) + } + + /// Where an arrowhead pointing into the end box goes: the cell just before + /// the end, and the way it points. + pub fn arrow_at_end(&self) -> Option<(ScreenPos, Direction)> { + let way = self.end_direction()?; + Some((step(self.end, way.opposite()), way)) + } + + /// Where an arrowhead pointing back into the start box goes. + pub fn arrow_at_start(&self) -> Option<(ScreenPos, Direction)> { + let way = self.start_direction()?; + Some((step(self.start, way), way.opposite())) + } + + /// Where a label `width` columns wide fits: centered on the longest level + /// run of the line, keeping clear of its ends. + pub fn label_spot(&self, width: i32) -> Option { + let points = self.points(); + points + .windows(2) + .filter(|pair| pair[0].row == pair[1].row) + .map(|pair| { + let (low, high) = (pair[0].col.min(pair[1].col), pair[0].col.max(pair[1].col)); + // Keep a cell of line visible at each end of the run. + (low + 2, high - 2, pair[0].row) + }) + .filter(|(low, high, _)| high - low + 1 >= width) + .max_by_key(|(low, high, _)| high - low) + .map(|(low, high, row)| ScreenPos::new((low + high + 1 - width) / 2, row)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn points(route: &Route) -> Vec<(i32, i32)> { + route.points().iter().map(|p| (p.col, p.row)).collect() + } + + #[test] + fn boxes_side_by_side_join_sideways() { + let parent = ScreenRect::new(0, 4, 16, 3); + let level = route(parent, ScreenRect::new(22, 4, 15, 3), Bend::NearStart).unwrap(); + assert_eq!(points(&level), vec![(15, 5), (22, 5)]); + let above = route(parent, ScreenRect::new(22, 0, 8, 3), Bend::NearStart).unwrap(); + assert_eq!(points(&above), vec![(15, 5), (18, 5), (18, 1), (22, 1)], "bends near the parent"); + let link = route(parent, ScreenRect::new(22, 0, 8, 3), Bend::Halfway).unwrap(); + assert_eq!(points(&link), vec![(15, 5), (18, 5), (18, 1), (22, 1)]); + let left = route(ScreenRect::new(30, 0, 8, 3), ScreenRect::new(0, 0, 8, 3), Bend::Halfway).unwrap(); + assert_eq!(points(&left), vec![(30, 1), (7, 1)]); + assert_eq!(left.arrow_at_end(), Some((ScreenPos::new(8, 1), Direction::Left))); + } + + #[test] + fn boxes_above_each_other_join_up_and_down() { + let below = route(ScreenRect::new(0, 0, 9, 3), ScreenRect::new(4, 8, 9, 3), Bend::NearStart).unwrap(); + assert_eq!(points(&below), vec![(4, 2), (4, 4), (8, 4), (8, 8)]); + assert_eq!(below.arrow_at_end(), Some((ScreenPos::new(8, 7), Direction::Down))); + assert_eq!(below.arrow_at_start(), Some((ScreenPos::new(4, 3), Direction::Up))); + assert!(route(ScreenRect::new(0, 0, 9, 3), ScreenRect::new(5, 2, 9, 3), Bend::Halfway).is_none()); + } + + #[test] + fn links_take_the_wider_gap_and_branches_stay_sideways() { + // One free column, five free rows: a link goes up, a branch sideways. + let (low, high) = (ScreenRect::new(0, 16, 21, 3), ScreenRect::new(22, 8, 12, 3)); + assert_eq!( + points(&route(low, high, Bend::Halfway).unwrap()), + vec![(10, 16), (10, 13), (28, 13), (28, 10)] + ); + assert_eq!(route(low, high, Bend::NearStart).unwrap().start, ScreenPos::new(20, 17)); + } + + #[test] + fn labels_sit_on_the_longest_level_run() { + let line = Route { start: ScreenPos::new(0, 0), bends: vec![], end: ScreenPos::new(20, 0) }; + assert_eq!(line.label_spot(7), Some(ScreenPos::new(7, 0))); + assert_eq!(line.label_spot(18), None, "too long for the run"); + } +} diff --git a/src/view/canvas/scene.rs b/src/view/canvas/scene.rs new file mode 100644 index 0000000..b26737f --- /dev/null +++ b/src/view/canvas/scene.rs @@ -0,0 +1,316 @@ +//! The scene: everything the canvas draws in one frame, worked out from the +//! document, the cursor, the camera and the theme. Clicks are looked up in +//! the same scene, so what you click is what you see. + +use std::collections::BTreeMap; + +use ratatui::layout::Size; +use ratatui::style::Color; +use unicode_width::UnicodeWidthStr; + +use super::boxes::{DEFAULT_MAX_WIDTH, NodeBox, truncate}; +use super::camera::{Camera, ScreenPos, ScreenRect, Zoom}; +use super::lines::{LineLayer, LineOwner, Paint}; +use super::route::{Bend, route}; +use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible}; +use crate::ui::glyphs::Glyphs; +use crate::ui::theme::{LineStyle, Theme}; + +/// How many columns of a title show at Far zoom. +const FAR_TITLE_WIDTH: usize = 14; + +/// What a scene needs besides the document, the cursor and the camera. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SceneOptions { + /// The width titles wrap at. + pub max_width: u16, + /// Focus mode: Open branches act like Auto ones. + pub focus: bool, +} + +impl Default for SceneOptions { + fn default() -> SceneOptions { + SceneOptions { max_width: DEFAULT_MAX_WIDTH, focus: false } + } +} + +/// How a node is drawn at the camera's zoom. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Shape { + /// Normal zoom: a box with the title inside, and the tags and a badge in + /// its bottom border. + Box { lines: Vec, tags: String, badge: String, border: Border }, + /// Far zoom: a bullet and the title, on one line. + Label(String), + /// Overview: a dot. + Dot, +} + +/// A node, as drawn. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SceneNode { + pub id: NodeId, + /// Where it is on screen. + pub rect: ScreenRect, + pub shape: Shape, + /// Its color: the border, bullet or dot. + pub color: Color, + /// Whether the cursor is on it. + pub selected: bool, +} + +/// Text drawn over the lines: an arrowhead or a label. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Mark { + pub(super) at: ScreenPos, + pub(super) text: String, + pub(super) color: Color, +} + +/// Everything the canvas draws in one frame. +#[derive(Debug, Clone, Default)] +pub struct Scene { + pub(super) size: Size, + pub(super) zoom: Zoom, + pub(super) glyphs: Glyphs, + pub(super) accent: Color, + pub(super) muted: Color, + /// The nodes in drawing order: the cursor's last, on top. + pub(super) nodes: Vec, + /// Where each node is in `nodes`. + index: BTreeMap, + /// For each screen cell, the topmost node drawn on it (its index in + /// `nodes`). + covered: Vec>, + pub(super) lines: LineLayer, + pub(super) marks: Vec, +} + +impl Scene { + /// Works out the frame: which nodes show (folding, the cursor), where + /// they are on screen, and every line between them. + pub fn build( + doc: &Document, + cursor: NodeId, + camera: Camera, + size: Size, + theme: &Theme, + options: SceneOptions, + ) -> Scene { + let visible = Visible::new(doc, cursor, options.focus); + let mut scene = Scene { + size, + zoom: camera.zoom, + glyphs: theme.glyphs, + accent: theme.accent, + muted: theme.muted, + covered: vec![None; size.area() as usize], + lines: LineLayer::new(size), + ..Scene::default() + }; + for &id in &visible.order { + let Some(node) = doc.node(id) else { continue }; + let style = theme.node_style(node, id == doc.root()); + let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default(); + let (rect, shape) = shape(node, badge, style.border, camera, theme.glyphs, options.max_width); + scene.nodes.push(SceneNode { id, rect, shape, color: style.color, selected: id == cursor }); + } + // The cursor's node goes last, on top of any it overlaps. + if let Some(index) = scene.nodes.iter().position(|node| node.selected) { + let node = scene.nodes.remove(index); + scene.nodes.push(node); + } + scene.index = scene.nodes.iter().enumerate().map(|(index, node)| (node.id, index)).collect(); + scene.cover(); + scene.add_borders(); + scene.add_lines(doc, theme); + scene + } + + /// The nodes on screen, in drawing order. + pub fn nodes(&self) -> &[SceneNode] { + &self.nodes + } + + /// The size of the view the scene was built for. + pub fn size(&self) -> Size { + self.size + } + + /// Where a node is on screen, if it's shown. + pub fn rect_of(&self, id: NodeId) -> Option { + self.index.get(&id).map(|&index| self.nodes[index].rect) + } + + /// The node drawn on a cell (the topmost, where boxes overlap). + pub fn node_at(&self, at: ScreenPos) -> Option { + self.covered_at(at).map(|index| self.nodes[index].id) + } + + /// What the line on a cell belongs to, if a line runs there. + pub fn line_at(&self, at: ScreenPos) -> Option { + self.lines.owner(at) + } + + /// The index (in `nodes`) of the topmost node on a cell. + pub(super) fn covered_at(&self, at: ScreenPos) -> Option { + covered_at(&self.covered, self.size, at) + } + + /// Records which node is drawn on each cell. Later nodes are on top. + fn cover(&mut self) { + let (width, height) = (i32::from(self.size.width), i32::from(self.size.height)); + for (index, node) in self.nodes.iter().enumerate() { + let rect = node.rect; + for row in rect.top().max(0)..rect.bottom().min(height) { + for col in rect.left().max(0)..rect.right().min(width) { + self.covered[(row * width + col) as usize] = Some(index); + } + } + } + } + + /// Adds the boxes' borders to the line layer, so lines joining them get + /// the right characters. The cursor's box is thick, in the accent color. + fn add_borders(&mut self) { + let Scene { nodes, covered, lines, size, accent, .. } = self; + for (index, node) in nodes.iter().enumerate() { + let Shape::Box { border, .. } = &node.shape else { continue }; + let (border, color) = + if node.selected { (Border::Thick, *accent) } else { (*border, node.color) }; + let paint = Paint { color, owner: LineOwner::Border(node.id) }; + lines.add_border(node.rect, border, paint, |at| covered_at(covered, *size, at) == Some(index)); + } + } + + /// Adds every branch (from a shown parent to a shown child) and every + /// cross-link between shown nodes. + fn add_lines(&mut self, doc: &Document, theme: &Theme) { + for child_index in 0..self.nodes.len() { + let child = &self.nodes[child_index]; + let Some(Place::Child { parent, branch }) = doc.place(child.id) else { continue }; + let Some(&parent_index) = self.index.get(parent) else { continue }; + let style = theme.branch_style(branch, child.color); + let owner = LineOwner::Branch(child.id); + self.connect(parent_index, child_index, Bend::NearStart, style, owner, None); + } + for link in doc.links() { + let (Some(&start), Some(&end)) = (self.index.get(&link.start()), self.index.get(&link.end())) + else { + continue; + }; + let style = theme.link_style(&link.look, self.nodes[start].color); + let label = link.label.as_ref().map(|label| label.as_str()); + self.connect(start, end, Bend::Halfway, style, LineOwner::Link(link.key()), label); + } + } + + /// Draws one line from node `from` to node `to` (indexes in `nodes`), + /// with its arrowheads and label. + fn connect( + &mut self, + from: usize, + to: usize, + bend: Bend, + style: LineStyle, + owner: LineOwner, + label: Option<&str>, + ) { + let normal = self.zoom == Zoom::Normal; + // Zoomed out there are no borders to join, so lines stop a cell short + // of the text instead. + let pad = if normal { 0 } else { 1 }; + let Some(route) = route(self.nodes[from].rect.widened(pad), self.nodes[to].rect.widened(pad), bend) + else { + return; + }; + let arrow_at_start = matches!(style.arrows, Arrows::Start | Arrows::Both); + let arrow_at_end = matches!(style.arrows, Arrows::End | Arrows::Both); + let paint = Paint { color: style.color, owner }; + let Scene { covered, lines, marks, size, glyphs, .. } = self; + let size = *size; + let free = |at: ScreenPos| covered_at(covered, size, at).is_none(); + + // Under boxes the line is hidden; zoomed out, its two end cells are + // the gap before the text. + let ends_hidden = !normal; + lines.add_path(&route.points(), style.stroke, paint, |at| { + !free(at) || (ends_hidden && (at == route.start || at == route.end)) + }); + // Join the line to the borders, unless an arrowhead touches the box. + if normal { + if !arrow_at_start + && covered_at(covered, size, route.start) == Some(from) + && let Some(way) = route.start_direction() + { + lines.add_arm(route.start, way, style.stroke, paint); + } + if !arrow_at_end + && covered_at(covered, size, route.end) == Some(to) + && let Some(way) = route.end_direction() + { + lines.add_arm(route.end, way.opposite(), style.stroke, paint); + } + } + let arrows = [(arrow_at_start, route.arrow_at_start()), (arrow_at_end, route.arrow_at_end())]; + for (wanted, spot) in arrows { + if let (true, Some((at, pointing))) = (wanted, spot) + && free(at) + { + marks.push(Mark { at, text: glyphs.arrow(pointing).to_string(), color: style.color }); + } + } + if let Some(label) = label { + let text = format!(" {label} "); + let width = text.width() as i32; + if let Some(at) = route.label_spot(width) + && (0..width).all(|offset| free(ScreenPos::new(at.col + offset, at.row))) + { + marks.push(Mark { at, text, color: style.color }); + } + } + } +} + +/// The index of the topmost node on a cell, in a `covered` grid. +fn covered_at(covered: &[Option], size: Size, at: ScreenPos) -> Option { + let (width, height) = (i32::from(size.width), i32::from(size.height)); + if !(0..width).contains(&at.col) || !(0..height).contains(&at.row) { + return None; + } + covered.get((at.row * width + at.col) as usize).copied().flatten() +} + +/// Where a node is on screen and how it's drawn, at the camera's zoom. +fn shape( + node: &Node, + badge: String, + border: Border, + camera: Camera, + glyphs: Glyphs, + max_width: u16, +) -> (ScreenRect, Shape) { + let at = camera.to_screen(node.pos); + match camera.zoom { + Zoom::Normal => { + let node_box = NodeBox::new(node, badge, max_width, glyphs); + let size = node_box.rect.size; + let rect = ScreenRect::new(at.col, at.row, i32::from(size.width), i32::from(size.height)); + (rect, Shape::Box { lines: node_box.lines, tags: node_box.tags, badge: node_box.badge, border }) + } + Zoom::Far => { + let title = match &node.content { + Content::Idea(idea) => idea.title.to_string(), + Content::Portal(portal) => format!("{} {}", glyphs.portal(), portal.title), + }; + let mut text = + format!("{} {}", glyphs.bullet(), truncate(&title, FAR_TITLE_WIDTH, glyphs.ellipsis())); + if !badge.is_empty() { + text.push(' '); + text.push_str(&badge); + } + (ScreenRect::new(at.col, at.row, text.width() as i32, 1), Shape::Label(text)) + } + Zoom::Overview => (ScreenRect::new(at.col, at.row, 1, 1), Shape::Dot), + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs new file mode 100644 index 0000000..d2c1c9a --- /dev/null +++ b/src/view/mod.rs @@ -0,0 +1,4 @@ +//! The views, one module each. Stage 5 of the build adds the interface they +//! all share; for now there's the canvas. + +pub mod canvas;