From 3b58d6494d93f2eb21940c0966d5533ff8d7bb9f Mon Sep 17 00:00:00 2001 From: Skkeye Date: Thu, 1 Oct 2026 01:17:38 -0400 Subject: [PATCH] Add the canvas engine and a canvas playground example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas draws a frame in three steps. Scene::build works out what's shown and where: folding decides which nodes show, boxes are sized from their wrapped titles (with tags and a +N badge in the bottom border), the camera turns canvas positions into screen cells, and every branch and cross-link gets a right-angle route. The line layer records which arms meet in each cell, so borders and lines join with the right box-drawing character (├ ┼ ┨ ╭), in any mix of light, thick, double, dashed and dotted. Rendering then writes lines, arrowheads and labels, and the nodes on top. Branches leave sideways and bend near their parent, so siblings share one bus; cross-links take the wider gap and carry their label on the longest level run. Far zoom keeps the full width and halves the height (one line per node), Overview shows dots. hjkl picks the nearest node within 45° of the direction, and new nodes find a free spot next to their parent or sibling. The theme works out looks in layers (default, tag style, named style, the node's own), with a mid-tone palette for dark and light terminals, and an ASCII fallback for fonts without box drawing. `cargo run --example canvas` opens a sample document to move around, zoom, fold, edit and drag with the mouse. The tests compare rendered text against the DESIGN.md mockup. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 736 ++++++++++++++++++++++++++++++++++- Cargo.toml | 6 + DESIGN.md | 8 +- README.md | 7 + examples/canvas.rs | 443 +++++++++++++++++++++ examples/garden.cum | 118 ++++++ src/lib.rs | 4 + src/model/edit.rs | 37 ++ src/model/geometry.rs | 66 +++- src/model/mod.rs | 2 +- src/ui/glyphs.rs | 349 +++++++++++++++++ src/ui/mod.rs | 6 + src/ui/theme.rs | 222 +++++++++++ src/view/canvas/boxes.rs | 153 ++++++++ src/view/canvas/camera.rs | 242 ++++++++++++ src/view/canvas/lines.rs | 275 +++++++++++++ src/view/canvas/mod.rs | 24 ++ src/view/canvas/navigate.rs | 59 +++ src/view/canvas/placement.rs | 80 ++++ src/view/canvas/render.rs | 291 ++++++++++++++ src/view/canvas/route.rs | 200 ++++++++++ src/view/canvas/scene.rs | 316 +++++++++++++++ src/view/mod.rs | 4 + 23 files changed, 3641 insertions(+), 7 deletions(-) create mode 100644 examples/canvas.rs create mode 100644 examples/garden.cum create mode 100644 src/ui/glyphs.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/theme.rs create mode 100644 src/view/canvas/boxes.rs create mode 100644 src/view/canvas/camera.rs create mode 100644 src/view/canvas/lines.rs create mode 100644 src/view/canvas/mod.rs create mode 100644 src/view/canvas/navigate.rs create mode 100644 src/view/canvas/placement.rs create mode 100644 src/view/canvas/render.rs create mode 100644 src/view/canvas/route.rs create mode 100644 src/view/canvas/scene.rs create mode 100644 src/view/mod.rs 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;