diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..64808e5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1673 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.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.5", +] + +[[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.5", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.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 = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazymail" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "ratatui", +] + +[[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 2.13.2", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.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 = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ad3c7de --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "lazymail" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "A Lazygit-style terminal UI for browsing the git mailing list (public-inbox / lore.kernel.org)" +license = "MIT" + +[dependencies] +anyhow = "1.0.104" +crossterm = "0.29.0" +ratatui = { version = "0.30.2", features = ["crossterm"] } + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" +unused_must_use = "deny" + +# Stub scaffold: pedantic/nursery on, with project-wide opinions below. +# `module_name_repetitions`: `MockBackend`/`LoreBackend` mirror their real-world +# counterparts, so the repetition is the point. +# `must_use_candidate` / `missing_errors_doc`: ad-hoc anyhow errors and tiny +# style builders make these two lints pure noise in this crate. +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" diff --git a/README.md b/README.md index 467009a..d601616 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,81 @@ # lazymail -A TUI for the git mailing list. \ No newline at end of file +A TUI for the git mailing list. + +## Status + +Early scaffold: a Lazygit-style TUI over in-memory mock data, with a real +on-disk file picker for patch attachments. + +- **Browse** — three panes: threads, messages, preview. +- **Participants** — filter threads and messages by participant (`f`), or + narrow to the selected thread's author with one key (`F`). +- **Preview** — shows the message body with any inline diff hidden; press `p` + to open the patch in a wide overlay. +- **Live** — threads and messages auto-refresh on a 2s tick while live (`t`). +- **Sender** — a separate screen for composing and sending a patch series + (`s`), with a yazi-like file picker rooted at `$HOME`. +- **Help** — `?` separates global keybinds from context-local ones. + +The real backend will fetch each list's public-inbox archive (e.g. +[`lore.kernel.org/git`](https://lore.kernel.org/git/)) or shell out to +`b4`/`lei`. Sending is not implemented yet. + +## Layout + +Browse: + +``` +┌ lazymail · git.vger.kernel.org ● LIVE · ⌕ vdye@github.com 8 threads ┐ +├─ Threads (25%) ────┬─ Messages (35%) ──────────┬─ Preview ─────────────────────┤ +│ ▍[PATCH v4 0/5]... │ ▍[PATCH v4 0/5] cover ... │ From: Victoria Dye │ +│ author · date │ From ... · to git@... │ Date: ... │ +│ [PATCH v2 0/2]... │ [PATCH v4 1/5] ... │ Subject: [PATCH v4 0/5] ... │ +│ ... │ ... │ body (diff hidden) │ +│ │ │ patch hidden · press p │ +├────────────────────┴───────────────────────────┴───────────────────────────────┤ +│ 1-3 focus · Tab cycle · j/k move · / filter · f participants · p patch ... │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +Sender (the right column is the file picker, active while the `Patches` field is +focused): + +``` +┌ lazymail · sender · git.vger.kernel.org ───────────────────────────────────────┐ +├─ Compose ──────────────────────────────────┬─ ~/patches/v4 ─────────────────────┤ +│ ▸ To: git@vger.kernel.org │ ../ │ +│ Cc: (none) │ ▍0001-refs-reftable.patch 3.2 KB │ +│ Subject: [PATCH 0/2] ... │ 0002-tests.patch 1.1 KB │ +│ Body: │ README.md │ +│ Cover letter stub. │ │ +├────────────────────────────────────────────┴────────────────────────────────────┤ +│ tab field · ←/→ cursor · j/k picker · enter attach · . hidden · ^S send ... │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +## Run + +```sh +cargo run +``` + +Keys: + +- **Browse**: `1`/`2`/`3` or `Tab`/`h`/`l` focus panes · `j`/`k` or arrows move · + `enter` drill into the next pane (threads → messages → preview) · `PgUp`/`PgDn` + scroll the preview · `/` text filter · `f` participant filter · `F` filter by the + selected thread's author · `c` clear participant filter · `p` open the patch + overlay (Preview focused) · `t` toggle live refresh · `r` refresh · + `L` mailing lists · `s` sender · `?` help · `q` quit. +- **Participants**: type to search · `j`/`k` move · `enter` apply · `esc` close. +- **Patch overlay**: `j`/`k` scroll · `esc`/`p`/`enter` close. +- **Lists**: `j`/`k` move · `enter` switch list · `esc` close. +- **Sender**: `Tab` cycle fields · arrows/`Home`/`End` move the cursor · + `Backspace`/`Delete` edit · in the `Patches` field `j`/`k` move the picker, + `l`/`Right`/`enter` open a directory or attach a file, `h`/`Left`/`Backspace` + go up, `.` toggle hidden files, `J`/`K` move the attached-patch selection, + `a` add a stub patch, `d` remove · + `Ctrl+S` send (stub) · `esc` back. +- **Help**: `?` shows global bindings first, then the bindings local to the + current screen, mode, and focused pane. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..ebae497 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2024" +max_width = 100 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..c69d957 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,94 @@ +//! User actions produced by key events and consumed by [`App`](crate::app::App). + +use crate::app::Panel; + +/// Semantic user intent, decoupled from concrete key bindings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + /// Leave the application. + Quit, + /// Focus a specific pane. + Focus(Panel), + /// Focus the next pane (Tab order). + CycleFocus, + /// Focus the previous pane. + CycleFocusBack, + /// Move the selection up in the focused list. + MoveUp, + /// Move the selection down in the focused list. + MoveDown, + /// Toggle the help overlay. + ToggleHelp, + /// Enter/leave filter-input mode. + ToggleFilter, + /// Open the selected message (future: pager/browser). Stub no-op. + Open, + /// Insert one character into the focused text input. + Input(char), + /// Delete the last character of the focused text input. + Backspace, + /// Leave the current mode / close the overlay / return to browsing. + Back, + /// Re-fetch data from the backend. + Refresh, + /// Open the mailing-list picker overlay. + OpenLists, + /// Open the Sender screen. + OpenSender, + /// Confirm the current overlay selection (switch list). + Confirm, + /// Focus the next compose field. + NextField, + /// Focus the previous compose field. + PrevField, + /// Attach a stub patch to the compose form. + AddPatch, + /// Remove the selected patch from the compose form. + RemovePatch, + /// Trigger the (stubbed) send. + Send, + /// Advance one clock tick (auto-refresh gating). + Tick, + /// Toggle live auto-refresh. + ToggleLive, + /// Toggle patch visibility in the preview. + TogglePatch, + /// Open the participants overlay. + OpenParticipants, + /// Remove the participant filter. + ClearParticipantFilter, + /// Filter threads/messages by the selected thread's author (one-key shortcut). + FilterBySelectedAuthor, + /// Apply the filter input to the thread list. + ApplyFilter, + /// Cancel filtering and clear the filter input. + CancelFilter, + /// Move the cursor one character left in the focused text field. + CursorLeft, + /// Move the cursor one character right in the focused text field. + CursorRight, + /// Move the cursor to the start of the focused text field. + CursorHome, + /// Move the cursor to the end of the focused text field. + CursorEnd, + /// Delete the character after the cursor. + DeleteForward, + /// Move the cursor up one line in the body. + CursorUp, + /// Move the cursor down one line in the body. + CursorDown, + /// Activate the selected file-picker entry. + PickerEnter, + /// Move the file-picker selection up. + PickerUp, + /// Move the file-picker selection down. + PickerDown, + /// Ascend to the file picker's parent directory. + PickerParent, + /// Toggle hidden entries in the file picker. + PickerToggleHidden, + /// Scroll the preview down. + ScrollDown, + /// Scroll the preview up. + ScrollUp, +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3c612b1 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,771 @@ +//! Application state: screen, focus, mode, selections, backend handle. + +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +use crate::action::Action; +use crate::backend::{self, Backend}; +use crate::compose::{Compose, ComposeField}; +use crate::config::{BackendKind, Config}; +use crate::models::{Author, MailingList, Message, Participant, Thread}; +use crate::picker::{self, PickerOutcome}; + +/// The three panes of the browse screen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Panel { + /// Left column: list of threads. + Threads, + /// Middle column: messages of the selected thread. + Messages, + /// Right column: preview of the selected message. + Preview, +} + +impl Panel { + /// The pane after `self` in Tab order, wrapping. + #[must_use] + pub const fn next(self) -> Self { + match self { + Self::Threads => Self::Messages, + Self::Messages => Self::Preview, + Self::Preview => Self::Threads, + } + } + + /// The pane before `self` in Tab order, wrapping. + #[must_use] + pub const fn prev(self) -> Self { + match self { + Self::Threads => Self::Preview, + Self::Messages => Self::Threads, + Self::Preview => Self::Messages, + } + } +} + +/// Which top-level screen is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Screen { + /// Read the list: threads, messages, preview. + Browse, + /// Compose and send a patch series. + Sender, +} + +/// How the app interprets keys. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// Global keys apply. + Normal, + /// Typing edits the filter input. + Filter, + /// Help overlay is open; almost any key closes it. + Help, + /// Mailing-list picker is open. + Lists, + /// Participants overlay is open. + Participants, + /// Patch overlay (wide diff view) is open. + Patch, +} + +/// Top-level application state. +pub struct App { + /// Data source for the active list. + pub backend: Box, + /// Backend selector, kept so a list switch can rebuild the backend. + pub kind: BackendKind, + /// All configured mailing lists. + pub lists: Vec, + /// Index of the active list. + pub active_list: usize, + /// Cursor in the list picker. + pub list_selected: usize, + /// Active top-level screen. + pub screen: Screen, + /// Sender compose state. + pub compose: Compose, + /// Focused pane (browse screen only). + pub focus: Panel, + /// Key interpretation mode. + pub mode: Mode, + /// Filter input edited in [`Mode::Filter`]. + pub filter_input: String, + /// Whether the text filter is applied to the thread list. + pub filter_active: bool, + /// Selected thread index, clamped by `clamp_selections`. + pub thread_selected: usize, + /// Selected message index within the selected thread. + pub message_selected: usize, + /// Participant filter: only threads/messages involving this author. + pub participant_filter: Option, + /// Participant-overlay search query. + pub participant_query: String, + /// Selected participant in the overlay. + pub participant_selected: usize, + /// Filesystem picker for attaching patch files. + pub picker: picker::FilePicker, + /// Whether auto-refresh ticks are enabled. + pub live: bool, + /// Clock of the last auto-refresh. + pub last_tick: Instant, + /// Preview scroll offset (lines). + pub preview_scroll: u16, + /// Patch-overlay scroll offset (lines). + pub patch_scroll: u16, + /// Set by [`Action::Quit`] to end the event loop. + pub quit: bool, +} + +impl App { + /// Create the app with the backend selected by `config`. + #[must_use] + pub fn new(config: &Config) -> Self { + let backend = backend::from_config(config); + let lists = config.lists.clone(); + let active_list = config.active_list.min(lists.len().saturating_sub(1)); + let compose = lists + .get(active_list) + .map_or_else(|| Compose::new(&backend::default_list()), Compose::new); + Self { + backend, + kind: config.kind, + lists, + active_list, + list_selected: active_list, + screen: Screen::Browse, + compose, + focus: Panel::Threads, + mode: Mode::Normal, + filter_input: String::new(), + filter_active: false, + thread_selected: 0, + message_selected: 0, + participant_filter: None, + participant_query: String::new(), + participant_selected: 0, + picker: picker::FilePicker::new_home(), + live: true, + last_tick: Instant::now(), + preview_scroll: 0, + patch_scroll: 0, + quit: false, + } + } + + /// Apply one action. + /// + /// # Errors + /// + /// Propagates backend failures from [`Action::Refresh`] and [`Action::Tick`]. + // Allow: this is a pure one-arm-per-action dispatcher; splitting it would + // scatter the routing logic without reducing its complexity. + #[allow(clippy::too_many_lines)] + pub fn handle(&mut self, action: Action) -> anyhow::Result<()> { + match action { + Action::Quit => self.quit = true, + Action::Focus(panel) => self.focus = panel, + Action::CycleFocus => self.focus = self.focus.next(), + Action::CycleFocusBack => self.focus = self.focus.prev(), + Action::MoveUp => self.move_selection(-1), + Action::MoveDown => self.move_selection(1), + Action::ToggleHelp => self.toggle(Mode::Help), + Action::ToggleFilter => self.toggle(Mode::Filter), + Action::Open => { + // Deliberately not Panel::next(): Enter must not wrap back to Threads. + self.focus = match self.focus { + Panel::Threads => Panel::Messages, + Panel::Messages | Panel::Preview => Panel::Preview, + }; + } + Action::Input(ch) => self.input_char(ch), + Action::Backspace => self.backspace(), + Action::Back => self.back(), + Action::Refresh => { + self.backend.refresh()?; + self.clamp_selections(); + } + Action::Tick => { + if self.live && self.last_tick.elapsed() >= Duration::from_secs(2) { + self.last_tick = Instant::now(); + self.backend.refresh()?; + self.clamp_selections(); + } + } + Action::ToggleLive => self.live = !self.live, + Action::TogglePatch => { + if self.focus == Panel::Preview && self.has_patch() { + self.patch_scroll = 0; + self.mode = Mode::Patch; + } + } + Action::FilterBySelectedAuthor => { + let author = self.selected_thread().map(|thread| thread.author.clone()); + if let Some(author) = author { + self.participant_filter = Some(author); + self.clamp_selections(); + } + } + Action::OpenParticipants => { + self.mode = Mode::Participants; + self.participant_query.clear(); + self.participant_selected = 0; + } + Action::ClearParticipantFilter => { + self.participant_filter = None; + self.clamp_selections(); + } + Action::ApplyFilter => { + self.filter_active = true; + self.mode = Mode::Normal; + self.clamp_selections(); + } + Action::CancelFilter => { + self.filter_input.clear(); + self.filter_active = false; + self.mode = Mode::Normal; + self.clamp_selections(); + } + Action::CursorLeft => { + if self.screen == Screen::Sender { + self.compose.cursor_left(); + } + } + Action::CursorRight => { + if self.screen == Screen::Sender { + self.compose.cursor_right(); + } + } + Action::CursorHome => { + if self.screen == Screen::Sender { + self.compose.cursor_home(); + } + } + Action::CursorEnd => { + if self.screen == Screen::Sender { + self.compose.cursor_end(); + } + } + Action::DeleteForward => { + if self.screen == Screen::Sender { + self.compose.delete_forward(); + } + } + Action::CursorUp => { + if self.screen == Screen::Sender { + self.compose.cursor_up(); + } + } + Action::CursorDown => { + if self.screen == Screen::Sender { + self.compose.cursor_down(); + } + } + Action::PickerEnter => self.picker_enter(), + Action::PickerUp => { + if self.picker_active() { + self.picker.move_selection(-1); + } + } + Action::PickerDown => { + if self.picker_active() { + self.picker.move_selection(1); + } + } + Action::PickerParent => { + if self.picker_active() { + self.picker.go_up(); + } + } + Action::PickerToggleHidden => { + if self.picker_active() { + self.picker.toggle_hidden(); + } + } + Action::ScrollUp => { + if self.mode == Mode::Patch { + self.patch_scroll = self.patch_scroll.saturating_sub(1); + } else { + self.preview_scroll = self.preview_scroll.saturating_sub(1); + } + } + Action::ScrollDown => { + if self.mode == Mode::Patch { + self.patch_scroll = self.patch_scroll.saturating_add(1); + } else { + self.preview_scroll = self.preview_scroll.saturating_add(1); + } + } + Action::OpenLists => { + self.list_selected = self.active_list; + self.mode = Mode::Lists; + } + Action::OpenSender => self.open_sender(), + Action::Confirm => match self.mode { + Mode::Lists => self.select_list(self.list_selected), + Mode::Participants => self.confirm_participant(), + _ => {} + }, + Action::NextField => { + if self.screen == Screen::Sender { + self.compose.next_field(); + } + } + Action::PrevField => { + if self.screen == Screen::Sender { + self.compose.prev_field(); + } + } + Action::AddPatch => { + if self.screen == Screen::Sender { + self.compose.add_patch(); + } + } + Action::RemovePatch => { + if self.screen == Screen::Sender { + self.compose.remove_selected_patch(); + } + } + Action::Send => { + if self.screen == Screen::Sender { + self.compose.status = Some("send is a stub — not implemented yet".to_owned()); + } + } + } + Ok(()) + } + + /// Threads of the active list, filtered by the active text and participant filters. + #[must_use] + pub fn threads(&self) -> Vec<&Thread> { + self.backend + .threads() + .iter() + .filter(|thread| self.thread_matches(thread)) + .collect() + } + + /// The selected thread, if any. + #[must_use] + pub fn selected_thread(&self) -> Option<&Thread> { + self.threads().get(self.thread_selected).copied() + } + + /// Messages of the selected thread (empty when nothing is selected), + /// filtered by the participant filter. + #[must_use] + pub fn messages(&self) -> Vec<&Message> { + let Some(thread) = self.selected_thread() else { + return Vec::new(); + }; + self.backend + .messages(thread.id) + .iter() + .filter(|message| self.message_matches(message)) + .collect() + } + + /// The selected message, if any. + #[must_use] + pub fn selected_message(&self) -> Option<&Message> { + self.messages().get(self.message_selected).copied() + } + + /// Aggregated participants across the whole backend, deduplicated by + /// email (case-insensitive), filtered by `participant_query`, and sorted + /// by message count (desc) then name (asc). + #[must_use] + pub fn participants(&self) -> Vec { + let mut map: HashMap)> = HashMap::new(); + for thread in self.backend.threads() { + let thread_id = thread.id.0; + for message in self.backend.messages(thread.id) { + for author in message.participants() { + let key = author.email.to_lowercase(); + map.entry(key) + .and_modify(|(participant, threads)| { + participant.message_count += 1; + if threads.insert(thread_id) { + participant.thread_count += 1; + } + }) + .or_insert_with(|| { + ( + Participant { + author: author.clone(), + thread_count: 1, + message_count: 1, + }, + HashSet::from([thread_id]), + ) + }); + } + } + } + let query = self.participant_query.to_lowercase(); + let mut result: Vec = map + .into_values() + .map(|(participant, _)| participant) + .filter(|participant| { + query.is_empty() + || participant.author.name.to_lowercase().contains(&query) + || participant.author.email.to_lowercase().contains(&query) + }) + .collect(); + result.sort_by(|a, b| { + b.message_count.cmp(&a.message_count).then_with(|| { + a.author + .name + .to_lowercase() + .cmp(&b.author.name.to_lowercase()) + }) + }); + result + } + + /// Whether the selected message carries a patch. + #[must_use] + pub fn has_patch(&self) -> bool { + self.selected_message().is_some_and(Message::has_patch) + } + + /// Whether a thread passes the active text and participant filters. + fn thread_matches(&self, thread: &Thread) -> bool { + if self.filter_active { + let query = self.filter_input.to_lowercase(); + let matches = thread.subject.to_lowercase().contains(&query) + || thread.author.name.to_lowercase().contains(&query) + || thread.author.email.to_lowercase().contains(&query); + if !matches { + return false; + } + } + if let Some(filter) = &self.participant_filter { + let email = filter.email.to_lowercase(); + let has_participant = self.backend.messages(thread.id).iter().any(|message| { + message + .participants() + .iter() + .any(|author| author.email.to_lowercase() == email) + }); + if !has_participant { + return false; + } + } + true + } + + /// Whether a message passes the participant filter. + fn message_matches(&self, message: &Message) -> bool { + self.participant_filter.as_ref().is_none_or(|filter| { + let email = filter.email.to_lowercase(); + message + .participants() + .iter() + .any(|author| author.email.to_lowercase() == email) + }) + } + + /// Whether the file picker currently owns keys (Sender screen, patches field). + fn picker_active(&self) -> bool { + self.screen == Screen::Sender && self.compose.field == ComposeField::Patches + } + + /// Activate the picker's selected entry, attaching any selected file. + fn picker_enter(&mut self) { + if !self.picker_active() { + return; + } + match self.picker.enter() { + PickerOutcome::Attach(path) => { + if let Err(err) = self.compose.attach_file(&path) { + self.compose.status = Some(format!("attach failed: {err}")); + } + } + PickerOutcome::None | PickerOutcome::Descend => {} + } + } + + /// Select the highlighted participant as the participant filter. + fn confirm_participant(&mut self) { + let participants = self.participants(); + if participants.is_empty() { + return; + } + let idx = self + .participant_selected + .min(participants.len().saturating_sub(1)); + let selected = participants + .get(idx) + .map(|participant| participant.author.clone()); + self.participant_filter = selected; + self.mode = Mode::Normal; + self.clamp_selections(); + } + + /// Switch to `mode`, or back to [`Mode::Normal`] when already there. + fn toggle(&mut self, mode: Mode) { + self.mode = if self.mode == mode { + Mode::Normal + } else { + mode + }; + } + + /// Make `index` the active list and reload its snapshot. + fn select_list(&mut self, index: usize) { + let Some(list) = self.lists.get(index).cloned() else { + return; + }; + self.active_list = index; + self.backend = backend::for_list(&list, self.kind); + self.thread_selected = 0; + self.message_selected = 0; + self.participant_filter = None; + self.filter_active = false; + self.filter_input.clear(); + self.compose.to = list.address; + self.mode = Mode::Normal; + self.clamp_selections(); + } + + /// Show the Sender screen, seeding `To:` from the active list if empty. + fn open_sender(&mut self) { + self.screen = Screen::Sender; + self.mode = Mode::Normal; + if self.compose.to.is_empty() { + if let Some(list) = self.lists.get(self.active_list) { + self.compose.to = list.address.clone(); + } + } + } + + /// Leave the current mode, or return to browsing from the Sender screen. + fn back(&mut self) { + if self.mode == Mode::Participants { + self.mode = Mode::Normal; + self.participant_query.clear(); + } else if self.mode != Mode::Normal { + self.mode = Mode::Normal; + } else if self.screen == Screen::Sender { + self.screen = Screen::Browse; + } + } + + /// Route one character to the filter, participant query, or focused compose field. + fn input_char(&mut self, ch: char) { + match self.mode { + Mode::Filter => self.filter_input.push(ch), + Mode::Participants => { + self.participant_query.push(ch); + self.participant_selected = self + .participant_selected + .min(self.participants().len().saturating_sub(1)); + } + Mode::Normal if self.screen == Screen::Sender => self.compose.insert_char(ch), + _ => {} + } + } + + /// Delete the last character of the filter, query, or focused compose field. + fn backspace(&mut self) { + match self.mode { + Mode::Filter => { + self.filter_input.pop(); + } + Mode::Participants => { + self.participant_query.pop(); + self.participant_selected = self + .participant_selected + .min(self.participants().len().saturating_sub(1)); + } + Mode::Normal if self.screen == Screen::Sender => self.compose.backspace(), + _ => {} + } + } + + /// Move the selection of whichever list currently owns the keys. + fn move_selection(&mut self, delta: i8) { + if self.mode == Mode::Lists { + let len = self.lists.len(); + if len > 0 { + self.list_selected = step(self.list_selected, len, delta); + } + } else if self.mode == Mode::Participants { + let len = self.participants().len(); + if len > 0 { + self.participant_selected = step(self.participant_selected, len, delta); + } + } else if self.screen == Screen::Sender && self.compose.field == ComposeField::Patches { + self.compose.move_patch_selection(delta); + } else { + self.move_pane_selection(delta); + } + } + + /// Move the selection in the focused browse pane; wraps at the edges. + fn move_pane_selection(&mut self, delta: i8) { + match self.focus { + Panel::Threads => { + let len = self.threads().len(); + if len == 0 { + return; + } + let next = step(self.thread_selected, len, delta); + if next != self.thread_selected { + self.thread_selected = next; + // Thread changed: preview must start at its first message. + self.message_selected = 0; + } + } + Panel::Messages => { + let len = self.messages().len(); + if len == 0 { + return; + } + self.message_selected = step(self.message_selected, len, delta); + } + Panel::Preview => { + // Future: scroll the preview. + } + } + } + + /// Keep every selection within the bounds of the current (filtered) snapshot. + fn clamp_selections(&mut self) { + let threads = self.threads().len().saturating_sub(1); + self.thread_selected = self.thread_selected.min(threads); + let messages = self.messages().len().saturating_sub(1); + self.message_selected = self.message_selected.min(messages); + let lists = self.lists.len().saturating_sub(1); + self.list_selected = self.list_selected.min(lists); + let participants = self.participants().len().saturating_sub(1); + self.participant_selected = self.participant_selected.min(participants); + let patches = self.compose.patches.len().saturating_sub(1); + self.compose.patch_selected = self.compose.patch_selected.min(patches); + } +} + +/// Index after `delta` steps with wrap-around; `0` for empty lists. +const fn step(index: usize, len: usize, delta: i8) -> usize { + if len == 0 { + return 0; + } + match delta { + d if d < 0 => (index + len - 1) % len, + d if d > 0 => (index + 1) % len, + _ => index, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tick_does_not_refresh_before_interval() { + let mut app = App::new(&Config::default()); + let before: Vec = app + .backend + .threads() + .iter() + .map(|t| t.subject.clone()) + .collect(); + app.handle(Action::Tick).unwrap(); + let after: Vec = app + .backend + .threads() + .iter() + .map(|t| t.subject.clone()) + .collect(); + assert_eq!(before, after); + } + + #[test] + fn tick_refreshes_after_interval() { + let mut app = App::new(&Config::default()); + app.last_tick = Instant::now().checked_sub(Duration::from_secs(3)).unwrap(); + let before: Vec = app + .backend + .threads() + .iter() + .map(|t| t.subject.clone()) + .collect(); + app.handle(Action::Tick).unwrap(); + let after: Vec = app + .backend + .threads() + .iter() + .map(|t| t.subject.clone()) + .collect(); + assert_ne!(before, after); + } + + #[test] + fn messages_filtered_by_participant() { + let mut app = App::new(&Config::default()); + let first_thread_id = app.backend.threads()[0].id; + let first_cc = app.backend.messages(first_thread_id)[0].cc[0].clone(); + app.participant_filter = Some(first_cc.clone()); + let filtered = app.messages(); + assert!(!filtered.is_empty()); + for message in &filtered { + assert!( + message + .participants() + .iter() + .any(|author| author.email == first_cc.email) + ); + } + } + + #[test] + fn participants_are_deduped_and_sorted() { + let app = App::new(&Config::default()); + let participants = app.participants(); + assert!(!participants.is_empty()); + for pair in participants.windows(2) { + let (a, b) = (&pair[0], &pair[1]); + assert!( + a.message_count > b.message_count + || (a.message_count == b.message_count + && a.author.name.to_lowercase() <= b.author.name.to_lowercase()) + ); + } + let mut emails: Vec = participants + .iter() + .map(|p| p.author.email.to_lowercase()) + .collect(); + emails.sort(); + let unique_len = emails.len(); + emails.dedup(); + assert_eq!(emails.len(), unique_len); + } + + #[test] + fn confirm_participant_clamps_out_of_range_selection() { + let mut app = App::new(&Config::default()); + let target = app.participants()[0].author.clone(); + // Narrow the query to a single participant so the visible list has len 1. + app.participant_query = target.email.clone(); + assert_eq!(app.participants().len(), 1); + app.mode = Mode::Participants; + app.participant_selected = usize::MAX; + app.confirm_participant(); + assert_eq!(app.participant_filter, Some(target)); + assert_eq!(app.mode, Mode::Normal); + } + + #[test] + fn open_drills_down_threads_messages_preview_without_wrapping() { + let mut app = App::new(&Config::default()); + app.focus = Panel::Threads; + app.handle(Action::Open).unwrap(); + assert_eq!(app.focus, Panel::Messages); + app.handle(Action::Open).unwrap(); + assert_eq!(app.focus, Panel::Preview); + // Already at the deepest pane: Enter is a no-op rather than wrapping. + app.handle(Action::Open).unwrap(); + assert_eq!(app.focus, Panel::Preview); + } +} diff --git a/src/backend/lore.rs b/src/backend/lore.rs new file mode 100644 index 0000000..ebbbe2e --- /dev/null +++ b/src/backend/lore.rs @@ -0,0 +1,43 @@ +//! Stub backend for the real lore.kernel.org archive. +//! +//! Not implemented: the real backend will fetch the list's public-inbox +//! archive (atom feed or mbox.gz endpoints) or shell out to `b4`/`lei`. +//! See for the archive software. + +use crate::models::{MailingList, Message, Thread, ThreadId}; + +use super::Backend; + +/// Archive-backed data source. Unreachable until networking lands. +#[derive(Clone, Debug)] +pub struct LoreBackend { + /// The list this backend would fetch from. + list: MailingList, +} + +impl LoreBackend { + /// Build a stub backend for `list`. + #[must_use] + pub fn new(list: &MailingList) -> Self { + Self { list: list.clone() } + } +} + +impl Backend for LoreBackend { + fn list_name(&self) -> &str { + &self.list.name + } + + fn threads(&self) -> &[Thread] { + &[] + } + + fn messages(&self, _thread_id: ThreadId) -> &[Message] { + &[] + } + + fn refresh(&mut self) -> anyhow::Result<()> { + // TODO: fetch `self.list.archive_url` (public-inbox atom feed). + todo!("LoreBackend networking is not implemented yet") + } +} diff --git a/src/backend/mock.rs b/src/backend/mock.rs new file mode 100644 index 0000000..869b580 --- /dev/null +++ b/src/backend/mock.rs @@ -0,0 +1,312 @@ +//! In-memory stub data mimicking a lore.kernel.org archive. +//! +//! Nothing here performs I/O. The real backend will fetch from +//! or shell out to `b4`/`lei`. + +use std::collections::HashMap; + +use crate::models::{Author, MailingList, Message, MessageId, Thread, ThreadId}; + +use super::Backend; + +/// Static "mailing list content" the mock rotates through. +struct ThreadSeed { + /// Series prefix, e.g. `PATCH v4`; empty for plain discussions. + prefix: &'static str, + subject: &'static str, + author_name: &'static str, + author_email: &'static str, + /// Total message count, including the cover letter when > 1. + message_count: usize, + /// Short display date for the thread list. + date: &'static str, +} + +const THREAD_SEEDS: [ThreadSeed; 10] = [ + ThreadSeed { + prefix: "PATCH v4", + subject: "refs: introduce a reftable backend for packed refs", + author_name: "Patrick Steinhardt", + author_email: "ps@pks.im", + message_count: 6, + date: "Sep 10", + }, + ThreadSeed { + prefix: "PATCH v2", + subject: "t/perf: add benchmarks for ref operations", + author_name: "Victoria Dye", + author_email: "vdye@github.com", + message_count: 3, + date: "Sep 10", + }, + ThreadSeed { + prefix: "RFC PATCH", + subject: "log --format: add a reftable dump backend", + author_name: "Karthik Nayak", + author_email: "karthik.188@gmail.com", + message_count: 2, + date: "Sep 09", + }, + ThreadSeed { + prefix: "PATCH", + subject: "tests: convert remaining scripts to test-tool helpers", + author_name: "Ævar Arnfjörð Bjarmason", + author_email: "avarab@gmail.com", + message_count: 4, + date: "Sep 09", + }, + ThreadSeed { + prefix: "", + subject: "git maintenance: schedule tasks on macOS via launchd", + author_name: "Derrick Stolee", + author_email: "stolee@gmail.com", + message_count: 1, + date: "Sep 08", + }, + ThreadSeed { + prefix: "PATCH v3", + subject: "bundle-uri: support sparse filters in clones", + author_name: "Junio C Hamano", + author_email: "gitster@pobox.com", + message_count: 5, + date: "Sep 08", + }, + ThreadSeed { + prefix: "PATCH", + subject: "rebase --update-refs: silence reflog message noise", + author_name: "Phillip Wood", + author_email: "phillip.wood123@gmail.com", + message_count: 2, + date: "Sep 07", + }, + ThreadSeed { + prefix: "", + subject: "Git v2.48.0 released", + author_name: "Junio C Hamano", + author_email: "gitster@pobox.com", + message_count: 1, + date: "Sep 07", + }, + ThreadSeed { + prefix: "PATCH v6", + subject: "worktree list: add --porcelain output", + author_name: "Elijah Newren", + author_email: "newren@gmail.com", + message_count: 7, + date: "Sep 06", + }, + ThreadSeed { + prefix: "PATCH", + subject: "submodule: parallelize fetch with --jobs", + author_name: "Taylor Blau", + author_email: "me@ttaylorr.com", + message_count: 3, + date: "Sep 06", + }, +]; + +/// Fixed pool of reviewers assigned as `Cc:` on mock messages. +const REVIEWERS: [(&str, &str); 6] = [ + ("Junio C Hamano", "gitster@pobox.com"), + ("Taylor Blau", "me@ttaylorr.com"), + ("Ævar Arnfjörð Bjarmason", "avarab@gmail.com"), + ("Victoria Dye", "vdye@github.com"), + ("Elijah Newren", "newren@gmail.com"), + ("Phillip Wood", "phillip.wood123@gmail.com"), +]; + +/// In-memory data source. [`Backend::refresh`] rotates the visible window +/// over [`THREAD_SEEDS`] so `r` visibly changes the data. +#[derive(Debug)] +pub struct MockBackend { + list: MailingList, + /// Refresh counter: each refresh rotates the thread window. + generation: usize, + threads: Vec, + messages: HashMap>, +} + +impl MockBackend { + /// Build a backend for `list` with an initial snapshot. + /// + /// The starting generation is derived from the list name so different + /// lists show different (but deterministic) thread windows. + #[must_use] + pub fn new(list: &MailingList) -> Self { + let mut backend = Self { + list: list.clone(), + generation: fnv1a(list.name.as_bytes()) % THREAD_SEEDS.len(), + threads: Vec::new(), + messages: HashMap::new(), + }; + backend.rebuild(); + backend + } + + /// Visible thread count varies with the generation so refreshes differ. + const fn visible_thread_count(generation: usize) -> usize { + THREAD_SEEDS.len().saturating_sub(2) + generation % 3 + } + + /// Regenerate the snapshot from the seed pool, rotated by `generation`. + fn rebuild(&mut self) { + self.threads.clear(); + self.messages.clear(); + let visible = Self::visible_thread_count(self.generation); + let start = self.generation % THREAD_SEEDS.len(); + for offset in 0..visible { + let seed = &THREAD_SEEDS[(start + offset) % THREAD_SEEDS.len()]; + let thread_id = ThreadId(offset); + self.threads.push(Thread { + id: thread_id, + subject: series_subject(seed), + author: seed_author(seed), + date: seed.date.to_owned(), + message_count: seed.message_count, + }); + let list = (0..seed.message_count) + .map(|index| message_from_seed(seed, &self.list, offset, index)) + .collect(); + self.messages.insert(thread_id, list); + } + } +} + +impl Backend for MockBackend { + fn list_name(&self) -> &str { + &self.list.name + } + + fn threads(&self) -> &[Thread] { + &self.threads + } + + fn messages(&self, thread_id: ThreadId) -> &[Message] { + self.messages.get(&thread_id).map_or(&[], Vec::as_slice) + } + + fn refresh(&mut self) -> anyhow::Result<()> { + self.generation += 1; + self.rebuild(); + Ok(()) + } +} + +/// Deterministic FNV-1a hash used to vary mock data per list. +fn fnv1a(bytes: &[u8]) -> usize { + let mut hash: usize = 0x811c_9dc5; + let mut index = 0; + while index < bytes.len() { + hash ^= usize::from(bytes[index]); + hash = hash.wrapping_mul(0x0100_0193); + index += 1; + } + hash +} + +fn seed_author(seed: &ThreadSeed) -> Author { + Author { + name: seed.author_name.to_owned(), + email: seed.author_email.to_owned(), + } +} + +/// Thread subject: `[ 0/] ` for series, plain otherwise. +fn series_subject(seed: &ThreadSeed) -> String { + if seed.prefix.is_empty() || seed.message_count <= 1 { + seed.subject.to_owned() + } else { + format!( + "[{} 0/{}] {}", + seed.prefix, + seed.message_count - 1, + seed.subject + ) + } +} + +fn message_from_seed( + seed: &ThreadSeed, + list: &MailingList, + thread_offset: usize, + index: usize, +) -> Message { + let subject = if seed.prefix.is_empty() || seed.message_count <= 1 { + seed.subject.to_owned() + } else { + format!( + "[{} {}/{}] {}", + seed.prefix, + index, + seed.message_count - 1, + seed.subject + ) + }; + let to = vec![Author { + name: list.name.clone(), + email: list.address.clone(), + }]; + let count = 1 + (thread_offset + index) % 2; + let start = (thread_offset * 7 + index) % REVIEWERS.len(); + let cc = (0..count) + .map(|i| { + let (name, email) = REVIEWERS[(start + i) % REVIEWERS.len()]; + Author { + name: name.to_owned(), + email: email.to_owned(), + } + }) + .collect(); + Message { + id: MessageId(index), + subject, + author: seed_author(seed), + to, + cc, + date: message_date(thread_offset, index), + body: message_body(seed, index), + } +} + +fn message_date(thread_offset: usize, index: usize) -> String { + format!( + "Fri, 11 Sep 2026 {:02}:{:02}:{:02} +0200", + 8 + thread_offset % 9, + index * 7 % 60, + (index * 13 + thread_offset) % 60 + ) +} + +fn message_body(seed: &ThreadSeed, index: usize) -> String { + let total = seed.message_count.saturating_sub(1); + let intro = if seed.message_count <= 1 { + format!( + "This is a standalone message about {}.\n\nDetails and rationale follow; review and\nfeedback are welcome.", + seed.subject + ) + } else if index == 0 { + format!( + "Cover letter for the {} series: {}.\n\nThe full series follows. See the individual\npatches for the actual changes.", + seed.prefix, seed.subject + ) + } else { + format!( + "Patch {index}/{total} of the {} series.\n\nOne focused change:\n- {} (summary stub)\n- tests updated accordingly", + seed.prefix, seed.subject + ) + }; + if index == 0 && seed.message_count > 1 { + // Cover letters carry no diff. + return intro; + } + let diff = format!( + "---\n diff --git a/refs/reftable/backend.c b/refs/reftable/backend.c\n\ + index abcdef0..fedcba1 100644\n\ + --- a/refs/reftable/backend.c\n\ + +++ b/refs/reftable/backend.c\n\ + @@ -1{index},3 +1{index},3 @@\n\ + -// old behavior (removed)\n\ + +// new behavior (lazymail placeholder diff)" + ); + format!("{intro}\n\n{diff}") +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..24e7194 --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,61 @@ +//! Data sources for the mailing list. +//! +//! [`Backend`] is the seam every real data source plugs into (`b4`/`lei` +//! subprocesses, the lore HTTP API). The UI only talks to this trait. + +pub mod lore; +pub mod mock; + +pub use lore::LoreBackend; +pub use mock::MockBackend; + +use crate::config::{BackendKind, Config}; +use crate::models::{MailingList, Message, Thread, ThreadId}; + +/// Read model of one mailing list. +pub trait Backend { + /// Human-readable list name shown in the header, e.g. `git.vger.kernel.org`. + fn list_name(&self) -> &str; + + /// All threads of the current snapshot, in display order. + fn threads(&self) -> &[Thread]; + + /// The messages of one thread, in order (cover letter first when present). + fn messages(&self, thread_id: ThreadId) -> &[Message]; + + /// Re-fetch the snapshot. + /// + /// `MockBackend` rotates its data; real backends will hit the archive. + fn refresh(&mut self) -> anyhow::Result<()>; +} + +/// The list used when none are configured, so callers never index blindly. +#[must_use] +pub fn default_list() -> MailingList { + MailingList { + name: "git.vger.kernel.org".to_owned(), + address: "git@vger.kernel.org".to_owned(), + archive_url: "https://lore.kernel.org/git/".to_owned(), + } +} + +/// Build a backend for one mailing list. +#[must_use] +pub fn for_list(list: &MailingList, kind: BackendKind) -> Box { + match kind { + BackendKind::Mock => Box::new(MockBackend::new(list)), + BackendKind::Lore => Box::new(LoreBackend::new(list)), + } +} + +/// Build the backend selected by `config`. +#[must_use] +pub fn from_config(config: &Config) -> Box { + config.current_list().map_or_else( + || { + let fallback = default_list(); + for_list(&fallback, config.kind) + }, + |list| for_list(list, config.kind), + ) +} diff --git a/src/compose.rs b/src/compose.rs new file mode 100644 index 0000000..0ce2468 --- /dev/null +++ b/src/compose.rs @@ -0,0 +1,513 @@ +//! Sender screen state: the compose form and its patch attachments. + +use std::path::{Path, PathBuf}; + +use crate::models::MailingList; + +/// Fields of the compose form, in Tab order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComposeField { + /// Recipients (`To:`). + To, + /// Carbon-copy recipients (`Cc:`). + Cc, + /// Mail subject. + Subject, + /// Cover-letter / message body. + Body, + /// Attached patch files. + Patches, +} + +impl ComposeField { + /// The field after `self` in Tab order, wrapping. + #[must_use] + pub const fn next(self) -> Self { + match self { + Self::To => Self::Cc, + Self::Cc => Self::Subject, + Self::Subject => Self::Body, + Self::Body => Self::Patches, + Self::Patches => Self::To, + } + } + + /// The field before `self` in Tab order, wrapping. + #[must_use] + pub const fn prev(self) -> Self { + match self { + Self::To => Self::Patches, + Self::Cc => Self::To, + Self::Subject => Self::Cc, + Self::Body => Self::Subject, + Self::Patches => Self::Body, + } + } + + /// Short label rendered before the field's content. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::To => "To:", + Self::Cc => "Cc:", + Self::Subject => "Subject:", + Self::Body => "Body:", + Self::Patches => "Patches:", + } + } +} + +/// One attached patch file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PatchAttachment { + /// File name, as `git format-patch` would emit it. + pub filename: String, + /// Patch subject. + pub subject: String, + /// Source path on disk (`None` for stub patches). + pub path: Option, + /// Patch content (capped at 1 MiB). + pub content: String, + /// Content length in bytes. + pub size: u64, +} + +/// Sender screen state. +#[derive(Clone, Debug)] +pub struct Compose { + /// Recipients, seeded from the active list address. + pub to: String, + /// Cc list (comma separated), a stub. + pub cc: String, + /// Mail subject. + pub subject: String, + /// Cover-letter body, a stub. + pub body: String, + /// Attached patch files. + pub patches: Vec, + /// Focused form field. + pub field: ComposeField, + /// Selected patch index (meaningful when `field == Patches`). + pub patch_selected: usize, + /// Cursor position (character index) into the focused text field. + pub cursor: usize, + /// Send status line, shown at the bottom of the form. + pub status: Option, +} + +impl Compose { + /// Seed a compose form for `list` with stub content. + #[must_use] + pub fn new(list: &MailingList) -> Self { + Self { + to: list.address.clone(), + cc: String::new(), + subject: "[PATCH 0/2] ".to_owned(), + body: "Cover letter stub.\n\nPatch 1 makes one focused change;\npatch 2 updates the tests.\nNothing here is sent yet." + .to_owned(), + patches: vec![ + PatchAttachment { + filename: "0001-stub-change.patch".to_owned(), + subject: "stub: first change".to_owned(), + path: None, + content: String::new(), + size: 0, + }, + PatchAttachment { + filename: "0002-stub-tests.patch".to_owned(), + subject: "stub: cover the change with tests".to_owned(), + path: None, + content: String::new(), + size: 0, + }, + ], + field: ComposeField::To, + patch_selected: 0, + cursor: 0, + status: None, + } + } + + /// Focus the next form field, wrapping. + pub fn next_field(&mut self) { + self.field = self.field.next(); + let len = self.current_text().map_or(0, |text| text.chars().count()); + self.cursor = len; + } + + /// Focus the previous form field, wrapping. + pub fn prev_field(&mut self) { + self.field = self.field.prev(); + let len = self.current_text().map_or(0, |text| text.chars().count()); + self.cursor = len; + } + + /// Move the patch selection by `delta`, wrapping. + pub fn move_patch_selection(&mut self, delta: i8) { + let len = self.patches.len(); + if len == 0 { + self.patch_selected = 0; + return; + } + let index = self.patch_selected.min(len - 1); + self.patch_selected = if delta < 0 { + (index + len - 1) % len + } else { + (index + 1) % len + }; + } + + /// Attach a new stub patch and select it. + pub fn add_patch(&mut self) { + let number = self.next_patch_number(); + self.patches.push(PatchAttachment { + filename: format!("{number:04}-stub-patch.patch"), + subject: format!("stub patch {number}"), + path: None, + content: String::new(), + size: 0, + }); + self.patch_selected = self.patches.len() - 1; + } + + /// Remove the selected patch, keeping the selection in range. + pub fn remove_selected_patch(&mut self) { + if self.patches.is_empty() { + return; + } + let index = self.patch_selected.min(self.patches.len() - 1); + self.patches.remove(index); + self.patch_selected = index.min(self.patches.len().saturating_sub(1)); + } + + /// The focused text field, or `None` when the patch list is focused. + #[must_use] + pub fn current_text(&self) -> Option<&str> { + match self.field { + ComposeField::To => Some(&self.to), + ComposeField::Cc => Some(&self.cc), + ComposeField::Subject => Some(&self.subject), + ComposeField::Body => Some(&self.body), + ComposeField::Patches => None, + } + } + + /// The focused text field for mutation, or `None` for the patch list. + #[must_use] + pub const fn current_text_mut(&mut self) -> Option<&mut String> { + match self.field { + ComposeField::To => Some(&mut self.to), + ComposeField::Cc => Some(&mut self.cc), + ComposeField::Subject => Some(&mut self.subject), + ComposeField::Body => Some(&mut self.body), + ComposeField::Patches => None, + } + } + + fn focused_text(&self) -> Option<&str> { + self.current_text() + } + + const fn focused_text_mut(&mut self) -> Option<&mut String> { + self.current_text_mut() + } + + /// Insert `ch` at the cursor and advance it. + pub fn insert_char(&mut self, ch: char) { + let cursor = self.cursor; + let Some(text) = self.focused_text_mut() else { + return; + }; + let byte = char_to_byte(text, cursor); + text.insert(byte, ch); + self.cursor = cursor + 1; + } + + /// Delete the character before the cursor. + pub fn backspace(&mut self) { + let cursor = self.cursor; + if cursor == 0 { + return; + } + let Some(text) = self.focused_text_mut() else { + return; + }; + let prev = char_to_byte(text, cursor - 1); + let curr = char_to_byte(text, cursor); + text.replace_range(prev..curr, ""); + self.cursor = cursor - 1; + } + + /// Delete the character at the cursor. + pub fn delete_forward(&mut self) { + let cursor = self.cursor; + let Some(text) = self.focused_text_mut() else { + return; + }; + let byte = char_to_byte(text, cursor); + let next = char_to_byte(text, cursor + 1); + text.replace_range(byte..next, ""); + } + + /// Move the cursor one character left. + pub const fn cursor_left(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + /// Move the cursor one character right. + pub fn cursor_right(&mut self) { + let max = self.focused_text().map_or(0, |text| text.chars().count()); + self.cursor = (self.cursor + 1).min(max); + } + + /// Move the cursor to the start of the field. + pub const fn cursor_home(&mut self) { + self.cursor = 0; + } + + /// Move the cursor to the end of the field. + pub fn cursor_end(&mut self) { + let len = self.focused_text().map_or(0, |text| text.chars().count()); + self.cursor = len; + } + + /// Move the cursor up one line in the body, preserving the column. + pub fn cursor_up(&mut self) { + if self.field != ComposeField::Body { + self.cursor_home(); + return; + } + let Some(text) = self.focused_text() else { + return; + }; + let cursor = self.cursor; + let next = move_cursor_vertical(text, cursor, true); + self.cursor = next; + } + + /// Move the cursor down one line in the body, preserving the column. + pub fn cursor_down(&mut self) { + if self.field != ComposeField::Body { + self.cursor_end(); + return; + } + let Some(text) = self.focused_text() else { + return; + }; + let cursor = self.cursor; + let next = move_cursor_vertical(text, cursor, false); + self.cursor = next; + } + + /// Attach a patch file, reading its content and deriving subject. + /// + /// # Errors + /// + /// Propagates I/O errors from reading `path`. + pub fn attach_file(&mut self, path: &Path) -> anyhow::Result<()> { + let mut content = std::fs::read_to_string(path)?; + if content.len() > MIB { + let mut end = MIB; + while !content.is_char_boundary(end) { + end -= 1; + } + content.truncate(end); + } + let subject = content + .lines() + .find_map(|line| { + let line = line.trim(); + line.strip_prefix("Subject:") + .map(|rest| rest.trim().to_owned()) + .filter(|rest| !rest.is_empty()) + }) + .unwrap_or_else(|| { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("patch") + .to_owned() + }); + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("patch") + .to_owned(); + let size = content.len() as u64; + self.patches.push(PatchAttachment { + filename, + subject, + path: Some(path.to_path_buf()), + content, + size, + }); + self.patch_selected = self.patches.len() - 1; + let name = self + .patches + .last() + .map_or("patch", |patch| patch.filename.as_str()); + self.status = Some(format!("attached {name}")); + Ok(()) + } + + /// Highest numeric filename prefix plus one, so new names stay unique. + fn next_patch_number(&self) -> usize { + self.patches + .iter() + .filter_map(|patch| patch.filename.split('-').next()) + .filter_map(|prefix| prefix.parse::().ok()) + .max() + .map_or(1, |max| max + 1) + } +} + +/// Byte index of the `char_index`-th character, or the string length when out of range. +fn char_to_byte(s: &str, char_index: usize) -> usize { + s.char_indices() + .nth(char_index) + .map_or(s.len(), |(index, _)| index) +} + +/// Maximum bytes of patch content retained when attaching a file. +const MIB: usize = 1 << 20; + +/// Character ranges `(start, end)` of every line in `text`. +fn lines(text: &str) -> Vec<(usize, usize)> { + let mut result = Vec::new(); + let mut start = 0; + for (byte, ch) in text.char_indices() { + if ch == '\n' { + let end = text[..byte].chars().count(); + result.push((start, end)); + start = end + 1; + } + } + result.push((start, text.chars().count())); + result +} + +/// New cursor after moving `up`/down one line, preserving the column. +fn move_cursor_vertical(text: &str, cursor: usize, up: bool) -> usize { + let lines = lines(text); + let Some(current) = lines + .iter() + .position(|&(start, end)| cursor >= start && cursor <= end) + else { + return cursor; + }; + let column = cursor - lines[current].0; + let target = if up { + if current == 0 { + return cursor; + } + current - 1 + } else { + if current + 1 >= lines.len() { + return cursor; + } + current + 1 + }; + let (start, end) = lines[target]; + start + column.min(end - start) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn compose() -> Compose { + Compose::new(&crate::backend::default_list()) + } + + fn unique_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = + std::env::temp_dir().join(format!("lazymail-compose-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn insert_and_cursor_on_subject() { + let mut compose = compose(); + compose.field = ComposeField::Subject; + compose.subject.clear(); + compose.cursor = 0; + compose.insert_char('a'); + compose.insert_char('b'); + assert_eq!(compose.subject, "ab"); + assert_eq!(compose.cursor, 2); + assert_eq!(compose.current_text(), Some("ab")); + } + + #[test] + fn backspace_and_delete_forward() { + let mut compose = compose(); + compose.field = ComposeField::Subject; + compose.subject = "abc".to_owned(); + compose.cursor = 3; + compose.backspace(); + assert_eq!(compose.subject, "ab"); + assert_eq!(compose.cursor, 2); + compose.cursor = 0; + compose.delete_forward(); + assert_eq!(compose.subject, "b"); + assert_eq!(compose.cursor, 0); + } + + #[test] + fn cursor_moves_clamp() { + let mut compose = compose(); + compose.field = ComposeField::Subject; + compose.subject = "ab".to_owned(); + compose.cursor = 0; + compose.cursor_left(); + assert_eq!(compose.cursor, 0); + compose.cursor_end(); + assert_eq!(compose.cursor, 2); + compose.cursor_right(); + assert_eq!(compose.cursor, 2); + compose.cursor_home(); + assert_eq!(compose.cursor, 0); + } + + #[test] + fn attach_file_reads_subject_and_size() { + let dir = unique_dir(); + let path = dir.join("0001-fix.patch"); + std::fs::write(&path, "Subject: fix the thing\n\nbody").unwrap(); + let mut compose = compose(); + compose.attach_file(&path).unwrap(); + assert_eq!(compose.patches.len(), 3); + let attached = compose.patches.last().unwrap(); + assert_eq!(attached.filename, "0001-fix.patch"); + assert_eq!(attached.subject, "fix the thing"); + assert_eq!( + usize::try_from(attached.size).unwrap(), + "Subject: fix the thing\n\nbody".len() + ); + assert_eq!(attached.path, Some(path.clone())); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn body_cursor_up_down_preserves_column() { + let mut compose = compose(); + compose.field = ComposeField::Body; + compose.body = "abc\ndefgh\nij".to_owned(); + compose.cursor = 11; + compose.cursor_up(); + assert_eq!(compose.cursor, 5); + compose.cursor_up(); + assert_eq!(compose.cursor, 1); + compose.cursor_up(); + assert_eq!(compose.cursor, 1); + compose.cursor_down(); + assert_eq!(compose.cursor, 5); + compose.cursor_down(); + assert_eq!(compose.cursor, 11); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..66ed73b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,62 @@ +//! Configuration stub: defaults only, no file parsing yet. + +use crate::models::MailingList; + +/// Which data source `lazymail` reads from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BackendKind { + /// In-memory mock data (the only working source today). + #[default] + Mock, + /// The real lore.kernel.org archive (backend still a stub). + // Allow: only reachable once CLI/config parsing lands (stub scaffold). + #[allow(dead_code)] + Lore, +} + +/// Application configuration. +/// +/// Stub: hardcoded defaults until real config parsing lands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Config { + /// Mailing lists the user can switch between. + pub lists: Vec, + /// Index into `lists` of the list shown at startup. + pub active_list: usize, + /// Data source selector. + pub kind: BackendKind, +} + +impl Config { + /// The list at `active_list`, or `None` when none are configured. + #[must_use] + pub fn current_list(&self) -> Option<&MailingList> { + self.lists.get(self.active_list) + } +} + +impl Default for Config { + fn default() -> Self { + Self { + lists: vec![ + MailingList { + name: "git.vger.kernel.org".to_owned(), + address: "git@vger.kernel.org".to_owned(), + archive_url: "https://lore.kernel.org/git/".to_owned(), + }, + MailingList { + name: "linux-kernel.vger.kernel.org".to_owned(), + address: "linux-kernel@vger.kernel.org".to_owned(), + archive_url: "https://lore.kernel.org/lkml/".to_owned(), + }, + MailingList { + name: "linux-arm-kernel.vger.kernel.org".to_owned(), + address: "linux-arm-kernel@lists.infradead.org".to_owned(), + archive_url: "https://lore.kernel.org/linux-arm-kernel/".to_owned(), + }, + ], + active_list: 0, + kind: BackendKind::Mock, + } + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..a473027 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,399 @@ +//! Key events → [`Action`] translation. +//! +//! Pure mapping: the active screen, mode and compose field influence the +//! result, so the bindings stay testable without a terminal. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::action::Action; +use crate::app::{Mode, Panel, Screen}; +use crate::compose::ComposeField; + +/// The context a key press is interpreted in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeyContext { + /// Active top-level screen. + pub screen: Screen, + /// Key interpretation mode. + pub mode: Mode, + /// Focused compose field (Sender screen only). + pub field: ComposeField, + /// Focused pane (browse screen only). + pub focus: Panel, +} + +/// Translate one key press into an action. +// Allow: `&KeyContext` is the contract-specified signature, even though the +// struct is small enough to copy. +#[allow(clippy::trivially_copy_pass_by_ref)] +#[must_use] +pub const fn map_key(key: &KeyEvent, ctx: &KeyContext) -> Option { + match ctx.mode { + Mode::Normal => match ctx.screen { + Screen::Browse => map_browse_key(key), + Screen::Sender => map_sender_key(key, ctx.field), + }, + Mode::Filter => map_filter_key(key), + Mode::Help => Some(map_help_key(key)), + Mode::Lists => map_lists_key(key), + Mode::Participants => map_participants_key(key), + Mode::Patch => map_patch_key(key), + } +} + +const fn map_browse_key(key: &KeyEvent) -> Option { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c' | 'q') => Some(Action::Quit), + _ => None, + }; + } + if key.modifiers.contains(KeyModifiers::ALT) { + return None; + } + match key.code { + KeyCode::Char('q') => Some(Action::Quit), + KeyCode::Char('1') => Some(Action::Focus(Panel::Threads)), + KeyCode::Char('2') => Some(Action::Focus(Panel::Messages)), + KeyCode::Char('3') => Some(Action::Focus(Panel::Preview)), + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => Some(Action::CycleFocus), + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => Some(Action::CycleFocusBack), + KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown), + KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp), + KeyCode::PageDown => Some(Action::ScrollDown), + KeyCode::PageUp => Some(Action::ScrollUp), + KeyCode::Enter => Some(Action::Open), + KeyCode::Char('/') => Some(Action::ToggleFilter), + KeyCode::Char('?') => Some(Action::ToggleHelp), + KeyCode::Char('r') => Some(Action::Refresh), + KeyCode::Char('L') => Some(Action::OpenLists), + KeyCode::Char('s') => Some(Action::OpenSender), + KeyCode::Char('t') => Some(Action::ToggleLive), + KeyCode::Char('f') => Some(Action::OpenParticipants), + KeyCode::Char('F') => Some(Action::FilterBySelectedAuthor), + KeyCode::Char('c') => Some(Action::ClearParticipantFilter), + KeyCode::Char('p') => Some(Action::TogglePatch), + KeyCode::Esc => Some(Action::Back), + _ => None, + } +} + +const fn map_sender_key(key: &KeyEvent, field: ComposeField) -> Option { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c' | 'q') => Some(Action::Quit), + KeyCode::Char('s') => Some(Action::Send), + _ => None, + }; + } + if key.modifiers.contains(KeyModifiers::ALT) { + return None; + } + let patches = matches!(field, ComposeField::Patches); + match key.code { + KeyCode::Tab => Some(Action::NextField), + KeyCode::BackTab => Some(Action::PrevField), + KeyCode::Esc => Some(Action::Back), + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right if patches => { + Some(Action::PickerEnter) + } + KeyCode::Char('.') if patches => Some(Action::PickerToggleHidden), + KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') if patches => { + Some(Action::PickerParent) + } + KeyCode::Down | KeyCode::Char('j') if patches => Some(Action::PickerDown), + KeyCode::Up | KeyCode::Char('k') if patches => Some(Action::PickerUp), + KeyCode::Char('a') if patches => Some(Action::AddPatch), + KeyCode::Char('d') if patches => Some(Action::RemovePatch), + KeyCode::Char('J') if patches => Some(Action::MoveDown), + KeyCode::Char('K') if patches => Some(Action::MoveUp), + KeyCode::Left => Some(Action::CursorLeft), + KeyCode::Right => Some(Action::CursorRight), + KeyCode::Home => Some(Action::CursorHome), + KeyCode::End => Some(Action::CursorEnd), + KeyCode::Up => Some(Action::CursorUp), + KeyCode::Down => Some(Action::CursorDown), + KeyCode::Delete => Some(Action::DeleteForward), + KeyCode::Backspace => Some(Action::Backspace), + KeyCode::Char(ch) => Some(Action::Input(ch)), + _ => None, + } +} + +const fn map_lists_key(key: &KeyEvent) -> Option { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c' | 'q') => Some(Action::Quit), + _ => None, + }; + } + match key.code { + KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown), + KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp), + KeyCode::Enter => Some(Action::Confirm), + KeyCode::Esc | KeyCode::Char('q') => Some(Action::Back), + _ => None, + } +} + +const fn map_filter_key(key: &KeyEvent) -> Option { + match key.code { + KeyCode::Char('c' | 'q') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Action::Quit) + } + KeyCode::Char(ch) => Some(Action::Input(ch)), + KeyCode::Backspace => Some(Action::Backspace), + KeyCode::Enter => Some(Action::ApplyFilter), + KeyCode::Esc => Some(Action::CancelFilter), + _ => None, + } +} + +const fn map_participants_key(key: &KeyEvent) -> Option { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c' | 'q') => Some(Action::Quit), + _ => None, + }; + } + match key.code { + KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown), + KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp), + KeyCode::Enter => Some(Action::Confirm), + KeyCode::Esc => Some(Action::Back), + KeyCode::Char(ch) => Some(Action::Input(ch)), + _ => None, + } +} + +const fn map_patch_key(key: &KeyEvent) -> Option { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c' | 'q') => Some(Action::Quit), + _ => None, + }; + } + match key.code { + KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollDown), + KeyCode::Up | KeyCode::Char('k') => Some(Action::ScrollUp), + KeyCode::Esc | KeyCode::Char('p') | KeyCode::Enter => Some(Action::Back), + _ => None, + } +} + +const fn map_help_key(key: &KeyEvent) -> Action { + match key.code { + KeyCode::Char('c' | 'q') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Quit, + _ => Action::Back, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BROWSE: KeyContext = KeyContext { + screen: Screen::Browse, + mode: Mode::Normal, + field: ComposeField::To, + focus: Panel::Threads, + }; + + const fn sender(field: ComposeField) -> KeyContext { + KeyContext { + screen: Screen::Sender, + mode: Mode::Normal, + field, + focus: Panel::Threads, + } + } + + const fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn browse_live_participants_patch() { + assert_eq!( + map_key(&key(KeyCode::Char('t')), &BROWSE), + Some(Action::ToggleLive) + ); + assert_eq!( + map_key(&key(KeyCode::Char('f')), &BROWSE), + Some(Action::OpenParticipants) + ); + assert_eq!( + map_key(&key(KeyCode::Char('p')), &BROWSE), + Some(Action::TogglePatch) + ); + assert_eq!( + map_key(&key(KeyCode::Char('F')), &BROWSE), + Some(Action::FilterBySelectedAuthor) + ); + assert_eq!( + map_key(&key(KeyCode::Char('c')), &BROWSE), + Some(Action::ClearParticipantFilter) + ); + } + + #[test] + fn sender_cursor_keys_route_to_cursor_actions() { + let ctx = sender(ComposeField::Subject); + assert_eq!(map_key(&key(KeyCode::Left), &ctx), Some(Action::CursorLeft)); + assert_eq!( + map_key(&key(KeyCode::Right), &ctx), + Some(Action::CursorRight) + ); + assert_eq!(map_key(&key(KeyCode::Home), &ctx), Some(Action::CursorHome)); + assert_eq!(map_key(&key(KeyCode::End), &ctx), Some(Action::CursorEnd)); + assert_eq!(map_key(&key(KeyCode::Up), &ctx), Some(Action::CursorUp)); + assert_eq!(map_key(&key(KeyCode::Down), &ctx), Some(Action::CursorDown)); + assert_eq!( + map_key(&key(KeyCode::Delete), &ctx), + Some(Action::DeleteForward) + ); + } + + #[test] + fn sender_patches_field_routes_to_picker() { + let ctx = sender(ComposeField::Patches); + assert_eq!( + map_key(&key(KeyCode::Char('h')), &ctx), + Some(Action::PickerParent) + ); + assert_eq!( + map_key(&key(KeyCode::Left), &ctx), + Some(Action::PickerParent) + ); + assert_eq!( + map_key(&key(KeyCode::Backspace), &ctx), + Some(Action::PickerParent) + ); + assert_eq!( + map_key(&key(KeyCode::Char('j')), &ctx), + Some(Action::PickerDown) + ); + assert_eq!( + map_key(&key(KeyCode::Char('k')), &ctx), + Some(Action::PickerUp) + ); + assert_eq!( + map_key(&key(KeyCode::Char('l')), &ctx), + Some(Action::PickerEnter) + ); + assert_eq!( + map_key(&key(KeyCode::Enter), &ctx), + Some(Action::PickerEnter) + ); + assert_eq!( + map_key(&key(KeyCode::Char('.')), &ctx), + Some(Action::PickerToggleHidden) + ); + } + + #[test] + fn sender_patches_uppercase_jk_move_patch_selection() { + let ctx = sender(ComposeField::Patches); + assert_eq!( + map_key(&key(KeyCode::Char('J')), &ctx), + Some(Action::MoveDown) + ); + assert_eq!( + map_key(&key(KeyCode::Char('K')), &ctx), + Some(Action::MoveUp) + ); + // Outside the patches field, uppercase J/K are plain text input. + let subject = sender(ComposeField::Subject); + assert_eq!( + map_key(&key(KeyCode::Char('J')), &subject), + Some(Action::Input('J')) + ); + assert_eq!( + map_key(&key(KeyCode::Char('K')), &subject), + Some(Action::Input('K')) + ); + } + + #[test] + fn browse_page_keys_scroll_the_preview() { + assert_eq!( + map_key(&key(KeyCode::PageUp), &BROWSE), + Some(Action::ScrollUp) + ); + assert_eq!( + map_key(&key(KeyCode::PageDown), &BROWSE), + Some(Action::ScrollDown) + ); + } + + #[test] + fn filter_mode_apply_and_cancel() { + let ctx = KeyContext { + screen: Screen::Browse, + mode: Mode::Filter, + field: ComposeField::To, + focus: Panel::Threads, + }; + assert_eq!( + map_key(&key(KeyCode::Enter), &ctx), + Some(Action::ApplyFilter) + ); + assert_eq!( + map_key(&key(KeyCode::Esc), &ctx), + Some(Action::CancelFilter) + ); + assert_eq!( + map_key(&key(KeyCode::Char('x')), &ctx), + Some(Action::Input('x')) + ); + assert_eq!( + map_key(&key(KeyCode::Backspace), &ctx), + Some(Action::Backspace) + ); + } + + #[test] + fn participants_mode_bindings() { + let ctx = KeyContext { + screen: Screen::Browse, + mode: Mode::Participants, + field: ComposeField::To, + focus: Panel::Threads, + }; + assert_eq!( + map_key(&key(KeyCode::Char('j')), &ctx), + Some(Action::MoveDown) + ); + assert_eq!(map_key(&key(KeyCode::Down), &ctx), Some(Action::MoveDown)); + assert_eq!( + map_key(&key(KeyCode::Char('k')), &ctx), + Some(Action::MoveUp) + ); + assert_eq!(map_key(&key(KeyCode::Enter), &ctx), Some(Action::Confirm)); + assert_eq!(map_key(&key(KeyCode::Esc), &ctx), Some(Action::Back)); + assert_eq!( + map_key(&key(KeyCode::Char('x')), &ctx), + Some(Action::Input('x')) + ); + } + + #[test] + fn patch_mode_scroll_and_back() { + let ctx = KeyContext { + screen: Screen::Browse, + mode: Mode::Patch, + field: ComposeField::To, + focus: Panel::Threads, + }; + assert_eq!( + map_key(&key(KeyCode::Char('j')), &ctx), + Some(Action::ScrollDown) + ); + assert_eq!( + map_key(&key(KeyCode::Char('k')), &ctx), + Some(Action::ScrollUp) + ); + assert_eq!(map_key(&key(KeyCode::Esc), &ctx), Some(Action::Back)); + assert_eq!(map_key(&key(KeyCode::Char('p')), &ctx), Some(Action::Back)); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..95243f5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,78 @@ +//! `lazymail` — a Lazygit-style TUI for browsing the git mailing list. +//! +//! Entry point: terminal setup/teardown, panic hook, event loop. + +mod action; +mod app; +mod backend; +mod compose; +mod config; +mod event; +mod models; +pub mod picker; +mod theme; +mod ui; + +use anyhow::Context; +use crossterm::event::{self as crossterm_event, Event, KeyEventKind}; +use ratatui::{DefaultTerminal, Frame}; +use std::time::Duration; + +use crate::action::Action; +use crate::app::App; +use crate::config::Config; +use crate::event::KeyContext; + +fn main() -> anyhow::Result<()> { + install_panic_hook(); + let app = App::new(&Config::default()); + let mut terminal = ratatui::try_init().context("failed to enter the alternate screen")?; + let result = run(&mut terminal, app); + ratatui::restore(); + result +} + +/// Restore the terminal before the panic message prints, so a panic can +/// never leave the user with a broken tty. +fn install_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + ratatui::restore(); + default_hook(info); + })); +} + +/// Event loop: draw, read one key, apply its action, repeat until quit. +fn run(terminal: &mut DefaultTerminal, mut app: App) -> anyhow::Result<()> { + loop { + terminal + .draw(|frame| render(frame, &app)) + .context("failed to draw a frame")?; + let ctx = KeyContext { + screen: app.screen, + mode: app.mode, + field: app.compose.field, + focus: app.focus, + }; + if crossterm_event::poll(Duration::from_millis(250))? { + let event = crossterm_event::read().context("failed to read input")?; + if let Event::Key(key) = event { + if key.kind == KeyEventKind::Press { + if let Some(action) = crate::event::map_key(&key, &ctx) { + app.handle(action)?; + } + } + } + } else { + app.handle(Action::Tick)?; + } + if app.quit { + break; + } + } + Ok(()) +} + +fn render(frame: &mut Frame, app: &App) { + ui::render(frame, app); +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..b85f2eb --- /dev/null +++ b/src/models.rs @@ -0,0 +1,236 @@ +//! Domain models: threads, messages, authors, mailing lists. + +use std::fmt; + +/// Stable identifier of a thread within one backend snapshot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ThreadId(pub usize); + +/// Stable identifier of a message within one thread. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MessageId(pub usize); + +/// One mailing list the app can browse or send to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MailingList { + /// Short display name shown in the header and the list picker. + pub name: String, + /// Posting address used as the `To:` field when composing. + pub address: String, + /// Public-inbox archive URL the lore backend will fetch from. + pub archive_url: String, +} + +/// Sender of a thread or message. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Author { + /// Display name. + pub name: String, + /// Mail address. + pub email: String, +} + +impl fmt::Display for Author { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} <{}>", self.name, self.email) + } +} + +/// One discussion on the list (a patch series counts as one thread). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Thread { + /// Snapshot-local id. + pub id: ThreadId, + /// Subject of the cover letter / first message. + pub subject: String, + /// Author of the thread's first message. + pub author: Author, + /// Short display date, e.g. `Sep 10`. + pub date: String, + /// Number of messages, including the cover letter when present. + pub message_count: usize, +} + +/// One mail (patch or plain message) inside a thread. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Message { + /// Snapshot-local id. + pub id: MessageId, + /// Mail subject, e.g. `[PATCH v4 2/6] ...`. + pub subject: String, + /// Sender. + pub author: Author, + /// `To:` recipients. + pub to: Vec, + /// `Cc:` recipients. + pub cc: Vec, + /// Full `Date:` header value. + pub date: String, + /// Body plus trailing diff placeholder, rendered in the preview pane. + pub body: String, +} + +/// Marker that begins the diff portion of a patch body. +pub const PATCH_MARKER: &str = "diff --git "; + +impl Message { + /// The diff portion of `body`, starting at the `---` line when one + /// immediately precedes the marker, otherwise at the marker's line. + /// + /// Returns `None` when the body contains no patch. + #[must_use] + pub fn patch(&self) -> Option<&str> { + let pos = self.body.find(PATCH_MARKER)?; + let line_start = self.body[..pos].rfind('\n').map_or(0, |i| i + 1); + if line_start > 0 { + let prev_line_start = self.body[..line_start - 1].rfind('\n').map_or(0, |i| i + 1); + if self.body[prev_line_start..line_start - 1].trim() == "---" { + return Some(&self.body[prev_line_start..]); + } + } + Some(&self.body[line_start..]) + } + + /// The body with the trailing patch removed, trimmed of trailing whitespace. + #[must_use] + pub fn body_without_patch(&self) -> &str { + self.patch().map_or_else( + || self.body.trim_end(), + |patch| { + let start = self.body.len() - patch.len(); + self.body[..start].trim_end() + }, + ) + } + + /// Whether `body` carries a diff. + #[must_use] + pub fn has_patch(&self) -> bool { + self.patch().is_some() + } + + /// All participants (author, then `to`, then `cc`), deduplicated by exact + /// email address. + #[must_use] + pub fn participants(&self) -> Vec<&Author> { + let mut seen: Vec<&str> = Vec::new(); + let mut out: Vec<&Author> = Vec::new(); + push_unique(&self.author, &mut seen, &mut out); + for author in &self.to { + push_unique(author, &mut seen, &mut out); + } + for author in &self.cc { + push_unique(author, &mut seen, &mut out); + } + out + } +} + +/// Push `author` into `out` unless its email has already been seen. +fn push_unique<'a>(author: &'a Author, seen: &mut Vec<&'a str>, out: &mut Vec<&'a Author>) { + if !seen.contains(&author.email.as_str()) { + seen.push(author.email.as_str()); + out.push(author); + } +} + +/// One participant aggregated across all threads for the participants overlay. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Participant { + /// The participant. + pub author: Author, + /// Number of distinct threads they appear in. + pub thread_count: usize, + /// Total number of messages they appear on. + pub message_count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn author(name: &str, email: &str) -> Author { + Author { + name: name.to_owned(), + email: email.to_owned(), + } + } + + #[test] + fn patch_splits_diff_from_intro() { + let message = Message { + id: MessageId(0), + subject: "x".to_owned(), + author: author("A", "a@example.com"), + to: Vec::new(), + cc: Vec::new(), + date: String::new(), + body: "intro\n\n---\ndiff --git a/foo b/foo\n+change".to_owned(), + }; + assert_eq!( + message.patch(), + Some("---\ndiff --git a/foo b/foo\n+change") + ); + assert_eq!(message.body_without_patch(), "intro"); + assert!(message.has_patch()); + } + + #[test] + fn patch_without_preceding_separator_starts_at_marker_line() { + let message = Message { + id: MessageId(0), + subject: "x".to_owned(), + author: author("A", "a@example.com"), + to: Vec::new(), + cc: Vec::new(), + date: String::new(), + body: "intro\n\ndiff --git a/foo b/foo\n+change".to_owned(), + }; + assert_eq!(message.patch(), Some("diff --git a/foo b/foo\n+change")); + assert_eq!(message.body_without_patch(), "intro"); + } + + #[test] + fn no_patch_body_returns_none_and_trimmed() { + let message = Message { + id: MessageId(0), + subject: "x".to_owned(), + author: author("A", "a@example.com"), + to: Vec::new(), + cc: Vec::new(), + date: String::new(), + body: " just a body \n".to_owned(), + }; + assert_eq!(message.patch(), None); + assert!(!message.has_patch()); + assert_eq!(message.body_without_patch(), " just a body"); + } + + #[test] + fn participants_dedup_by_email() { + let message = Message { + id: MessageId(0), + subject: "x".to_owned(), + author: author("Alice", "alice@example.com"), + to: vec![ + author("Bob", "bob@example.com"), + author("Alice Again", "alice@example.com"), + ], + cc: vec![ + author("Bob", "bob@example.com"), + author("Carol", "carol@example.com"), + ], + date: String::new(), + body: String::new(), + }; + let emails: Vec<&str> = message + .participants() + .iter() + .map(|a| a.email.as_str()) + .collect(); + assert_eq!( + emails, + vec!["alice@example.com", "bob@example.com", "carol@example.com"] + ); + } +} diff --git a/src/picker.rs b/src/picker.rs new file mode 100644 index 0000000..48f8d0e --- /dev/null +++ b/src/picker.rs @@ -0,0 +1,281 @@ +//! Filesystem picker: browse a directory and attach patch files. + +use std::path::{Path, PathBuf}; + +/// Whether an entry is a directory or a regular file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryKind { + Dir, + File, +} + +/// One directory entry. +#[derive(Debug)] +pub struct PickerEntry { + /// File name (not the full path). + pub name: String, + /// Full path on disk. + pub path: PathBuf, + /// Directory or file. + pub kind: EntryKind, + /// Size in bytes (`0` for directories). + pub size: u64, + /// Whether this file looks like a patch/email attachment. + pub is_patch: bool, +} + +/// Directory browser state. +#[derive(Debug)] +pub struct FilePicker { + /// The directory being browsed. + pub dir: PathBuf, + /// Current entries, directories first then files, case-insensitive name. + pub entries: Vec, + /// Index of the selected entry. + pub selected: usize, + /// Whether hidden (dot) entries are shown. + pub show_hidden: bool, + /// Error reading the directory, if any. + pub error: Option, +} + +/// Result of activating the selected entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PickerOutcome { + /// Nothing selected (empty directory). + None, + /// Descended into a directory. + Descend, + /// A file to attach. + Attach(PathBuf), +} + +impl FilePicker { + /// A picker rooted at `$HOME`, or the current directory when unset. + #[must_use] + pub fn new_home() -> Self { + let dir = std::env::var_os("HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))); + let mut picker = Self { + dir, + entries: Vec::new(), + selected: 0, + show_hidden: false, + error: None, + }; + picker.refresh(); + picker + } + + /// Re-read `dir`, filtering hidden entries unless `show_hidden`. + pub fn refresh(&mut self) { + self.entries.clear(); + self.error = None; + let entries = match std::fs::read_dir(&self.dir) { + Ok(entries) => entries, + Err(err) => { + self.error = Some(format!("cannot read {}: {err}", self.dir.display())); + self.selected = 0; + return; + } + }; + for entry in entries { + let Ok(entry) = entry else { continue }; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') && !self.show_hidden { + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + let kind = if metadata.is_dir() { + EntryKind::Dir + } else { + EntryKind::File + }; + let size = if metadata.is_dir() { 0 } else { metadata.len() }; + let is_patch = matches!(kind, EntryKind::File) && is_patch_extension(&name); + self.entries.push(PickerEntry { + name, + path: entry.path(), + kind, + size, + is_patch, + }); + } + self.entries.sort_by(|a, b| { + let a_dir = matches!(a.kind, EntryKind::Dir); + let b_dir = matches!(b.kind, EntryKind::Dir); + b_dir.cmp(&a_dir).then_with(|| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + }) + }); + self.selected = self.selected.min(self.entries.len().saturating_sub(1)); + } + + /// Move the selection by `delta`, wrapping at the edges. + pub fn move_selection(&mut self, delta: i8) { + let len = self.entries.len(); + if len == 0 { + return; + } + self.selected = if delta < 0 { + (self.selected + len - 1) % len + } else { + (self.selected + 1) % len + }; + } + + /// Activate the selected entry: descend into a directory or attach a file. + pub fn enter(&mut self) -> PickerOutcome { + let Some(entry) = self.entries.get(self.selected) else { + return PickerOutcome::None; + }; + let kind = entry.kind; + let path = entry.path.clone(); + match kind { + EntryKind::Dir => { + self.dir = path; + self.refresh(); + PickerOutcome::Descend + } + EntryKind::File => PickerOutcome::Attach(path), + } + } + + /// Move to the parent directory, if any. + pub fn go_up(&mut self) -> bool { + let Some(parent) = self.dir.parent().map(Path::to_path_buf) else { + return false; + }; + if parent.as_os_str().is_empty() { + return false; + } + self.dir = parent; + self.selected = 0; + self.refresh(); + true + } + + /// Toggle hidden entries on/off and re-read. + pub fn toggle_hidden(&mut self) { + self.show_hidden = !self.show_hidden; + self.refresh(); + } + + /// The selected entry, if any. + #[must_use] + pub fn selected_entry(&self) -> Option<&PickerEntry> { + self.entries.get(self.selected) + } +} + +/// Whether `name` has a patch/email file extension (case-insensitive). +fn is_patch_extension(name: &str) -> bool { + name.rsplit_once('.').is_some_and(|(_, ext)| { + matches!( + ext.to_ascii_lowercase().as_str(), + "patch" | "diff" | "mbox" | "eml" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = + std::env::temp_dir().join(format!("lazymail-picker-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn picker_at(dir: PathBuf) -> FilePicker { + FilePicker { + dir, + entries: Vec::new(), + selected: 0, + show_hidden: false, + error: None, + } + } + + #[test] + fn filters_hidden_and_toggles() { + let dir = unique_dir(); + std::fs::write(dir.join("visible.txt"), "x").unwrap(); + std::fs::write(dir.join(".hidden.txt"), "x").unwrap(); + let mut picker = picker_at(dir.clone()); + picker.refresh(); + let names: Vec<&str> = picker.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["visible.txt"]); + + picker.toggle_hidden(); + let names: Vec<&str> = picker.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec![".hidden.txt", "visible.txt"]); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn sorts_dirs_before_files() { + let dir = unique_dir(); + std::fs::create_dir(dir.join("zeta_dir")).unwrap(); + std::fs::write(dir.join("alpha.txt"), "x").unwrap(); + std::fs::create_dir(dir.join("beta_dir")).unwrap(); + let mut picker = picker_at(dir.clone()); + picker.refresh(); + let names: Vec<&str> = picker.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["beta_dir", "zeta_dir", "alpha.txt"]); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn move_selection_wraps() { + let dir = unique_dir(); + std::fs::write(dir.join("a"), "x").unwrap(); + std::fs::write(dir.join("b"), "x").unwrap(); + std::fs::write(dir.join("c"), "x").unwrap(); + let mut picker = picker_at(dir.clone()); + picker.refresh(); + assert_eq!(picker.selected, 0); + picker.move_selection(-1); + assert_eq!(picker.selected, 2); + picker.move_selection(1); + assert_eq!(picker.selected, 0); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn go_up_returns_to_parent() { + let dir = unique_dir(); + std::fs::create_dir(dir.join("sub")).unwrap(); + let mut picker = picker_at(dir.clone()); + picker.refresh(); + assert_eq!(picker.enter(), PickerOutcome::Descend); + assert_eq!(picker.dir, dir.join("sub")); + assert!(picker.go_up()); + assert_eq!(picker.dir, dir); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn recognizes_patch_extensions() { + assert!(is_patch_extension("0001-fix.patch")); + assert!(is_patch_extension("foo.PATCH")); + assert!(is_patch_extension("series.diff")); + assert!(is_patch_extension("msg.mbox")); + assert!(is_patch_extension("mail.eml")); + assert!(!is_patch_extension("notes.txt")); + assert!(!is_patch_extension("README")); + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..d9f6a45 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,41 @@ +//! Colors and shared style helpers for all widgets. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::Block; + +/// Accent color: focused borders, selection, brand text. +pub const ACCENT: Color = Color::Cyan; +/// Muted color: unfocused borders, secondary text. +pub const MUTED: Color = Color::DarkGray; +/// Background of the highlighted row. +const SELECT_BG: Color = Color::Rgb(23, 42, 58); + +/// Style for brand/key text on the header and footer. +#[must_use] +pub const fn accent() -> Style { + Style::new().fg(ACCENT) +} + +/// Style for secondary text (dates, authors, hints). +#[must_use] +pub const fn muted() -> Style { + Style::new().fg(MUTED) +} + +/// Style for the highlighted list row. +#[must_use] +pub const fn selection() -> Style { + Style::new() + .fg(ACCENT) + .bg(SELECT_BG) + .add_modifier(Modifier::BOLD) +} + +/// Bordered pane block; the border is [`ACCENT`] when focused. +#[must_use] +pub fn pane_block(title: &str, focused: bool) -> Block<'_> { + let border = if focused { ACCENT } else { MUTED }; + Block::bordered() + .border_style(Style::new().fg(border)) + .title(title) +} diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs new file mode 100644 index 0000000..e0a93a6 --- /dev/null +++ b/src/ui/filepicker.rs @@ -0,0 +1,209 @@ +//! File-picker pane: a yazi-like mini browser for attaching patch files. + +use std::path::{Path, PathBuf}; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap}; + +use crate::app::App; +use crate::compose::ComposeField; +use crate::picker::{EntryKind, PickerEntry}; +use crate::theme; + +/// Emphasized style for attachable patch files. +const PATCH_STYLE: Style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD); + +/// Render the file picker: a bordered directory listing with a hint footer. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let picker = &app.picker; + let focused = app.compose.field == ComposeField::Patches; + let title = format!(" {} ", directory_label(&picker.dir, picker.show_hidden)); + let block = theme::pane_block(&title, focused); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.is_empty() { + return; + } + + let [list_area, hint_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + render_hint(frame, hint_area); + + if let Some(error) = &picker.error { + frame.render_widget( + Paragraph::new(error.as_str()) + .style(theme::muted()) + .wrap(Wrap { trim: true }), + list_area, + ); + return; + } + if list_area.is_empty() { + return; + } + if picker.entries.is_empty() { + frame.render_widget( + Paragraph::new("empty directory").style(theme::muted()), + list_area, + ); + return; + } + + let height = usize::from(list_area.height); + let offset = scroll_offset(picker.selected, picker.entries.len(), height); + let mut state = ListState::default() + .with_selected(Some( + picker.selected.min(picker.entries.len().saturating_sub(1)), + )) + .with_offset(offset); + let list = List::new(picker.entries.iter().map(entry_item)) + .highlight_style(theme::selection()) + .highlight_symbol("▸ "); + frame.render_stateful_widget(list, list_area, &mut state); +} + +/// One picker row: directories accent with a trailing `/`, patch files green +/// and bold with a size, other files muted. +fn entry_item(entry: &PickerEntry) -> ListItem<'static> { + let line = match entry.kind { + EntryKind::Dir => Line::from(Span::styled(format!("{}/", entry.name), theme::accent())), + EntryKind::File => { + let style = if entry.is_patch { + PATCH_STYLE + } else { + theme::muted() + }; + Line::from(vec![ + Span::styled(entry.name.clone(), style), + Span::styled(format!(" {}", human_size(entry.size)), style), + ]) + } + }; + ListItem::new(line) +} + +/// Footer hint line shown inside the picker block. +fn render_hint(frame: &mut Frame, area: Rect) { + if area.is_empty() { + return; + } + let line = Line::from(vec![ + Span::styled("j/k", theme::accent()), + Span::raw(" move"), + Span::styled(" · ", theme::muted()), + Span::styled("l/Enter", theme::accent()), + Span::raw(" open"), + Span::styled(" · ", theme::muted()), + Span::styled("h", theme::accent()), + Span::raw(" parent"), + Span::styled(" · ", theme::muted()), + Span::styled(".", theme::accent()), + Span::raw(" hidden"), + ]); + frame.render_widget(Paragraph::new(line), area); +} + +/// Block title: the directory (home abbreviated to `~`) plus a hidden marker. +fn directory_label(dir: &Path, show_hidden: bool) -> String { + let label = std::env::var_os("HOME") + .map(PathBuf::from) + .map_or_else(|| dir.display().to_string(), |home| abbreviate(dir, &home)); + with_hidden(&label, show_hidden) +} + +/// Abbreviate a leading `home` prefix to `~`. +fn abbreviate(dir: &Path, home: &Path) -> String { + let Ok(rest) = dir.strip_prefix(home) else { + return dir.display().to_string(); + }; + if rest.as_os_str().is_empty() { + "~".to_owned() + } else { + format!("~/{}", rest.display()) + } +} + +/// Append the `[hidden]` suffix when hidden entries are shown. +fn with_hidden(label: &str, show_hidden: bool) -> String { + if show_hidden { + format!("{label} [hidden]") + } else { + label.to_owned() + } +} + +/// Offset that keeps `selected` visible in a `height`-row viewport. +pub const fn scroll_offset(selected: usize, len: usize, height: usize) -> usize { + if height == 0 || len <= height { + return 0; + } + let first_visible = selected.saturating_add(1).saturating_sub(height); + let last_window = len - height; + if first_visible < last_window { + first_visible + } else { + last_window + } +} + +/// Human-readable byte size: `12 B`, `1.5 KB`, `3.0 MB`. +pub fn human_size(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + if bytes < KIB { + format!("{bytes} B") + } else if bytes < MIB { + scaled(bytes, KIB, "KB") + } else { + scaled(bytes, MIB, "MB") + } +} + +/// `bytes` as a one-decimal multiple of `unit`, avoiding float conversions. +fn scaled(bytes: u64, unit: u64, suffix: &str) -> String { + let whole = bytes / unit; + let tenth = (bytes % unit) * 10 / unit; + format!("{whole}.{tenth} {suffix}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_size_scales_units() { + assert_eq!(human_size(0), "0 B"); + assert_eq!(human_size(1023), "1023 B"); + assert_eq!(human_size(1024), "1.0 KB"); + assert_eq!(human_size(1536), "1.5 KB"); + assert_eq!(human_size(1024 * 1024), "1.0 MB"); + assert_eq!(human_size(1024 * 1024 + 512 * 1024), "1.5 MB"); + } + + #[test] + fn scroll_offset_keeps_selection_visible() { + assert_eq!(scroll_offset(0, 10, 5), 0); + assert_eq!(scroll_offset(4, 10, 5), 0); + assert_eq!(scroll_offset(5, 10, 5), 1); + assert_eq!(scroll_offset(9, 10, 5), 5); + assert_eq!(scroll_offset(3, 3, 5), 0); + assert_eq!(scroll_offset(2, 10, 0), 0); + } + + #[test] + fn abbreviates_home_prefix() { + let home = Path::new("/home/me"); + assert_eq!(abbreviate(Path::new("/home/me"), home), "~"); + assert_eq!(abbreviate(Path::new("/home/me/src"), home), "~/src"); + assert_eq!(abbreviate(Path::new("/etc"), home), "/etc"); + } + + #[test] + fn hidden_marker_is_appended() { + assert_eq!(with_hidden("~/src", true), "~/src [hidden]"); + assert_eq!(with_hidden("~/src", false), "~/src"); + } +} diff --git a/src/ui/footer.rs b/src/ui/footer.rs new file mode 100644 index 0000000..628238d --- /dev/null +++ b/src/ui/footer.rs @@ -0,0 +1,191 @@ +//! Footer bar: context-sensitive keybinding hints (or the filter prompt). + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use crate::app::{App, Mode, Panel, Screen}; +use crate::compose::ComposeField; +use crate::theme; + +/// One `key` → `label` hint pair. +type Binding = (&'static str, &'static str); + +/// Hints while browsing with the thread or message list focused. +const BROWSE_BINDINGS: &[Binding] = &[ + ("tab", "cycle"), + ("j/k", "move"), + ("enter", "open"), + ("/", "filter"), + ("f", "people"), + ("F", "author"), + ("c", "clear"), + ("t", "live"), + ("s", "sender"), + ("?", "help"), + ("q", "quit"), +]; + +/// Hints while browsing with the preview focused. +const BROWSE_PREVIEW_BINDINGS: &[Binding] = &[ + ("p", "patch"), + ("pgup/pgdn", "scroll"), + ("tab", "cycle"), + ("/", "filter"), + ("f", "people"), + ("F", "author"), + ("c", "clear"), + ("t", "live"), + ("s", "sender"), + ("?", "help"), + ("q", "quit"), +]; + +/// Hints on the Sender screen while a text field is focused. +const SENDER_TEXT_BINDINGS: &[Binding] = &[ + ("tab", "field"), + ("←/→", "cursor"), + ("home/end", "line"), + ("⌫", "edit"), + ("^S", "send"), + ("esc", "back"), + ("^C", "quit"), +]; + +/// Hints on the Sender screen while the patches field is focused. +const SENDER_PATCHES_BINDINGS: &[Binding] = &[ + ("tab", "field"), + ("j/k", "pick"), + ("J/K", "patch"), + ("enter", "open"), + ("h", "parent"), + (".", "hidden"), + ("a", "add"), + ("d", "remove"), + ("^S", "send"), + ("esc", "back"), + ("^C", "quit"), +]; + +/// Hints while typing a filter. +const FILTER_BINDINGS: &[Binding] = &[("type", "edit"), ("enter", "apply"), ("esc", "cancel")]; + +/// Hints while the participants overlay is open. +const PARTICIPANTS_BINDINGS: &[Binding] = &[ + ("type", "search"), + ("j/k", "move"), + ("enter", "apply"), + ("esc", "cancel"), +]; + +/// Hints while the patch overlay is open. +const PATCH_BINDINGS: &[Binding] = &[("j/k", "scroll"), ("esc/p/enter", "close")]; + +/// Hints while the mailing-list picker is open. +const LISTS_BINDINGS: &[Binding] = &[("j/k", "move"), ("enter", "select"), ("esc", "cancel")]; + +/// Render the one-line footer bar. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + if app.mode == Mode::Filter { + render_filter_prompt(frame, area, app); + } else { + render_bindings(frame, area, bindings(app)); + } +} + +/// The hint set for the current screen, mode and focused pane or field. +const fn bindings(app: &App) -> &'static [Binding] { + match app.mode { + Mode::Filter => FILTER_BINDINGS, + Mode::Participants => PARTICIPANTS_BINDINGS, + Mode::Patch => PATCH_BINDINGS, + Mode::Lists => LISTS_BINDINGS, + Mode::Normal | Mode::Help => match app.screen { + Screen::Browse => browse_bindings(app.focus), + Screen::Sender => sender_bindings(app.compose.field), + }, + } +} + +/// Browse hints for the focused pane. +const fn browse_bindings(focus: Panel) -> &'static [Binding] { + match focus { + Panel::Threads | Panel::Messages => BROWSE_BINDINGS, + Panel::Preview => BROWSE_PREVIEW_BINDINGS, + } +} + +/// Sender hints for the focused compose field. +const fn sender_bindings(field: ComposeField) -> &'static [Binding] { + match field { + ComposeField::Patches => SENDER_PATCHES_BINDINGS, + ComposeField::To | ComposeField::Cc | ComposeField::Subject | ComposeField::Body => { + SENDER_TEXT_BINDINGS + } + } +} + +/// Render `key label` pairs separated by bars. +fn render_bindings(frame: &mut Frame, area: Rect, bindings: &[Binding]) { + let mut spans = Vec::with_capacity(bindings.len() * 3); + for (index, (key, label)) in bindings.iter().enumerate() { + if index > 0 { + spans.push(Span::styled(" │ ", theme::muted())); + } + spans.push(Span::styled(*key, theme::accent())); + spans.push(Span::from(format!(" {label}"))); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// Filter prompt replacing the hints while typing a filter. +fn render_filter_prompt(frame: &mut Frame, area: Rect, app: &App) { + let prompt = Line::from(vec![ + Span::styled(" filter: ", theme::accent()), + Span::from(app.filter_input.as_str()), + Span::styled("▌", theme::accent()), + Span::styled(" enter apply │ esc cancel", theme::muted()), + ]); + frame.render_widget(Paragraph::new(prompt), area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn keys(app: &App) -> Vec<&'static str> { + bindings(app).iter().map(|(key, _)| *key).collect() + } + + #[test] + fn browse_hints_include_participant_and_patch_keys() { + let app = App::new(&Config::default()); + let hints = keys(&app); + for key in ["t", "f", "F", "c"] { + assert!(hints.contains(&key), "browse hints must mention {key}"); + } + // `p` opens the patch only when the preview pane is focused. + assert!(!hints.contains(&"p"), "threads focus must not advertise p"); + let mut preview = App::new(&Config::default()); + preview.focus = Panel::Preview; + assert!( + keys(&preview).contains(&"p"), + "preview focus must mention p" + ); + } + + #[test] + fn hints_follow_mode_and_compose_field() { + let mut app = App::new(&Config::default()); + app.mode = Mode::Patch; + assert_eq!(keys(&app), ["j/k", "esc/p/enter"]); + app.mode = Mode::Normal; + app.screen = Screen::Sender; + app.compose.field = ComposeField::Patches; + assert!(keys(&app).contains(&"a")); + app.compose.field = ComposeField::To; + assert!(keys(&app).contains(&"home/end")); + } +} diff --git a/src/ui/header.rs b/src/ui/header.rs new file mode 100644 index 0000000..35b0e51 --- /dev/null +++ b/src/ui/header.rs @@ -0,0 +1,60 @@ +//! Header bar: app brand, screen context, list name, live state, filters. + +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use crate::app::{App, Screen}; +use crate::theme; + +/// Render the one-line header bar. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + if area.width == 0 || area.height == 0 { + return; + } + let mut spans = vec![Span::styled(" lazymail ", theme::selection())]; + if app.screen == Screen::Sender { + spans.push(Span::from(" · sender")); + } + spans.push(Span::from(format!(" · {}", app.backend.list_name()))); + if let Some(filter) = &app.participant_filter { + spans.push(Span::styled(" · ", theme::muted())); + spans.push(Span::styled(format!("⌕ {}", filter.email), theme::accent())); + } + if app.filter_active { + spans.push(Span::styled(" · ", theme::muted())); + spans.push(Span::styled( + format!("⌕ \"{}\"", app.filter_input), + theme::accent(), + )); + } + + let (live_label, live_style) = if app.live { + ("● LIVE", theme::accent()) + } else { + ("○ PAUSED", theme::muted()) + }; + let live = Span::styled(live_label, live_style); + + if app.screen == Screen::Sender { + spans.push(Span::styled(" · ", theme::muted())); + spans.push(live); + frame.render_widget(Paragraph::new(Line::from(spans)), area); + return; + } + + let count = format!("{} threads", app.threads().len()); + let status = format!(" · {count}"); + // Two extra columns for breathing room. + let status_width = + u16::try_from(live_label.chars().count() + status.chars().count() + 2).unwrap_or(32); + let [left, right] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(status_width)]).areas(area); + frame.render_widget(Paragraph::new(Line::from(spans)), left); + let right_line = Line::from(vec![live, Span::styled(status, theme::muted())]); + frame.render_widget( + Paragraph::new(right_line).alignment(Alignment::Right), + right, + ); +} diff --git a/src/ui/help.rs b/src/ui/help.rs new file mode 100644 index 0000000..e3e0c26 --- /dev/null +++ b/src/ui/help.rs @@ -0,0 +1,305 @@ +//! Help overlay: centered cheat sheet split into global and context-local keys. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Modifier; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; + +use crate::app::{App, Mode, Panel, Screen}; +use crate::compose::ComposeField; +use crate::theme; + +/// One `keys` → `description` help row. +type Binding = (&'static str, &'static str); + +/// Keys available from any browse context. +const GLOBAL_BINDINGS: &[Binding] = &[ + ("q / ^C", "quit"), + ("?", "help"), + ("r", "refresh"), + ("t", "toggle live"), + ("L", "mailing lists"), + ("s", "compose/sender"), + ("tab / shift-tab", "cycle pane"), + ("h / l / ← / →", "cycle pane"), + ("1 / 2 / 3", "focus pane"), +]; + +/// Browse keys while the thread or message list is focused. +const BROWSE_BINDINGS: &[Binding] = &[ + ("j / k / ↑ / ↓", "move"), + ("enter", "open"), + ("/", "filter"), + ("f", "participants"), + ("F", "filter author"), + ("c", "clear author"), + ("esc", "back"), +]; + +/// Browse keys while the preview is focused. +const BROWSE_PREVIEW_BINDINGS: &[Binding] = &[ + ("p", "view patch"), + ("pgup / pgdn", "scroll"), + ("enter", "open"), + ("/", "filter"), + ("f", "participants"), + ("F", "filter author"), + ("c", "clear author"), + ("esc", "back"), +]; + +/// Sender keys while a text field is focused. +const SENDER_TEXT_BINDINGS: &[Binding] = &[ + ("tab / shift-tab", "switch field"), + ("← / →", "move cursor"), + ("home / end", "line ends"), + ("backspace / del", "edit"), + ("^S", "send (stub)"), + ("esc", "back to browse"), +]; + +/// Sender keys while the patches field (file picker) is focused. +const SENDER_PATCHES_BINDINGS: &[Binding] = &[ + ("tab / shift-tab", "switch field"), + ("j / k / ↑ / ↓", "pick file"), + ("J / K", "select patch"), + ("l / → / enter", "open dir/file"), + ("h / ← / bksp", "parent dir"), + (".", "hidden files"), + ("a", "add stub patch"), + ("d", "remove patch"), + ("^S", "send (stub)"), + ("esc", "back to browse"), +]; + +/// Filter-mode keys. +const FILTER_BINDINGS: &[Binding] = &[ + ("type", "edit filter"), + ("enter", "apply"), + ("esc", "cancel"), +]; + +/// Participants-overlay keys. +const PARTICIPANTS_BINDINGS: &[Binding] = &[ + ("type", "search"), + ("j / k / ↑ / ↓", "move"), + ("enter", "apply"), + ("esc", "cancel"), +]; + +/// Patch-overlay keys. +const PATCH_BINDINGS: &[Binding] = &[("j / k / ↑ / ↓", "scroll"), ("esc / p / enter", "close")]; + +/// Mailing-list picker keys. +const LISTS_BINDINGS: &[Binding] = &[ + ("j / k / ↑ / ↓", "move"), + ("enter", "select"), + ("esc", "cancel"), +]; + +/// The context the overlay documents. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Context { + /// Browse screen, per focused pane. + Browse(Panel), + /// Sender screen, per focused compose field. + Sender(ComposeField), + /// Text filter input. + Filter, + /// Participants overlay. + Participants, + /// Patch overlay. + Patch, + /// Mailing-list picker. + Lists, +} + +impl Context { + /// Human-readable context name, e.g. `Browse / Threads`. + fn label(self) -> String { + match self { + Self::Browse(panel) => format!("Browse / {}", panel_label(panel)), + Self::Sender(field) => format!("Sender / {}", field_label(field)), + Self::Filter => "Filter".to_owned(), + Self::Participants => "Participants".to_owned(), + Self::Patch => "Patch".to_owned(), + Self::Lists => "Lists".to_owned(), + } + } +} + +/// Render the centered help overlay on top of the panes. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + frame.render_widget(Clear, area); + let [_, middle, _] = Layout::vertical([ + Constraint::Percentage(10), + Constraint::Min(18), + Constraint::Percentage(10), + ]) + .areas(area); + let [_, center, _] = Layout::horizontal([ + Constraint::Percentage(5), + Constraint::Fill(1), + Constraint::Percentage(5), + ]) + .areas(middle); + + let ctx = context(app); + let mut lines = Vec::new(); + lines.push(section_heading("Global")); + lines.extend(binding_lines(GLOBAL_BINDINGS)); + lines.push(Line::from("")); + lines.push(section_heading(&format!("Local — {}", ctx.label()))); + lines.extend(binding_lines(local_bindings(app))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "any key closes this overlay", + theme::muted(), + ))); + + let block = Block::bordered() + .border_style(theme::accent()) + .title(format!(" help · {} ", ctx.label())) + .padding(Padding::uniform(1)); + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, center); +} + +/// A section label line, e.g. `Global` or `Local — Filter`. +fn section_heading(title: &str) -> Line<'static> { + Line::from(Span::styled( + title.to_owned(), + theme::accent().add_modifier(Modifier::BOLD), + )) +} + +/// Render `bindings` as lines holding up to two `keys description` columns. +fn binding_lines(bindings: &[Binding]) -> Vec> { + let key_width = bindings + .iter() + .map(|(keys, _)| keys.chars().count()) + .max() + .unwrap_or(0); + bindings + .chunks(2) + .map(|pair| { + let mut spans = Vec::with_capacity(pair.len() * 4); + for (index, (keys, description)) in pair.iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(format!("{keys: Context { + match app.mode { + Mode::Filter => Context::Filter, + Mode::Participants => Context::Participants, + Mode::Patch => Context::Patch, + Mode::Lists => Context::Lists, + Mode::Normal | Mode::Help => match app.screen { + Screen::Browse => Context::Browse(app.focus), + Screen::Sender => Context::Sender(app.compose.field), + }, + } +} + +/// The local binding table for `app`'s current context. +const fn local_bindings(app: &App) -> &'static [Binding] { + match context(app) { + Context::Browse(Panel::Preview) => BROWSE_PREVIEW_BINDINGS, + Context::Browse(Panel::Threads | Panel::Messages) => BROWSE_BINDINGS, + Context::Sender(ComposeField::Patches) => SENDER_PATCHES_BINDINGS, + Context::Sender( + ComposeField::To | ComposeField::Cc | ComposeField::Subject | ComposeField::Body, + ) => SENDER_TEXT_BINDINGS, + Context::Filter => FILTER_BINDINGS, + Context::Participants => PARTICIPANTS_BINDINGS, + Context::Patch => PATCH_BINDINGS, + Context::Lists => LISTS_BINDINGS, + } +} + +/// Pane name for a browse context label. +const fn panel_label(panel: Panel) -> &'static str { + match panel { + Panel::Threads => "Threads", + Panel::Messages => "Messages", + Panel::Preview => "Preview", + } +} + +/// Field name for a sender context label. +const fn field_label(field: ComposeField) -> &'static str { + match field { + ComposeField::To => "To", + ComposeField::Cc => "Cc", + ComposeField::Subject => "Subject", + ComposeField::Body => "Body", + ComposeField::Patches => "Patches", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + #[test] + fn context_names_screen_mode_and_focus() { + let mut app = App::new(&Config::default()); + assert_eq!(context(&app), Context::Browse(Panel::Threads)); + app.focus = Panel::Preview; + assert_eq!(context(&app).label(), "Browse / Preview"); + app.mode = Mode::Participants; + assert_eq!(context(&app), Context::Participants); + app.mode = Mode::Normal; + app.screen = Screen::Sender; + assert_eq!(context(&app).label(), "Sender / To"); + app.compose.field = ComposeField::Patches; + assert_eq!(context(&app).label(), "Sender / Patches"); + } + + #[test] + fn local_bindings_follow_the_context() { + let mut app = App::new(&Config::default()); + assert_eq!(local_bindings(&app), BROWSE_BINDINGS); + app.focus = Panel::Preview; + assert_eq!(local_bindings(&app), BROWSE_PREVIEW_BINDINGS); + app.mode = Mode::Patch; + assert_eq!(local_bindings(&app), PATCH_BINDINGS); + } + + #[test] + fn render_draws_sections_and_survives_tiny_terminals() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Cell; + + let app = App::new(&Config::default()); + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), &app)) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let rendered: String = buffer.content().iter().map(Cell::symbol).collect(); + assert!(rendered.contains("Global")); + assert!(rendered.contains("Local — Browse / Threads")); + + for (width, height) in [(80, 24), (40, 10), (20, 5), (1, 1)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), &app)) + .unwrap(); + } + } +} diff --git a/src/ui/layout.rs b/src/ui/layout.rs new file mode 100644 index 0000000..32b1aa2 --- /dev/null +++ b/src/ui/layout.rs @@ -0,0 +1,25 @@ +//! Screen splitting: header/body/footer rows and the three body columns. + +use ratatui::layout::{Constraint, Layout, Rect}; + +/// Split the full screen into header (1 row), body, footer (1 row). +#[must_use] +pub fn root(area: Rect) -> [Rect; 3] { + Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(area) +} + +/// Split the body into Threads (~25%), Messages (~35%), Preview (rest). +#[must_use] +pub fn columns(body: Rect) -> [Rect; 3] { + Layout::horizontal([ + Constraint::Percentage(25), + Constraint::Percentage(35), + Constraint::Fill(1), + ]) + .areas(body) +} diff --git a/src/ui/lists.rs b/src/ui/lists.rs new file mode 100644 index 0000000..f1c3557 --- /dev/null +++ b/src/ui/lists.rs @@ -0,0 +1,51 @@ +//! Mailing-list picker overlay: centered list of configured lists. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Padding}; + +use crate::app::App; +use crate::theme; + +/// Render the centered list picker on top of the browse screen. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + frame.render_widget(Clear, area); + let [_, middle, _] = Layout::vertical([ + Constraint::Percentage(25), + Constraint::Min(10), + Constraint::Percentage(25), + ]) + .areas(area); + let [_, center, _] = Layout::horizontal([ + Constraint::Percentage(20), + Constraint::Fill(1), + Constraint::Percentage(20), + ]) + .areas(middle); + + let items = app.lists.iter().enumerate().map(|(index, list)| { + let marker = if index == app.active_list { "●" } else { " " }; + let name = Line::from(vec![ + Span::raw(format!("{marker} ")), + Span::raw(list.name.as_str()), + ]); + let meta = Line::from(Span::styled( + format!(" {} · {}", list.address, list.archive_url), + theme::muted(), + )); + ListItem::new(vec![name, meta]) + }); + + let mut state = ListState::default(); + state.select(Some(app.list_selected)); + let block = Block::bordered() + .border_style(theme::accent()) + .title(" lists ") + .padding(Padding::uniform(1)); + let list = List::new(items) + .block(block) + .highlight_style(theme::selection()) + .highlight_symbol("▍"); + frame.render_stateful_widget(list, center, &mut state); +} diff --git a/src/ui/messages.rs b/src/ui/messages.rs new file mode 100644 index 0000000..6845d6f --- /dev/null +++ b/src/ui/messages.rs @@ -0,0 +1,95 @@ +//! Messages pane: the mails of the selected thread (middle column). + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use crate::app::{App, Panel}; +use crate::models::{Author, Message}; +use crate::theme; +use crate::ui::filepicker::scroll_offset; + +/// Render the message list of the selected thread. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let messages = app.messages(); + let title = format!(" Messages ({}) ", messages.len()); + let block = theme::pane_block(title.as_str(), app.focus == Panel::Messages); + + if messages.is_empty() { + frame.render_widget( + Paragraph::new("no messages") + .style(theme::muted()) + .block(block), + area, + ); + return; + } + + let items = messages.iter().map(|message| { + ListItem::new(vec![ + Line::from(message.subject.as_str()), + Line::from(Span::styled(participant_line(message), theme::muted())), + ]) + }); + let mut state = ListState::default(); + state.select(Some(app.message_selected)); + let visible = usize::from(block.inner(area).height) / 2; + *state.offset_mut() = scroll_offset(app.message_selected, messages.len(), visible); + let list = List::new(items) + .block(block) + .highlight_style(theme::selection()) + .highlight_symbol("▍"); + frame.render_stateful_widget(list, area, &mut state); +} + +/// One muted line: `From Name · to a@x, b@y · cc c@z +N`. +fn participant_line(message: &Message) -> String { + let to = recipients("to", &message.to); + let cc = recipients("cc", &message.cc); + match (to, cc) { + (Some(to), Some(cc)) => format!("From {} · {to} · {cc}", message.author), + (Some(to), None) => format!("From {} · {to}", message.author), + (None, Some(cc)) => format!("From {} · {cc}", message.author), + (None, None) => format!("From {}", message.author), + } +} + +/// Format up to two `prefix email` recipients, collapsing the rest into `+N`. +fn recipients(prefix: &str, authors: &[Author]) -> Option { + const SHOWN: usize = 2; + if authors.is_empty() { + return None; + } + let shown = authors + .iter() + .take(SHOWN) + .map(|author| author.email.as_str()) + .collect::>() + .join(", "); + let extra = authors.len().saturating_sub(SHOWN); + Some(if extra == 0 { + format!("{prefix} {shown}") + } else { + format!("{prefix} {shown} +{extra}") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn author(email: &str) -> Author { + Author { + name: "N".to_owned(), + email: email.to_owned(), + } + } + + #[test] + fn recipients_collapse_beyond_two() { + let list = vec![author("a@x"), author("b@x"), author("c@x")]; + assert_eq!(recipients("to", &list), Some("to a@x, b@x +1".to_owned())); + assert_eq!(recipients("cc", &[]), None); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..91a6b17 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,48 @@ +//! Top-level rendering: screen splitting and delegation to the widgets. + +pub mod filepicker; +pub mod footer; +pub mod header; +pub mod help; +pub mod layout; +pub mod lists; +pub mod messages; +pub mod participants; +pub mod patch; +pub mod preview; +pub mod sender; +pub mod threads; + +use ratatui::Frame; + +use crate::app::{App, Mode, Screen}; + +/// Render the full screen. +pub fn render(frame: &mut Frame, app: &App) { + let [header_area, body_area, footer_area] = layout::root(frame.area()); + header::render(frame, header_area, app); + + match app.screen { + Screen::Browse => { + let [threads_area, messages_area, preview_area] = layout::columns(body_area); + threads::render(frame, threads_area, app); + messages::render(frame, messages_area, app); + preview::render(frame, preview_area, app); + } + Screen::Sender => sender::render(frame, body_area, app), + } + + footer::render(frame, footer_area, app); + if app.mode == Mode::Help { + help::render(frame, frame.area(), app); + } + if app.mode == Mode::Lists { + lists::render(frame, frame.area(), app); + } + if app.mode == Mode::Participants { + participants::render(frame, frame.area(), app); + } + if app.mode == Mode::Patch { + patch::render(frame, frame.area(), app); + } +} diff --git a/src/ui/participants.rs b/src/ui/participants.rs new file mode 100644 index 0000000..b608b53 --- /dev/null +++ b/src/ui/participants.rs @@ -0,0 +1,235 @@ +//! Participants overlay: pick a participant to filter threads and messages. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Padding, Paragraph}; + +use crate::app::App; +use crate::models::Participant; +use crate::theme; + +/// Render the centered participants overlay. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + if area.width == 0 || area.height == 0 { + return; + } + frame.render_widget(Clear, area); + let [_, middle, _] = Layout::vertical([ + Constraint::Percentage(15), + Constraint::Percentage(70), + Constraint::Percentage(15), + ]) + .areas(area); + let [_, center, _] = Layout::horizontal([ + Constraint::Percentage(20), + Constraint::Percentage(60), + Constraint::Percentage(20), + ]) + .areas(middle); + if center.width == 0 || center.height == 0 { + return; + } + + let block = Block::bordered() + .border_style(theme::accent()) + .title(" participants ") + .padding(Padding::uniform(1)); + let inner = block.inner(center); + frame.render_widget(block, center); + if inner.width == 0 || inner.height == 0 { + return; + } + + let participants = app.participants(); + let [search_area, status_area, list_area, footer_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(inner); + + render_search(frame, search_area, app); + render_status(frame, status_area, app, participants.len()); + render_list(frame, list_area, app, &participants); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "type to search · j/k move · Enter apply · Esc cancel", + theme::muted(), + ))), + footer_area, + ); +} + +/// The `Filter: ▌` search line. +fn render_search(frame: &mut Frame, area: Rect, app: &App) { + let mut spans = vec![Span::styled("Filter: ", theme::accent())]; + if app.participant_query.is_empty() { + spans.push(Span::styled("type to filter…", theme::muted())); + } else { + spans.push(Span::raw(app.participant_query.as_str())); + } + spans.push(Span::styled("▌", theme::accent())); + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// Active-filter banner, or the number of (query-filtered) participants. +fn render_status(frame: &mut Frame, area: Rect, app: &App, count: usize) { + let line = app.participant_filter.as_ref().map_or_else( + || { + Line::from(Span::styled( + format!("{count} participants"), + theme::muted(), + )) + }, + |filter| { + Line::from(vec![ + Span::styled("active filter: ", theme::muted()), + Span::styled(filter.email.as_str(), theme::accent()), + ]) + }, + ); + frame.render_widget(Paragraph::new(line), area); +} + +/// The scrollable participant list. +fn render_list(frame: &mut Frame, area: Rect, app: &App, participants: &[Participant]) { + if area.width == 0 || area.height == 0 { + return; + } + if participants.is_empty() { + frame.render_widget( + Paragraph::new("no participants").style(theme::muted()), + area, + ); + return; + } + + let selected = app.participant_selected.min(participants.len() - 1); + let visible = usize::from(area.height); + let offset = selected.saturating_sub(visible.saturating_sub(1)); + // The highlight symbol occupies one column left of every row. + let row_width = usize::from(area.width).saturating_sub(1); + let filter = app + .participant_filter + .as_ref() + .map(|author| author.email.as_str()); + + let items = participants + .iter() + .map(|participant| row(participant, filter, row_width)); + let mut state = ListState::default(); + state.select(Some(selected)); + *state.offset_mut() = offset; + let list = List::new(items) + .highlight_style(theme::selection()) + .highlight_symbol("▍"); + frame.render_stateful_widget(list, area, &mut state); +} + +/// One participant row: `Name ` left, counts right. +fn row(participant: &Participant, filter: Option<&str>, width: usize) -> ListItem<'static> { + let active = filter.is_some_and(|email| email.eq_ignore_ascii_case(&participant.author.email)); + let marker = if active { 2 } else { 0 }; + let thread_label = if participant.thread_count == 1 { + "thread" + } else { + "threads" + }; + let message_label = if participant.message_count == 1 { + "msg" + } else { + "msgs" + }; + let meta = format!( + "{} {thread_label} · {} {message_label}", + participant.thread_count, participant.message_count + ); + let meta_len = meta.chars().count(); + let left = format!("{} <{}>", participant.author.name, participant.author.email); + let available = width.saturating_sub(marker + meta_len + 1); + let left = truncate(&left, available); + let pad = width.saturating_sub(marker + left.chars().count() + meta_len); + + let mut spans = Vec::with_capacity(4); + if active { + spans.push(Span::styled("● ", theme::accent())); + } + spans.push(Span::raw(left)); + spans.push(Span::raw(" ".repeat(pad))); + spans.push(Span::styled(meta, theme::muted())); + ListItem::new(Line::from(spans)) +} + +/// Truncate `text` to at most `max` characters, appending `…` when shortened. +fn truncate(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_owned(); + } + if max == 0 { + return String::new(); + } + let mut out: String = text.chars().take(max - 1).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + #[test] + fn truncate_keeps_short_text_and_ellipsizes_long() { + assert_eq!(truncate("short", 10), "short"); + assert_eq!(truncate("abcdef", 4), "abc…"); + assert_eq!(truncate("abcdef", 0), ""); + } + + #[test] + fn overlay_renders_on_degenerate_areas() { + let app = App::new(&Config::default()); + for (width, height) in [(0, 0), (1, 1), (10, 3), (120, 40)] { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &app)) + .expect("overlay draws"); + } + } + + #[test] + fn overlay_shows_search_list_and_active_filter() { + let mut app = App::new(&Config::default()); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &app)) + .expect("overlay draws"); + let text = buffer_text(terminal.backend()); + assert!(text.contains("participants")); + assert!(text.contains("Filter:")); + assert!(text.contains("type to search")); + assert!(text.contains(&format!("{} participants", app.participants().len()))); + + app.participant_filter = app.participants().first().map(|p| p.author.clone()); + terminal + .draw(|frame| render(frame, frame.area(), &app)) + .expect("filtered overlay draws"); + let text = buffer_text(terminal.backend()); + assert!(text.contains("active filter:")); + assert!(text.contains('●')); + } + + fn buffer_text(backend: &TestBackend) -> String { + backend + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect() + } +} diff --git a/src/ui/patch.rs b/src/ui/patch.rs new file mode 100644 index 0000000..14824e3 --- /dev/null +++ b/src/ui/patch.rs @@ -0,0 +1,243 @@ +//! Patch overlay: wide, scrollable diff view of the selected message. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph}; + +use crate::app::App; +use crate::theme; + +/// Added lines (the theme has no diff colors of its own). +const ADDED: Color = Color::Green; +/// Removed lines. +const REMOVED: Color = Color::Red; +/// Share of the frame the overlay covers, in percent. +const OVERLAY_WIDTH: u16 = 90; +const OVERLAY_HEIGHT: u16 = 85; +/// Shown when the selected message carries no diff. +const NO_PATCH: &str = "no patch for this message"; + +/// Render the patch overlay: a centered, wide, scrollable diff view. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + if area.width < 4 || area.height < 4 { + return; + } + let center = centered(area); + if center.width < 4 || center.height < 4 { + return; + } + frame.render_widget(Clear, area); + + let Some(message) = app.selected_message() else { + render_empty(frame, center, " patch "); + return; + }; + let Some(patch) = message.patch() else { + render_empty(frame, center, &format!(" patch · {} ", message.subject)); + return; + }; + + let block = Block::bordered() + .border_style(theme::accent()) + .title(format!(" patch · {} ", message.subject)) + .title_bottom(Line::from(Span::styled( + " j/k scroll · Esc/p close ", + theme::muted(), + ))) + .padding(Padding::horizontal(1)); + let inner = block.inner(center); + if inner.width == 0 || inner.height == 0 { + frame.render_widget(block, center); + return; + } + + let total = patch.lines().count(); + let viewport = usize::from(inner.height); + let max_scroll = total.saturating_sub(viewport); + let offset = usize::from(app.patch_scroll).min(max_scroll); + let position = format!(" line {}/{} ", offset.saturating_add(1), total); + let block = + block.title_bottom(Line::from(Span::styled(position, theme::accent())).right_aligned()); + + let visible = patch + .lines() + .skip(offset) + .map(diff_line) + .collect::>(); + frame.render_widget(Paragraph::new(visible).block(block), center); +} + +/// Centered overlay rectangle covering [`OVERLAY_WIDTH`] by +/// [`OVERLAY_HEIGHT`] percent of `area`. +fn centered(area: Rect) -> Rect { + let [_, row, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Percentage(OVERLAY_HEIGHT), + Constraint::Fill(1), + ]) + .areas(area); + let [_, center, _] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Percentage(OVERLAY_WIDTH), + Constraint::Fill(1), + ]) + .areas(row); + center +} + +/// Overlay frame with a muted placeholder instead of a diff. +fn render_empty(frame: &mut Frame, area: Rect, title: &str) { + let block = Block::bordered() + .border_style(theme::accent()) + .title(title.to_owned()) + .padding(Padding::horizontal(1)); + let paragraph = Paragraph::new(NO_PATCH).style(theme::muted()).block(block); + frame.render_widget(paragraph, area); +} + +/// Style one patch line: headers cyan, additions green, removals red, +/// context muted. +fn diff_line(line: &str) -> Line<'_> { + let head = line.trim_start(); + if head.starts_with("diff --git") + || head.starts_with("index ") + || head.starts_with("---") + || head.starts_with("+++") + || head.starts_with("@@") + { + Line::styled(line, theme::accent()) + } else if line.starts_with('+') { + Line::styled(line, Style::new().fg(ADDED)) + } else if line.starts_with('-') { + Line::styled(line, Style::new().fg(REMOVED)) + } else { + Line::styled(line, theme::muted()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::models::Message; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// Render `app` into a test terminal and return the visible text. + fn render_text(app: &App, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), app)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let mut text = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + text.push_str(buffer.cell((x, y)).map_or(" ", |cell| cell.symbol())); + } + text.push('\n'); + } + text + } + + /// First thread/message pair whose message carries a diff. + fn patched_selection(app: &App) -> (usize, usize) { + app.backend + .threads() + .iter() + .enumerate() + .find_map(|(thread_index, thread)| { + app.backend + .messages(thread.id) + .iter() + .position(Message::has_patch) + .map(|message_index| (thread_index, message_index)) + }) + .expect("mock backend has a patched message") + } + + /// First thread/message pair whose message carries no diff. + fn unpatched_selection(app: &App) -> (usize, usize) { + app.backend + .threads() + .iter() + .enumerate() + .find_map(|(thread_index, thread)| { + app.backend + .messages(thread.id) + .iter() + .position(|message| !message.has_patch()) + .map(|message_index| (thread_index, message_index)) + }) + .expect("mock backend has an unpatched message") + } + + #[test] + fn patch_overlay_renders_diff_footer_and_position() { + let mut app = App::new(&Config::default()); + let (thread, message) = patched_selection(&app); + app.thread_selected = thread; + app.message_selected = message; + assert!(app.has_patch()); + let text = render_text(&app, 100, 30); + assert!(text.contains("diff --git")); + assert!(text.contains("j/k scroll · Esc/p close")); + assert!(text.contains("line 1/")); + assert!(text.contains("patch ·")); + } + + #[test] + fn patch_scroll_advances_and_clamps_to_the_last_line() { + let mut app = App::new(&Config::default()); + let (thread, message) = patched_selection(&app); + app.thread_selected = thread; + app.message_selected = message; + let tail = app + .selected_message() + .and_then(|selected| selected.patch()) + .and_then(|patch| patch.lines().last()) + .expect("patched message has diff lines") + .to_owned(); + + app.patch_scroll = 1; + let text = render_text(&app, 100, 10); + assert!(text.contains("line 2/")); + + app.patch_scroll = u16::MAX; + let text = render_text(&app, 100, 10); + assert!( + text.contains(tail.as_str()), + "last diff line missing: {tail:?}" + ); + assert!(text.contains("Esc/p close")); + } + + #[test] + fn patch_overlay_reports_missing_patch() { + let mut app = App::new(&Config::default()); + let (thread, message) = unpatched_selection(&app); + app.thread_selected = thread; + app.message_selected = message; + assert!(!app.has_patch()); + let text = render_text(&app, 100, 30); + assert!(text.contains("no patch for this message")); + } + + #[test] + fn patch_overlay_reports_no_selection() { + let mut app = App::new(&Config::default()); + app.thread_selected = usize::MAX; + let text = render_text(&app, 100, 30); + assert!(text.contains("no patch for this message")); + } + + #[test] + fn patch_overlay_survives_tiny_areas() { + let app = App::new(&Config::default()); + for (width, height) in [(0, 0), (1, 1), (4, 4), (10, 6)] { + let _ = render_text(&app, width, height); + } + } +} diff --git a/src/ui/preview.rs b/src/ui/preview.rs new file mode 100644 index 0000000..26cb2b4 --- /dev/null +++ b/src/ui/preview.rs @@ -0,0 +1,206 @@ +//! Preview pane: headers and body of the selected message (right column). + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Wrap}; + +use crate::app::{App, Panel}; +use crate::models::{Author, Message}; +use crate::theme; + +/// Header labels rendered with a muted prefix, in display order. +const HEADERS: [&str; 5] = ["From:", "To:", "Cc:", "Date:", "Subject:"]; + +/// Render the selected message: headers fixed on top, then the body with any +/// diff stripped. `app.preview_scroll` shifts the body by wrapped lines. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + if area.width < 2 || area.height < 2 { + return; + } + let block = theme::pane_block(" Preview ", app.focus == Panel::Preview); + let Some(message) = app.selected_message() else { + frame.render_widget( + Paragraph::new("no message selected") + .style(theme::muted()) + .block(block), + area, + ); + return; + }; + + let inner = block.inner(area); + if inner.width == 0 || inner.height == 0 { + frame.render_widget(block, area); + return; + } + + let header_height = u16::try_from(HEADERS.len()) + .unwrap_or(u16::MAX) + .min(inner.height); + let [header_area, body_area] = + Layout::vertical([Constraint::Length(header_height), Constraint::Fill(1)]).areas(inner); + frame.render_widget(Paragraph::new(header_lines(message)), header_area); + + let mut body: Vec = Vec::new(); + if message.has_patch() { + body.push(patch_hint(inner.width)); + body.push(Line::from("")); + } + body.extend(message.body_without_patch().split('\n').map(Line::from)); + + let viewport = usize::from(body_area.height); + let max_scroll = body.len().saturating_sub(viewport); + let offset = usize::from(app.preview_scroll).min(max_scroll); + let scroll = u16::try_from(offset).unwrap_or(u16::MAX); + let paragraph = Paragraph::new(body) + .wrap(Wrap { trim: true }) + .scroll((scroll, 0)); + frame.render_widget(paragraph, body_area); +} + +/// Header lines in display order, each one row tall. +fn header_lines(message: &Message) -> Vec> { + let values = [ + message.author.to_string(), + join_authors(&message.to), + join_authors(&message.cc), + message.date.clone(), + message.subject.clone(), + ]; + HEADERS + .iter() + .zip(values) + .map(|(label, value)| { + Line::from(vec![ + Span::styled(format!("{label:<9} "), theme::muted()), + Span::from(value), + ]) + }) + .collect() +} + +/// Comma-separated `Name ` list. +fn join_authors(authors: &[Author]) -> String { + authors + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +/// Full-width separator announcing that the diff is hidden. +fn patch_hint(width: u16) -> Line<'static> { + const LABEL: &str = " patch hidden · press p to view "; + let label_width = u16::try_from(LABEL.chars().count()).unwrap_or(u16::MAX); + let fill = width.saturating_sub(label_width); + let left = fill / 2; + let right = fill - left; + let text = format!( + "{}{LABEL}{}", + "─".repeat(usize::from(left)), + "─".repeat(usize::from(right)) + ); + Line::from(Span::styled(text, theme::accent())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// Render `app` into a test terminal and return the visible text. + fn render_text(app: &App, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), app)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let mut text = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + text.push_str(buffer.cell((x, y)).map_or(" ", |cell| cell.symbol())); + } + text.push('\n'); + } + text + } + + /// First thread/message pair whose message carries a diff. + fn patched_selection(app: &App) -> (usize, usize) { + app.backend + .threads() + .iter() + .enumerate() + .find_map(|(thread_index, thread)| { + app.backend + .messages(thread.id) + .iter() + .position(Message::has_patch) + .map(|message_index| (thread_index, message_index)) + }) + .expect("mock backend has a patched message") + } + + #[test] + fn preview_shows_headers_without_diff() { + let mut app = App::new(&Config::default()); + let (thread, message) = patched_selection(&app); + app.thread_selected = thread; + app.message_selected = message; + assert!(app.has_patch()); + let text = render_text(&app, 80, 24); + for label in HEADERS { + assert!(text.contains(label), "missing header {label}"); + } + assert!(text.contains("patch hidden")); + assert!(!text.contains("diff --git")); + assert!(!text.contains("@@")); + } + + #[test] + fn preview_scroll_is_clamped_and_never_shows_blank_past_the_end() { + let mut app = App::new(&Config::default()); + let (thread, message) = patched_selection(&app); + app.thread_selected = thread; + app.message_selected = message; + + let top = render_text(&app, 80, 8); + assert!(top.contains("patch hidden")); + assert!(top.contains("From:")); + + app.preview_scroll = 2; + let scrolled = render_text(&app, 80, 8); + assert!(!scrolled.contains("patch hidden")); + + app.preview_scroll = u16::MAX; + let clamped = render_text(&app, 80, 8); + // Row 6 is the single body row between the five header rows and the + // bottom border; the clamp must keep real content in it. + assert!( + clamped + .lines() + .nth(6) + .is_some_and(|row| !row.trim().is_empty()) + ); + assert!(clamped.contains("From:")); + } + + #[test] + fn preview_shows_empty_state_without_a_thread() { + let mut app = App::new(&Config::default()); + app.thread_selected = usize::MAX; + let text = render_text(&app, 60, 12); + assert!(text.contains("no message selected")); + } + + #[test] + fn preview_survives_tiny_areas() { + let app = App::new(&Config::default()); + for (width, height) in [(0, 0), (1, 1), (2, 2), (5, 3)] { + let _ = render_text(&app, width, height); + } + } +} diff --git a/src/ui/sender.rs b/src/ui/sender.rs new file mode 100644 index 0000000..850f339 --- /dev/null +++ b/src/ui/sender.rs @@ -0,0 +1,391 @@ +//! Sender screen: compose form (left) and file picker (right). + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use crate::app::App; +use crate::compose::{Compose, ComposeField, PatchAttachment}; +use crate::theme; +use crate::ui::filepicker::{self, human_size, scroll_offset}; + +/// Columns consumed by the `▸ ` marker and the padded field label. +const FIELD_PREFIX: usize = 11; +/// Rows above the body: `To`, `Cc`, `Subject`, and a separator. +const FIELDS_HEIGHT: u16 = 4; +/// Indent applied to wrapped body lines. +const BODY_INDENT: usize = 2; + +/// Render the Sender screen body: compose form left, file picker right. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let [form_area, picker_area] = + Layout::horizontal([Constraint::Percentage(58), Constraint::Percentage(42)]).areas(area); + render_form(frame, form_area, app); + filepicker::render(frame, picker_area, app); +} + +/// Render the compose form: fields, body, attached patches, and status. +fn render_form(frame: &mut Frame, area: Rect, app: &App) { + let compose = &app.compose; + let block = theme::pane_block(" Compose ", true); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.is_empty() { + return; + } + + let status_height = u16::from(compose.status.is_some()); + let fields_height = FIELDS_HEIGHT.min(inner.height.saturating_sub(status_height)); + let flexible = inner.height - fields_height - status_height; + let desired = u16::try_from(compose.patches.len()) + .unwrap_or(u16::MAX) + .saturating_add(2); + let patches_height = desired.min(flexible.saturating_sub(2)); + let body_height = flexible - patches_height; + + let [fields_area, body_area, patches_area, status_area] = Layout::vertical([ + Constraint::Length(fields_height), + Constraint::Length(body_height), + Constraint::Length(patches_height), + Constraint::Length(status_height), + ]) + .areas(inner); + + render_fields(frame, fields_area, compose); + render_body(frame, body_area, compose); + render_patches(frame, patches_area, compose); + render_status(frame, status_area, compose); +} + +/// Render the single-line `To`, `Cc`, and `Subject` fields. +fn render_fields(frame: &mut Frame, area: Rect, compose: &Compose) { + let width = usize::from(area.width).saturating_sub(FIELD_PREFIX); + let fields = [ + (ComposeField::To, compose.to.as_str()), + (ComposeField::Cc, compose.cc.as_str()), + (ComposeField::Subject, compose.subject.as_str()), + ]; + let mut lines: Vec> = fields + .into_iter() + .map(|(field, value)| { + let focused = compose.field == field; + text_line(field, value, focused.then_some(compose.cursor), width) + }) + .collect(); + lines.push(Line::from("")); + frame.render_widget(Paragraph::new(lines), area); +} + +/// One `label: value` line; a `Some` cursor marks the focused field. +fn text_line( + field: ComposeField, + value: &str, + cursor: Option, + width: usize, +) -> Line<'static> { + let mut spans = label_spans(field, cursor.is_some()); + if let Some(cursor) = cursor { + spans.extend(value_spans(value, cursor, width)); + } else { + let shown: String = value.chars().take(width).collect(); + spans.push(Span::raw(shown)); + } + Line::from(spans) +} + +/// The `▸ ` marker plus the padded label; accent and bold when focused. +fn label_spans(field: ComposeField, focused: bool) -> Vec> { + let marker_style = if focused { + theme::accent() + } else { + Style::default() + }; + let marker = Span::styled(if focused { "▸ " } else { " " }, marker_style); + let label = field.label(); + let label_style = if focused { + theme::accent().add_modifier(Modifier::BOLD) + } else { + theme::muted() + }; + vec![marker, Span::styled(format!("{label:<9}"), label_style)] +} + +/// Value spans for a focused single-line field, windowed so the cursor stays +/// visible; the character under the cursor is drawn in reverse video. +fn value_spans(value: &str, cursor: usize, width: usize) -> Vec> { + let width = width.max(1); + let chars: Vec = value.chars().collect(); + let cursor = cursor.min(chars.len()); + let mut display = chars; + if cursor == display.len() { + display.push(' '); + } + let start = cursor.saturating_add(1).saturating_sub(width); + let end = (start + width).min(display.len()); + let before: String = display[start..cursor].iter().collect(); + let at = display[cursor]; + let after: String = display[cursor + 1..end].iter().collect(); + vec![ + Span::raw(before), + Span::styled(at.to_string(), cursor_style()), + Span::raw(after), + ] +} + +/// Reverse-video accent block used as the text cursor. +const fn cursor_style() -> Style { + theme::accent().add_modifier(Modifier::REVERSED) +} + +/// Render the body label and a wrapped, cursor-aware window of the body text. +fn render_body(frame: &mut Frame, area: Rect, compose: &Compose) { + let focused = compose.field == ComposeField::Body; + let [label_area, text_area] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); + frame.render_widget( + Paragraph::new(Line::from(label_spans(ComposeField::Body, focused))), + label_area, + ); + if text_area.is_empty() { + return; + } + + let width = usize::from(text_area.width) + .saturating_sub(BODY_INDENT) + .max(1); + let (lines, cursor_line, cursor_column) = wrap_body(&compose.body, compose.cursor, width); + let height = usize::from(text_area.height); + let offset = if focused { + scroll_offset(cursor_line, lines.len(), height) + } else { + 0 + }; + let rendered: Vec> = lines + .iter() + .enumerate() + .skip(offset) + .take(height) + .map(|(index, line)| body_line(line, focused && index == cursor_line, cursor_column)) + .collect(); + frame.render_widget(Paragraph::new(rendered), text_area); +} + +/// One indented body line; the cursor line draws a reversed-video cell. +fn body_line(line: &str, cursor_here: bool, cursor_column: usize) -> Line<'static> { + let indent = Span::raw(" ".repeat(BODY_INDENT)); + if !cursor_here { + return Line::from(vec![indent, Span::raw(line.to_owned())]); + } + let chars: Vec = line.chars().collect(); + let column = cursor_column.min(chars.len()); + let before: String = chars[..column].iter().collect(); + let at = chars.get(column).copied().unwrap_or(' '); + let after: String = chars[(column + 1).min(chars.len())..].iter().collect(); + Line::from(vec![ + indent, + Span::raw(before), + Span::styled(at.to_string(), cursor_style()), + Span::raw(after), + ]) +} + +/// Wrap `body` to `width` columns; also return the cursor's line and column. +fn wrap_body(body: &str, cursor: usize, width: usize) -> (Vec, usize, usize) { + let width = width.max(1); + let chars: Vec = body.chars().collect(); + let cursor = cursor.min(chars.len()); + let mut lines: Vec = Vec::new(); + let mut cursor_line = 0; + let mut cursor_column = 0; + let mut offset = 0; + loop { + let mut end = offset; + while end < chars.len() && chars[end] != '\n' { + end += 1; + } + let mut chunks: Vec = Vec::new(); + let mut start = offset; + while start < end { + let stop = (start + width).min(end); + chunks.push(chars[start..stop].iter().collect()); + start = stop; + } + if chunks.is_empty() { + chunks.push(String::new()); + } + if cursor >= offset && cursor <= end { + let column = cursor - offset; + cursor_line = lines.len() + column / width; + cursor_column = column % width; + while chunks.len() <= cursor_line - lines.len() { + chunks.push(String::new()); + } + } + lines.extend(chunks); + if end >= chars.len() { + break; + } + offset = end + 1; + } + (lines, cursor_line, cursor_column) +} + +/// Render the attached-patch list; the selected row is highlighted. +fn render_patches(frame: &mut Frame, area: Rect, compose: &Compose) { + let focused = compose.field == ComposeField::Patches; + let title = format!(" Patches ({}) ", compose.patches.len()); + let block = theme::pane_block(&title, focused); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.is_empty() { + return; + } + if compose.patches.is_empty() { + let hint = Paragraph::new("no patches — press a to add").style(theme::muted()); + frame.render_widget(hint, inner); + return; + } + + let height = usize::from(inner.height); + let offset = scroll_offset(compose.patch_selected, compose.patches.len(), height); + let mut state = ListState::default() + .with_selected(Some( + compose + .patch_selected + .min(compose.patches.len().saturating_sub(1)), + )) + .with_offset(offset); + let list = List::new(compose.patches.iter().map(patch_item)) + .highlight_style(theme::selection()) + .highlight_symbol("▸ "); + frame.render_stateful_widget(list, inner, &mut state); +} + +/// One attached-patch row: filename, subject, and human-readable size. +fn patch_item(patch: &PatchAttachment) -> ListItem<'static> { + ListItem::new(Line::from(vec![ + Span::raw(patch.filename.clone()), + Span::raw(" "), + Span::styled(patch.subject.clone(), theme::muted()), + Span::raw(" "), + Span::styled(human_size(patch.size), theme::muted()), + ])) +} + +/// Render the status line when a status message is set. +fn render_status(frame: &mut Frame, area: Rect, compose: &Compose) { + let Some(status) = &compose.status else { + return; + }; + let line = Line::from(vec![ + Span::styled(" status: ", theme::muted()), + Span::styled(status.as_str(), theme::accent()), + ]); + frame.render_widget(Paragraph::new(line), area); +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use super::*; + use crate::app::Screen; + use crate::config::Config; + + fn sender_app() -> App { + let mut app = App::new(&Config::default()); + app.screen = Screen::Sender; + app + } + + #[test] + fn renders_sender_without_panic_on_tiny_rects() { + let mut app = sender_app(); + for (width, height) in [(1, 1), (2, 2), (3, 4), (10, 3), (20, 6), (100, 30)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + for field in [ + ComposeField::To, + ComposeField::Cc, + ComposeField::Subject, + ComposeField::Body, + ComposeField::Patches, + ] { + app.compose.field = field; + terminal + .draw(|frame| crate::ui::render(frame, &app)) + .unwrap(); + } + } + } + + #[test] + fn renders_form_contents_and_visible_cursor() { + let mut app = sender_app(); + app.compose.field = ComposeField::Subject; + app.compose.subject = "hello".to_owned(); + app.compose.cursor = 1; + app.compose.status = Some("attached patch".to_owned()); + let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &app)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + assert!(text.contains("Compose")); + assert!(text.contains("Subject:")); + assert!(text.contains("Patches")); + assert!(text.contains("j/k")); + assert!(text.contains("attached patch")); + assert!( + buffer + .content() + .iter() + .any(|cell| cell.modifier.contains(Modifier::REVERSED)) + ); + } + + #[test] + fn wrap_body_tracks_cursor_at_end() { + let (lines, line, column) = wrap_body("abcd", 4, 4); + assert_eq!(lines, vec!["abcd", ""]); + assert_eq!((line, column), (1, 0)); + } + + #[test] + fn wrap_body_tracks_cursor_mid_line() { + let (lines, line, column) = wrap_body("abcdef", 6, 4); + assert_eq!(lines, vec!["abcd", "ef"]); + assert_eq!((line, column), (1, 2)); + } + + #[test] + fn wrap_body_handles_empty_and_newlines() { + let (lines, line, column) = wrap_body("", 0, 10); + assert_eq!(lines, vec![""]); + assert_eq!((line, column), (0, 0)); + let (lines, line, column) = wrap_body("a\n\nb", 4, 10); + assert_eq!(lines, vec!["a", "", "b"]); + assert_eq!((line, column), (2, 1)); + } + + #[test] + fn value_spans_windows_around_cursor() { + let spans = value_spans("abcdef", 6, 4); + let text: String = spans.iter().map(|span| span.content.as_ref()).collect(); + assert_eq!(text, "def "); + } + + #[test] + fn value_spans_handles_empty_value() { + let spans = value_spans("", 0, 4); + let text: String = spans.iter().map(|span| span.content.as_ref()).collect(); + assert_eq!(text, " "); + } +} diff --git a/src/ui/threads.rs b/src/ui/threads.rs new file mode 100644 index 0000000..cbc36a6 --- /dev/null +++ b/src/ui/threads.rs @@ -0,0 +1,44 @@ +//! Threads pane: the discussions on the list (left column). + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use crate::app::{App, Panel}; +use crate::theme; +use crate::ui::filepicker::scroll_offset; + +/// Render the thread list. +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let threads = app.threads(); + let title = format!(" Threads ({}) ", threads.len()); + let block = theme::pane_block(title.as_str(), app.focus == Panel::Threads); + + if threads.is_empty() { + frame.render_widget( + Paragraph::new("no threads (backend stub)") + .style(theme::muted()) + .block(block), + area, + ); + return; + } + + let items = threads.iter().map(|thread| { + let meta = Span::styled( + format!(" {} · {}", thread.author.name, thread.date), + theme::muted(), + ); + ListItem::new(vec![Line::from(thread.subject.as_str()), Line::from(meta)]) + }); + let mut state = ListState::default(); + state.select(Some(app.thread_selected)); + let visible = usize::from(block.inner(area).height) / 2; + *state.offset_mut() = scroll_offset(app.thread_selected, threads.len(), visible); + let list = List::new(items) + .block(block) + .highlight_style(theme::selection()) + .highlight_symbol("▍"); + frame.render_stateful_widget(list, area, &mut state); +}