diff --git a/Cargo.lock b/Cargo.lock index c37e97c..5c167e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -23,24 +38,91 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[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.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bech32" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[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.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[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.2" @@ -48,16 +130,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] -name = "bytes" -version = "1.11.1" +name = "by_address" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] -name = "cassowary" -version = "0.3.0" +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "castaway" @@ -84,6 +192,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -97,11 +211,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -111,19 +231,71 @@ dependencies = [ "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 = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "futures-core", "mio", @@ -134,6 +306,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -143,6 +333,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "darling" version = "0.23.0" @@ -163,7 +373,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -174,7 +384,54 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", +] + +[[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" +dependencies = [ + "powerfmt", +] + +[[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.117", +] + +[[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]] @@ -198,6 +455,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.15.0" @@ -226,18 +492,103 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[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 = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[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 = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -294,7 +645,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -326,6 +677,16 @@ dependencies = [ "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.2.17" @@ -337,15 +698,59 @@ dependencies = [ "wasi", ] +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -354,6 +759,12 @@ 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 = "iana-time-zone" version = "0.1.65" @@ -366,7 +777,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -379,14 +790,70 @@ dependencies = [ ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "icy_sixel" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5" +dependencies = [ + "quantette", + "thiserror 2.0.18", +] [[package]] -name = "indoc" -version = "2.0.7" +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ @@ -403,14 +870,14 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -431,12 +898,47 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[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 = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.14" @@ -446,6 +948,15 @@ dependencies = [ "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -458,6 +969,12 @@ 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" @@ -475,11 +992,21 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "hashbrown", + "nix", + "winapi", ] [[package]] @@ -488,6 +1015,37 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[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 = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -500,6 +1058,56 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[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.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -507,6 +1115,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", ] [[package]] @@ -521,6 +1139,54 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[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 = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "bytemuck", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -545,10 +1211,99 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[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 0.8.5", +] + +[[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.117", +] + +[[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" @@ -556,6 +1311,50 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -565,6 +1364,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quantette" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c98fecda8b16396ff9adac67644a523dd1778c42b58606a29df5c31ca925d174" +dependencies = [ + "bitvec", + "bytemuck", + "image", + "libm", + "num-traits", + "ordered-float 5.1.0", + "palette", + "rand 0.9.2", + "rand_xoshiro", + "rayon", + "ref-cast", + "wide", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.44" @@ -574,25 +1405,197 @@ 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 = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags", - "cassowary", + "bitflags 2.11.0", "compact_str", - "crossterm", + "hashbrown 0.16.1", "indoc", - "instability", "itertools", + "kasuari", "lru", - "paste", "strum", + "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-image" +version = "10.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57add959ab80c9a92be620fa6f8e4a64f7c014829250ba78862e8d81a903cb5" +dependencies = [ + "base64-simd", + "icy_sixel", + "image", + "rand 0.8.5", + "ratatui", + "rustix 0.38.44", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", ] [[package]] @@ -601,7 +1604,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -610,9 +1613,67 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", ] [[package]] @@ -621,7 +1682,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -634,7 +1695,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -653,12 +1714,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629516c85c29fe757770fa03f2074cf1eac43d44c02a3de9fc2ef7b0e207dfdd" +dependencies = [ + "bytemuck", +] + [[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.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -686,7 +1762,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -702,6 +1778,17 @@ dependencies = [ "zmij", ] +[[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 = "shlex" version = "1.3.0" @@ -739,6 +1826,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -774,57 +1873,177 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "strum" -version = "0.26.3" +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[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.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "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.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "strum_macros", + "thiserror-impl 2.0.18", ] [[package]] -name = "strum_macros" -version = "0.26.4" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "heck", "proc-macro2", "quote", - "rustversion", - "syn", + "syn 2.0.117", ] [[package]] -name = "syn" -version = "2.0.117" +name = "thiserror-impl" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "time" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ - "thiserror-impl", + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "time-core" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tokio" @@ -851,7 +2070,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -865,6 +2084,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[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" @@ -879,26 +2110,65 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] -name = "unicode-width" -version = "0.2.0" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "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 = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] [[package]] name = "wasi" @@ -906,6 +2176,24 @@ 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.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -938,7 +2226,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -951,6 +2239,118 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[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 4.6.0", + "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 = "which" version = "7.0.3" @@ -963,6 +2363,16 @@ dependencies = [ "winsafe", ] +[[package]] +name = "wide" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ca908d26e4786149c48efcf6c0ea09ab0e06d1fe3c17dc1b4b0f1ca4a7e788" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -985,17 +2395,51 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1006,7 +2450,18 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1017,7 +2472,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1026,6 +2481,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1035,6 +2499,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -1206,6 +2680,94 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wn-tui" version = "0.1.0" @@ -1213,20 +2775,66 @@ dependencies = [ "anyhow", "bech32", "chrono", - "crossterm", + "crossterm 0.28.1", "dirs", "futures", + "image", "ratatui", + "ratatui-image", "serde", "serde_json", "tokio", "tokio-stream", - "unicode-width 0.2.0", + "unicode-width", "which", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 102e727..fbfc8c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -ratatui = "0.29" +ratatui = "0.30" crossterm = { version = "0.28", features = ["event-stream"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } @@ -17,3 +17,5 @@ futures = "0.3" tokio-stream = "0.1" which = "7" bech32 = "0.11" +ratatui-image = { version = "10", default-features = false, features = ["crossterm"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } diff --git a/README.md b/README.md index 72095cf..a0d543a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ![wn-tui](https://blossom.primal.net/ac0d58ade515b45cdc9281f86e2467a9df999394aa28e95ea093cadedf868aaa.png) -A terminal user interface for [WhiteNoise](https://github.com/marmot-protocol/whitenoise), a secure group messenger built on MLS and Nostr. +A terminal user interface for [WhiteNoise](https://github.com/marmot-protocol/whitenoise-rs), a secure group messenger built on MLS and Nostr. -``` +```text +-[Chats]--------+-[Coffee Chat]-------------------------------+ | > Coffee Chat | [10:31] alice: Hey everyone | | Work | [10:32] bob: What's up? | @@ -26,13 +26,12 @@ wn-tui is a pure presentation layer over the WhiteNoise CLI. It spawns `wn` comm ### 1. WhiteNoise daemon and CLI -Build from the [`feat/cli`](https://github.com/marmot-protocol/whitenoise/pull/537) branch of whitenoise-rs: +Build from [whitenoise-rs](https://github.com/marmot-protocol/whitenoise-rs): ```sh -git clone https://github.com/marmot-protocol/whitenoise.git -cd whitenoise -git checkout feat/cli -cargo build --release +git clone https://github.com/marmot-protocol/whitenoise-rs.git +cd whitenoise-rs +cargo build --release --bin wn --features cli --bin wnd ``` This produces two binaries in `target/release/`: diff --git a/src/action.rs b/src/action.rs index 4e35139..53451d2 100644 --- a/src/action.rs +++ b/src/action.rs @@ -30,6 +30,40 @@ pub enum Action { MessageSent, MessageSendError(String), + // Reactions + ReactionSuccess, + ReactionError(String), + MessagesLoaded(Vec), + + // Message deletion + MessageDeleted { + message_id: String, + }, + MessageDeleteError(String), + + // Media upload + MediaUploaded, + MediaUploadError(String), + + // Media + MediaDownloaded { + file_hash: String, + file_path: String, + }, + MediaDownloadFailed { + file_hash: String, + error: String, + }, + MediaImageLoaded { + file_hash: String, + bytes: Vec, + }, + MediaPopupReady { + bytes: Vec, + img_width: u32, + img_height: u32, + }, + // Notifications (streaming) NotificationUpdate(Value), NotificationStreamEnded, @@ -46,8 +80,11 @@ pub enum Action { // Profile ProfileLoaded(Value), + ProfileImageFetched(Vec), ProfileUpdateSuccess(String), ProfileUpdateError(String), + NsecExported(String), + NsecExportError(String), // Settings SettingsLoaded(Value), @@ -58,10 +95,20 @@ pub enum Action { FollowsLoaded(Vec), FollowSuccess(String), FollowError(String), + FollowCheckResult { + pubkey: String, + following: bool, + }, // User search SearchResult(Value), SearchStreamEnded, + UserProfileLoaded(Value), + UserProfileError(String), + + // Account management + LogoutSuccess, + LogoutError(String), // Logs Log(String), @@ -91,6 +138,30 @@ pub enum Effect { account: String, group_id: String, text: String, + reply_to: Option, + }, + + LoadMessages { + account: String, + group_id: String, + }, + + // Reactions + ReactToMessage { + account: String, + group_id: String, + message_id: String, + emoji: String, + }, + UnreactToMessage { + account: String, + group_id: String, + message_id: String, + }, + DeleteMessage { + account: String, + group_id: String, + message_id: String, }, // Group management @@ -108,6 +179,7 @@ pub enum Effect { CreateGroup { account: String, name: String, + members: Vec, }, AddMember { account: String, @@ -144,7 +216,17 @@ pub enum Effect { UpdateProfile { account: String, name: Option, + display_name: Option, about: Option, + picture: Option, + nip05: Option, + lud16: Option, + }, + ExportNsec { + account: String, + }, + FetchProfileImage { + url: String, }, // Settings @@ -170,6 +252,10 @@ pub enum Effect { account: String, pubkey: String, }, + CheckFollow { + account: String, + pubkey: String, + }, // User search SearchUsers { @@ -177,6 +263,34 @@ pub enum Effect { query: String, }, UnsubscribeSearch, + ShowUserProfile { + account: String, + pubkey: String, + }, + + // Account management + Logout { + account: String, + }, + + // Media + DownloadMedia { + account: String, + group_id: String, + file_hash: String, + }, + LoadMediaImage { + file_hash: String, + file_path: String, + }, + LoadMediaPopup { + file_path: String, + }, + UploadMedia { + account: String, + group_id: String, + file_path: String, + }, // Daemon logs TailDaemonLog, diff --git a/src/app.rs b/src/app.rs index 2510af6..ee0d4ee 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::Frame; +use ratatui_image::picker::Picker; +use ratatui_image::protocol::StatefulProtocol; use serde_json::Value; use crate::action::{Action, Effect}; @@ -41,21 +43,52 @@ pub enum Popup { Error { message: String, }, + Info { + title: String, + message: String, + }, + UserProfile { + data: Value, + }, + GroupInfo { + data: Value, + }, + GroupPicker { + pubkey: String, + selected: usize, + }, + ImageViewer { + img_width: u32, + img_height: u32, + }, + FileBrowser { + path: std::path::PathBuf, + entries: Vec<(String, bool)>, // (name, is_dir) + selected: usize, + }, } #[derive(Debug, Clone, PartialEq)] pub enum InputPurpose { CreateGroup, + CreateGroupWithUser { pubkey: String }, AddMember, RenameGroup, EditProfileName, + EditDisplayName, EditProfileAbout, + EditPicture, + EditNip05, + EditLud16, + ReactionEmoji, } #[derive(Debug, Clone, PartialEq)] pub enum ConfirmPurpose { LeaveGroup, RemoveMember { npub: String }, + DeleteMessage { message_id: String }, + Logout { account: String }, } /// Why the user search screen was opened. @@ -65,6 +98,14 @@ pub enum SearchPurpose { AddMember { group_id: String }, } +/// State of a media download. +#[derive(Debug, Clone)] +pub enum MediaDownload { + Downloading, + Downloaded(String), // file_path + Failed(String), // error +} + /// Which log tab is active. #[derive(Debug, Clone, PartialEq)] pub enum LogTab { @@ -90,7 +131,11 @@ pub struct App { pub active_group_id: Option, pub messages: Vec, pub message_scroll: usize, + pub selected_message: Option, + pub message_viewport_height: usize, pub composer: Input, + /// Active reply context: (event_id, author_name, content_preview). + pub reply_to: Option<(String, String, String)>, // Notifications pub unread_counts: HashMap, @@ -110,6 +155,9 @@ pub struct App { // Profile pub profile: Option, + pub picker: Option, + pub profile_image: Option, + pub popup_image: Option, // Settings pub settings_data: Option, @@ -125,6 +173,11 @@ pub struct App { pub search_results: Vec, pub selected_result: usize, pub search_purpose: SearchPurpose, + pub follow_checks: HashMap, + + // Media + pub media_downloads: HashMap, + pub inline_images: HashMap, // Log panel pub show_logs: bool, @@ -153,7 +206,10 @@ impl App { active_group_id: None, messages: Vec::new(), message_scroll: 0, + selected_message: None, + message_viewport_height: 20, // updated each render composer: Input::new(), + reply_to: None, unread_counts: HashMap::new(), connected: false, viewing_group_id: None, @@ -163,6 +219,9 @@ impl App { selected_member: 0, popup: None, profile: None, + picker: None, + profile_image: None, + popup_image: None, settings_data: None, selected_setting: 0, follows: Vec::new(), @@ -171,6 +230,9 @@ impl App { search_results: Vec::new(), selected_result: 0, search_purpose: SearchPurpose::Browse, + follow_checks: HashMap::new(), + media_downloads: HashMap::new(), + inline_images: HashMap::new(), show_logs: false, logs: Vec::new(), daemon_logs: Vec::new(), @@ -226,7 +288,7 @@ impl App { // Message streaming Action::MessageUpdate { group_id, message } => { if self.active_group_id.as_deref() == Some(&group_id) { - self.handle_message_update(message); + return self.handle_message_update(message); } } Action::MessageStreamEnded => {} @@ -239,6 +301,131 @@ impl App { }); } + // Reactions + Action::ReactionSuccess => { + // Reload messages to get updated reaction counts + if let (Some(account), Some(group_id)) = (&self.account, &self.active_group_id) { + return vec![Effect::LoadMessages { + account: account.clone(), + group_id: group_id.clone(), + }]; + } + } + Action::MessagesLoaded(msgs) => { + self.messages = msgs; + let mut effects = Vec::new(); + for msg in &self.messages { + for (hash, _, _) in Self::image_attachments(msg) { + if !self.media_downloads.contains_key(&hash) { + if let (Some(account), Some(group_id)) = + (&self.account, &self.active_group_id) + { + self.media_downloads + .insert(hash.clone(), MediaDownload::Downloading); + effects.push(Effect::DownloadMedia { + account: account.clone(), + group_id: group_id.clone(), + file_hash: hash, + }); + } + } + } + } + if !effects.is_empty() { + return effects; + } + } + Action::ReactionError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Reaction failed: {msg}"), + }); + } + + // Message deletion + Action::MessageDeleted { message_id } => { + self.messages + .retain(|m| m.get("id").and_then(|v| v.as_str()) != Some(&message_id)); + } + Action::MessageDeleteError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Delete failed: {msg}"), + }); + } + + // Media + Action::MediaDownloaded { + file_hash, + file_path, + } => { + self.media_downloads.insert( + file_hash.clone(), + MediaDownload::Downloaded(file_path.clone()), + ); + // Auto-load image for inline display + return vec![Effect::LoadMediaImage { + file_hash, + file_path, + }]; + } + Action::MediaDownloadFailed { file_hash, error } => { + self.logs.push(format!("Media download failed: {error}")); + self.media_downloads + .insert(file_hash, MediaDownload::Failed(error)); + } + Action::MediaImageLoaded { file_hash, bytes } => { + if let Some(picker) = &self.picker { + match image::load_from_memory(&bytes) { + Ok(img) => { + self.logs.push(format!( + "Inline image ready: {} ({}x{}, {} bytes)", + &file_hash[..file_hash.len().min(16)], + img.width(), + img.height(), + bytes.len() + )); + let proto = picker.new_resize_protocol(img); + self.inline_images.insert(file_hash, proto); + } + Err(e) => { + self.logs + .push(format!("Image decode failed for {file_hash}: {e}")); + } + } + } + } + + Action::MediaPopupReady { + bytes, + img_width, + img_height, + } => { + if let Some(picker) = &self.picker { + match image::load_from_memory(&bytes) { + Ok(img) => { + let proto = picker.new_resize_protocol(img); + self.popup_image = Some(proto); + self.popup = Some(Popup::ImageViewer { + img_width, + img_height, + }); + } + Err(e) => { + self.logs.push(format!("Popup image decode failed: {e}")); + } + } + } + } + + Action::MediaUploaded => { + self.logs.push("Media uploaded successfully".to_string()); + } + Action::MediaUploadError(e) => { + self.logs.push(format!("Media upload failed: {e}")); + self.popup = Some(Popup::Error { + message: format!("Upload failed: {e}"), + }); + } + // Notifications Action::NotificationUpdate(val) => { self.handle_notification(val); @@ -267,17 +454,29 @@ impl App { Action::GroupActionSuccess(msg) => { self.status_message = Some(msg.clone()); self.popup = None; - // If we left a group, go back to main + // If we left a group, remove from chat list and go back to main if msg.contains("Left group") { + if let Some(gid) = &self.viewing_group_id { + self.chats + .retain(|c| chat_list::group_id(c).as_ref() != Some(gid)); + if !self.chats.is_empty() { + self.selected_chat = self.selected_chat.min(self.chats.len() - 1); + } else { + self.selected_chat = 0; + } + } self.screen = Screen::Main; self.viewing_group_id = None; + return vec![]; } // Reload group detail if still on that screen if self.screen == Screen::GroupDetail { return self.reload_group_detail(); } - // Re-subscribe to chats to pick up changes + // Re-subscribe to chats to pick up changes (e.g., after accepting invite) if let Some(account) = &self.account { + self.chats.clear(); + self.selected_chat = 0; return vec![Effect::SubscribeChats { account: account.clone(), }]; @@ -291,7 +490,40 @@ impl App { // Profile Action::ProfileLoaded(val) => { + let url = val + .get("picture") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); self.profile = Some(val); + self.profile_image = None; + if let Some(url) = url { + self.logs.push(format!("Fetching profile image: {url}")); + return vec![Effect::FetchProfileImage { url }]; + } + } + Action::ProfileImageFetched(bytes) => { + self.logs + .push(format!("Image received: {} bytes", bytes.len())); + if let Some(picker) = &self.picker { + match image::load_from_memory(&bytes) { + Ok(img) => { + let proto = picker.new_resize_protocol(img); + if matches!(self.popup, Some(Popup::UserProfile { .. })) { + self.popup_image = Some(proto); + } else { + self.profile_image = Some(proto); + } + self.logs.push("Image decoded and protocol created".into()); + } + Err(e) => { + self.logs.push(format!("Image decode failed: {e}")); + } + } + } else { + self.logs + .push("No image picker available (terminal may not support images)".into()); + } } Action::ProfileUpdateSuccess(msg) => { self.status_message = Some(msg); @@ -306,6 +538,17 @@ impl App { message: format!("Error: {msg}"), }); } + Action::NsecExported(nsec) => { + self.popup = Some(Popup::Info { + title: "Show nsec".into(), + message: nsec, + }); + } + Action::NsecExportError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Export nsec failed: {msg}"), + }); + } // Settings Action::SettingsLoaded(val) => { @@ -343,12 +586,58 @@ impl App { Action::FollowError(msg) => { self.popup = Some(Popup::Error { message: msg }); } + Action::FollowCheckResult { pubkey, following } => { + self.follow_checks.insert(pubkey, following); + } // User search Action::SearchResult(val) => { + let pubkey = val + .get("pubkey") + .or_else(|| val.get("npub")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); self.search_results.push(val); + if let (Some(pk), Some(account)) = (pubkey, &self.account) { + if !self.follow_checks.contains_key(&pk) { + return vec![Effect::CheckFollow { + account: account.clone(), + pubkey: pk, + }]; + } + } } Action::SearchStreamEnded => {} + Action::UserProfileLoaded(data) => { + let url = data + .get("metadata") + .and_then(|m| m.get("picture")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.popup_image = None; + self.popup = Some(Popup::UserProfile { data }); + if let Some(url) = url { + self.logs + .push(format!("Fetching user profile image: {url}")); + return vec![Effect::FetchProfileImage { url }]; + } + } + // Account management + Action::LogoutSuccess => { + return self.handle_logout(); + } + Action::LogoutError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Logout failed: {msg}"), + }); + } + + Action::UserProfileError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Error: {msg}"), + }); + } // Logs Action::Log(msg) => { @@ -422,6 +711,58 @@ impl App { ] } + /// Build an `UpdateProfile` effect from the given field values. + fn profile_update( + account: String, + name: Option, + display_name: Option, + about: Option, + picture: Option, + nip05: Option, + lud16: Option, + ) -> Effect { + Effect::UpdateProfile { + account, + name, + display_name, + about, + picture, + nip05, + lud16, + } + } + + /// Reset all account-specific UI state and navigate to the account selector. + /// Does NOT call `wn logout` — just clears in-memory state and re-checks accounts. + fn reset_to_account_select(&mut self) -> Vec { + self.screen = Screen::Login; + self.login_mode = LoginMode::Loading("Checking accounts...".into()); + self.account = None; + self.status_message = None; + self.chats.clear(); + self.messages.clear(); + self.active_group_id = None; + self.unread_counts.clear(); + self.connected = false; + self.popup = None; + self.profile = None; + self.profile_image = None; + self.follows.clear(); + self.follow_checks.clear(); + self.viewing_group_id = None; + self.group_detail = None; + self.group_members.clear(); + self.group_admins.clear(); + self.media_downloads.clear(); + self.inline_images.clear(); + self.reply_to = None; + vec![Effect::CheckAccounts] + } + + fn handle_logout(&mut self) -> Vec { + self.reset_to_account_select() + } + /// Total unread messages across all chats. pub fn total_unread(&self) -> usize { self.unread_counts.values().sum() @@ -435,8 +776,17 @@ impl App { .count() } - /// Check if a pubkey is in the follows list. + fn clear_search_results(&mut self) { + self.search_results.clear(); + self.follow_checks.clear(); + self.selected_result = 0; + } + + /// Check if a pubkey is followed. Uses authoritative check cache first, falls back to local list. pub fn is_following(&self, pubkey: &str) -> bool { + if let Some(&checked) = self.follow_checks.get(pubkey) { + return checked; + } self.follows .iter() .any(|f| f.get("pubkey").and_then(|v| v.as_str()) == Some(pubkey)) @@ -476,6 +826,13 @@ impl App { } fn handle_paste(&mut self, text: &str) { + // Paste into active popup text input if one is open + if let Some(Popup::TextInput { input, .. }) = &mut self.popup { + for ch in text.chars() { + input.insert(ch); + } + return; + } let input = match self.screen { Screen::Login => Some(&mut self.nsec_input), Screen::UserSearch => Some(&mut self.search_input), @@ -489,7 +846,89 @@ impl App { } } - fn handle_message_update(&mut self, val: Value) { + /// Extract image attachments from a message: (file_hash_hex, mime_type, filename). + /// + /// CLI attachment fields (verified 2026-03-07): + /// `original_file_hash` — hex string or byte vec + /// `mime_type` — e.g. "image/jpeg" + /// `file_metadata` — may contain filename + /// `blossom_url` — fallback for filename extraction + pub fn image_attachments(msg: &Value) -> Vec<(String, String, String)> { + let attachments = match msg.get("media_attachments").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => return vec![], + }; + let mut result = Vec::new(); + for att in attachments { + let mime = att.get("mime_type").and_then(|v| v.as_str()).unwrap_or(""); + if !mime.starts_with("image/") { + continue; + } + // Extract filename: try file_metadata.original_filename, then filename, then blossom_url basename + let filename = att + .get("file_metadata") + .and_then(|m| m.get("original_filename").or_else(|| m.get("filename"))) + .and_then(|v| v.as_str()) + .or_else(|| att.get("filename").and_then(|v| v.as_str())) + .map(|s| s.to_string()) + .or_else(|| { + att.get("blossom_url") + .and_then(|v| v.as_str()) + .and_then(|url| url.rsplit('/').next()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "image".to_string()); + // Hash: try original_file_hash first, then file_hash, then encrypted_file_hash + let hash_hex = crate::util::extract_hash_hex(att, "original_file_hash") + .or_else(|| crate::util::extract_hash_hex(att, "file_hash")) + .or_else(|| crate::util::extract_hash_hex(att, "encrypted_file_hash")); + let hash_hex = match hash_hex { + Some(h) if !h.is_empty() => h, + _ => continue, + }; + result.push((hash_hex, mime.to_string(), filename)); + } + result + } + + /// Emit download effects for image attachments not already tracked. + fn download_effects_for_message(&mut self, msg: &Value) -> Vec { + let (account, group_id) = match (&self.account, &self.active_group_id) { + (Some(a), Some(g)) => (a.clone(), g.clone()), + _ => return vec![], + }; + let attachments = Self::image_attachments(msg); + if !attachments.is_empty() { + self.logs.push(format!( + "Auto-download: found {} image attachment(s)", + attachments.len() + )); + } + let mut effects = Vec::new(); + for (hash, _, filename) in attachments { + if !self.media_downloads.contains_key(&hash) { + self.logs + .push(format!("Auto-download: queuing {filename} (hash={hash})")); + self.media_downloads + .insert(hash.clone(), MediaDownload::Downloading); + effects.push(Effect::DownloadMedia { + account: account.clone(), + group_id: group_id.clone(), + file_hash: hash, + }); + } + } + effects + } + + fn handle_message_update(&mut self, val: Value) -> Vec { + // Only display chat messages (kind 9), skip reactions (7), deletions (5), etc. + if let Some(kind) = val.get("kind").and_then(|v| v.as_u64()) { + if kind != 9 { + return vec![]; + } + } + // Deduplicate by message id if let Some(id) = val.get("id").and_then(|v| v.as_str()) { if self @@ -497,14 +936,16 @@ impl App { .iter() .any(|m| m.get("id").and_then(|v| v.as_str()) == Some(id)) { - return; + return vec![]; } } + let effects = self.download_effects_for_message(&val); let was_at_bottom = self.message_scroll == 0; self.messages.push(val); if !was_at_bottom { self.message_scroll += 1; } + effects } fn handle_key(&mut self, key: KeyEvent) -> Vec { @@ -531,8 +972,9 @@ impl App { return vec![]; } - // Tab switches log tabs when log panel is visible - if key.code == KeyCode::Tab && self.show_logs && self.screen == Screen::Main { + // Tab switches log tabs when log panel is visible (on screens where Tab isn't used) + if key.code == KeyCode::Tab && self.show_logs && !matches!(self.screen, Screen::UserSearch) + { self.log_tab = match self.log_tab { LogTab::Activity => LogTab::Daemon, LogTab::Daemon => LogTab::Activity, @@ -637,14 +1079,144 @@ impl App { } _ => vec![], }, - Popup::Help { .. } | Popup::Error { .. } => { - // Any key dismisses help/error popups + Popup::GroupPicker { pubkey, selected } => match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !self.chats.is_empty() { + *selected = (*selected + 1).min(self.chats.len() - 1); + } + vec![] + } + KeyCode::Char('k') | KeyCode::Up => { + *selected = selected.saturating_sub(1); + vec![] + } + KeyCode::Enter => { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + let chat = self.chats.get(*selected).cloned(); + let group_id = chat.as_ref().and_then(crate::widget::chat_list::group_id); + let pubkey = pubkey.clone(); + self.popup = None; + match group_id { + Some(gid) => vec![Effect::AddMember { + account, + group_id: gid, + npub: pubkey, + }], + None => vec![], + } + } + KeyCode::Esc => { + self.popup = None; + vec![] + } + _ => vec![], + }, + Popup::FileBrowser { + path, + entries, + selected, + } => match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !entries.is_empty() { + *selected = (*selected + 1).min(entries.len() - 1); + } + vec![] + } + KeyCode::Char('k') | KeyCode::Up => { + *selected = selected.saturating_sub(1); + vec![] + } + KeyCode::Char('g') => { + *selected = 0; + vec![] + } + KeyCode::Char('G') => { + if !entries.is_empty() { + *selected = entries.len() - 1; + } + vec![] + } + KeyCode::Char('~') => { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")); + *entries = Self::scan_dir(&home); + *path = home; + *selected = 0; + vec![] + } + KeyCode::Enter => { + if let Some((name, is_dir)) = entries.get(*selected).cloned() { + if is_dir { + let new_path = path.join(&name); + *entries = Self::scan_dir(&new_path); + *path = new_path; + *selected = 0; + } else { + let file_path = path.join(&name).to_string_lossy().to_string(); + self.popup = None; + if let (Some(account), Some(group_id)) = + (&self.account, &self.active_group_id) + { + return vec![Effect::UploadMedia { + account: account.clone(), + group_id: group_id.clone(), + file_path, + }]; + } + } + } + vec![] + } + KeyCode::Backspace | KeyCode::Char('h') => { + if let Some(parent) = path.parent().map(|p| p.to_path_buf()) { + *entries = Self::scan_dir(&parent); + *path = parent; + *selected = 0; + } + vec![] + } + KeyCode::Esc => { + self.popup = None; + vec![] + } + _ => vec![], + }, + Popup::Help { .. } + | Popup::Error { .. } + | Popup::Info { .. } + | Popup::UserProfile { .. } + | Popup::GroupInfo { .. } + | Popup::ImageViewer { .. } => { + // Any key dismisses help/error/info/profile/group info/image viewer popups + self.popup_image = None; self.popup = None; vec![] } } } + fn scan_dir(path: &std::path::Path) -> Vec<(String, bool)> { + let mut entries = Vec::new(); + if let Ok(read_dir) = std::fs::read_dir(path) { + for entry in read_dir.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + entries.push((name, is_dir)); + } + } + entries.sort_by(|a, b| match (a.1, b.1) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.0.to_lowercase().cmp(&b.0.to_lowercase()), + }); + entries + } + fn submit_text_input(&mut self, purpose: InputPurpose, value: String) -> Vec { let account = match &self.account { Some(a) => a.clone(), @@ -656,6 +1228,14 @@ impl App { vec![Effect::CreateGroup { account, name: value, + members: vec![], + }] + } + InputPurpose::CreateGroupWithUser { pubkey } => { + vec![Effect::CreateGroup { + account, + name: value, + members: vec![pubkey], }] } InputPurpose::AddMember => { @@ -681,43 +1261,121 @@ impl App { }] } InputPurpose::EditProfileName => { - vec![Effect::UpdateProfile { + vec![Self::profile_update( account, - name: Some(value), - about: None, - }] + Some(value), + None, + None, + None, + None, + None, + )] + } + InputPurpose::EditDisplayName => { + vec![Self::profile_update( + account, + None, + Some(value), + None, + None, + None, + None, + )] } InputPurpose::EditProfileAbout => { - vec![Effect::UpdateProfile { + vec![Self::profile_update( account, - name: None, - about: Some(value), - }] + None, + None, + Some(value), + None, + None, + None, + )] + } + InputPurpose::EditPicture => { + vec![Self::profile_update( + account, + None, + None, + None, + Some(value), + None, + None, + )] + } + InputPurpose::EditNip05 => { + vec![Self::profile_update( + account, + None, + None, + None, + None, + Some(value), + None, + )] + } + InputPurpose::EditLud16 => { + vec![Self::profile_update( + account, + None, + None, + None, + None, + None, + Some(value), + )] + } + InputPurpose::ReactionEmoji => { + if value.is_empty() { + return vec![]; + } + self.react_to_selected(&value) } } } fn submit_confirm(&mut self, purpose: ConfirmPurpose) -> Vec { + if let ConfirmPurpose::Logout { account } = purpose { + return vec![Effect::Logout { account }]; + } + let account = match &self.account { Some(a) => a.clone(), None => return vec![], }; - let group_id = match &self.viewing_group_id { - Some(g) => g.clone(), - None => return vec![], - }; match purpose { + ConfirmPurpose::DeleteMessage { message_id } => { + let group_id = match &self.active_group_id { + Some(g) => g.clone(), + None => return vec![], + }; + vec![Effect::DeleteMessage { + account, + group_id, + message_id, + }] + } ConfirmPurpose::LeaveGroup => { + let group_id = match &self.viewing_group_id { + Some(g) => g.clone(), + None => return vec![], + }; vec![Effect::LeaveGroup { account, group_id }] } ConfirmPurpose::RemoveMember { npub } => { + let group_id = match &self.viewing_group_id { + Some(g) => g.clone(), + None => return vec![], + }; vec![Effect::RemoveMember { account, group_id, npub, }] } + ConfirmPurpose::Logout { .. } => unreachable!(), } } @@ -850,11 +1508,20 @@ impl App { KeyCode::Char('/') => { self.screen = Screen::UserSearch; self.search_input.clear(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); self.search_purpose = SearchPurpose::Browse; vec![] } + KeyCode::Char('C') => { + self.login_mode = LoginMode::Loading("Creating identity...".into()); + self.screen = Screen::Login; + vec![Effect::CreateIdentity] + } + KeyCode::Char('A') => { + // Switch account — reset UI state and go to account selector + // (does NOT call wn logout — just navigates to the picker) + self.reset_to_account_select() + } KeyCode::Char('q') => { self.running = false; vec![] @@ -894,56 +1561,257 @@ impl App { ] } - fn handle_messages_key(&mut self, key: KeyEvent) -> Vec { - match key.code { - KeyCode::Char('k') | KeyCode::Up => { - let max = self.messages.len().saturating_sub(1); - self.message_scroll = (self.message_scroll + 1).min(max); - vec![] - } - KeyCode::Char('j') | KeyCode::Down => { - self.message_scroll = self.message_scroll.saturating_sub(1); - vec![] - } - KeyCode::Char('G') => { - self.message_scroll = 0; - vec![] - } - KeyCode::Char('i') | KeyCode::Enter => { - self.focus = Panel::Composer; - vec![] - } - KeyCode::Tab => { - self.focus = Panel::ChatList; - vec![] - } - KeyCode::Esc => { - self.focus = Panel::ChatList; - vec![] - } - _ => vec![], + /// Currently selected message index, defaulting to the newest. + fn selected_msg_index(&self) -> Option { + if self.messages.is_empty() { + return None; + } + match self.selected_message { + Some(idx) => Some(idx.min(self.messages.len() - 1)), + None => Some(self.messages.len() - 1), } } - fn handle_composer_key(&mut self, key: KeyEvent) -> Vec { - match key.code { + /// Nudge viewport scroll by 1 if the selection moved outside the visible range. + fn scroll_to_follow(&mut self, direction: isize) { + let sel = match self.selected_msg_index() { + Some(i) => i, + None => return, + }; + let last = self.messages.len().saturating_sub(1); + let bottom_visible = last.saturating_sub(self.message_scroll); + + if direction > 0 && sel > bottom_visible { + // Selection below viewport — scroll down by 1 + self.message_scroll = self.message_scroll.saturating_sub(1); + } else if direction < 0 { + // Selection moved up — scroll up only if selection is no longer rendered. + // The viewport fits ~message_viewport_height rows. Assume each message + // averages 1 row (short messages). If selection is more than viewport_height + // messages above the bottom, it's likely off-screen. + let distance_from_bottom = bottom_visible.saturating_sub(sel); + if distance_from_bottom >= self.message_viewport_height { + self.message_scroll += 1; + } + } + } + + fn react_to_selected(&self, emoji: &str) -> Vec { + let (account, group_id) = match (&self.account, &self.active_group_id) { + (Some(a), Some(g)) => (a.clone(), g.clone()), + _ => return vec![], + }; + let msg_idx = match self.selected_msg_index() { + Some(i) => i, + None => return vec![], + }; + let message_id = match self.messages[msg_idx].get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => return vec![], + }; + vec![Effect::ReactToMessage { + account, + group_id, + message_id, + emoji: emoji.to_string(), + }] + } + + fn unreact_to_selected(&self) -> Vec { + let (account, group_id) = match (&self.account, &self.active_group_id) { + (Some(a), Some(g)) => (a.clone(), g.clone()), + _ => return vec![], + }; + let msg_idx = match self.selected_msg_index() { + Some(i) => i, + None => return vec![], + }; + let message_id = match self.messages[msg_idx].get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => return vec![], + }; + vec![Effect::UnreactToMessage { + account, + group_id, + message_id, + }] + } + + fn delete_selected_message(&mut self) -> Vec { + let msg_idx = match self.selected_msg_index() { + Some(i) => i, + None => return vec![], + }; + let msg = &self.messages[msg_idx]; + // Only allow deleting own messages + let author = msg.get("author").and_then(|v| v.as_str()).unwrap_or(""); + if self.account.as_deref() != Some(author) { + return vec![]; + } + let message_id = match msg.get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => return vec![], + }; + let preview = msg + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .chars() + .take(40) + .collect::(); + self.popup = Some(Popup::Confirm { + title: "Delete Message".into(), + message: format!("Delete \"{preview}\"?"), + purpose: ConfirmPurpose::DeleteMessage { message_id }, + }); + vec![] + } + + fn handle_messages_key(&mut self, key: KeyEvent) -> Vec { + match key.code { + KeyCode::Char('k') | KeyCode::Up => { + // Move selection up (toward older messages) + if !self.messages.is_empty() { + let current = self.selected_msg_index().unwrap_or(self.messages.len() - 1); + self.selected_message = Some(current.saturating_sub(1)); + self.scroll_to_follow(-1); + } + vec![] + } + KeyCode::Char('j') | KeyCode::Down => { + // Move selection down (toward newer messages) + if !self.messages.is_empty() { + let current = self.selected_msg_index().unwrap_or(self.messages.len() - 1); + let max = self.messages.len() - 1; + self.selected_message = Some((current + 1).min(max)); + self.scroll_to_follow(1); + } + vec![] + } + KeyCode::Char('G') => { + // Jump to newest message + if !self.messages.is_empty() { + self.selected_message = Some(self.messages.len() - 1); + self.message_scroll = 0; + } + vec![] + } + KeyCode::Char('g') => { + // Jump to oldest message + if !self.messages.is_empty() { + self.selected_message = Some(0); + self.message_scroll = self.messages.len().saturating_sub(1); + } + vec![] + } + KeyCode::Char('r') => { + // Open emoji input popup, pre-filled with default reaction + let mut input = Input::new(); + input.insert('+'); + self.popup = Some(Popup::TextInput { + title: "React with emoji".into(), + input, + purpose: InputPurpose::ReactionEmoji, + }); + vec![] + } + KeyCode::Char('o') => { + // Open first downloaded image attachment in full-size popup + if let Some(idx) = self.selected_msg_index() { + if let Some(msg) = self.messages.get(idx) { + for (hash, _, _) in Self::image_attachments(msg) { + if let Some(MediaDownload::Downloaded(path)) = + self.media_downloads.get(&hash) + { + return vec![Effect::LoadMediaPopup { + file_path: path.clone(), + }]; + } + } + } + } + vec![] + } + KeyCode::Char('u') => self.unreact_to_selected(), + KeyCode::Char('d') => self.delete_selected_message(), + KeyCode::Char('U') => { + if self.active_group_id.is_some() { + let start = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")); + let entries = Self::scan_dir(&start); + self.popup = Some(Popup::FileBrowser { + path: start, + entries, + selected: 0, + }); + } + vec![] + } + KeyCode::Char('R') => { + // Reply to selected message + if let Some(idx) = self.selected_msg_index() { + if let Some(msg) = self.messages.get(idx) { + let event_id = msg.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if event_id.is_empty() { + return vec![]; + } + let author = msg + .get("display_name") + .or_else(|| msg.get("author_name")) + .or_else(|| msg.get("author")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let preview: String = msg + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .chars() + .take(30) + .collect(); + self.reply_to = Some((event_id.to_string(), author.to_string(), preview)); + self.focus = Panel::Composer; + } + } + vec![] + } + KeyCode::Char('i') | KeyCode::Enter => { + self.focus = Panel::Composer; + vec![] + } + KeyCode::Tab => { + self.focus = Panel::ChatList; + vec![] + } + KeyCode::Esc => { + self.focus = Panel::ChatList; + vec![] + } + _ => vec![], + } + } + + fn handle_composer_key(&mut self, key: KeyEvent) -> Vec { + match key.code { KeyCode::Enter => { if !self.composer.is_empty() { if let (Some(account), Some(group_id)) = (&self.account, &self.active_group_id) { let text = self.composer.value.clone(); + let reply_to = self.reply_to.as_ref().map(|(id, _, _)| id.clone()); let effect = Effect::SendMessage { account: account.clone(), group_id: group_id.clone(), text, + reply_to, }; self.composer.clear(); + self.reply_to = None; return vec![effect]; } } vec![] } KeyCode::Esc => { + self.reply_to = None; self.focus = Panel::Messages; vec![] } @@ -1002,7 +1870,11 @@ impl App { self.active_group_id = Some(group_id.clone()); self.messages.clear(); + self.media_downloads.clear(); + self.inline_images.clear(); + self.reply_to = None; self.message_scroll = 0; + self.selected_message = None; self.focus = Panel::Messages; self.unread_counts.remove(&group_id); @@ -1042,8 +1914,7 @@ impl App { }; self.screen = Screen::UserSearch; self.search_input.clear(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); self.search_purpose = SearchPurpose::AddMember { group_id }; vec![] } @@ -1055,6 +1926,14 @@ impl App { }); vec![] } + KeyCode::Char('i') => { + if let Some(detail) = &self.group_detail { + self.popup = Some(Popup::GroupInfo { + data: detail.clone(), + }); + } + vec![] + } KeyCode::Char('x') => self.confirm_remove_member(), KeyCode::Char('R') => { let current_name = self @@ -1127,6 +2006,21 @@ impl App { // ── Profile key handling ───────────────────────────────────────── + fn open_profile_edit(&mut self, title: &str, field: &str, purpose: InputPurpose) { + if let Some(profile) = &self.profile { + let current = profile.get(field).and_then(|v| v.as_str()).unwrap_or(""); + let mut input = Input::new(); + for ch in current.chars() { + input.insert(ch); + } + self.popup = Some(Popup::TextInput { + title: title.into(), + input, + purpose, + }); + } + } + fn handle_profile_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::Esc => { @@ -1135,45 +2029,58 @@ impl App { vec![] } KeyCode::Char('n') => { - if self.profile.is_some() { - let current = self - .profile - .as_ref() - .and_then(|p| p.get("name").or_else(|| p.get("display_name"))) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut input = Input::new(); - for ch in current.chars() { - input.insert(ch); - } - self.popup = Some(Popup::TextInput { - title: "Edit Name".into(), - input, - purpose: InputPurpose::EditProfileName, - }); - } + self.open_profile_edit("Edit Name", "name", InputPurpose::EditProfileName); + vec![] + } + KeyCode::Char('D') => { + self.open_profile_edit( + "Edit Display Name", + "display_name", + InputPurpose::EditDisplayName, + ); vec![] } KeyCode::Char('a') => { - if self.profile.is_some() { - let current = self - .profile - .as_ref() - .and_then(|p| p.get("about")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut input = Input::new(); - for ch in current.chars() { - input.insert(ch); - } - self.popup = Some(Popup::TextInput { - title: "Edit About".into(), - input, - purpose: InputPurpose::EditProfileAbout, - }); - } + self.open_profile_edit("Edit About", "about", InputPurpose::EditProfileAbout); + vec![] + } + KeyCode::Char('P') => { + self.open_profile_edit("Edit Picture URL", "picture", InputPurpose::EditPicture); + vec![] + } + KeyCode::Char('5') => { + self.open_profile_edit("Edit NIP-05", "nip05", InputPurpose::EditNip05); + vec![] + } + KeyCode::Char('$') => { + self.open_profile_edit("Edit Lightning Address", "lud16", InputPurpose::EditLud16); vec![] } + KeyCode::Char('e') => { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + vec![Effect::ExportNsec { account }] + } + KeyCode::Enter => { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + let pubkey = self + .follows + .get(self.selected_follow) + .and_then(|f| f.get("pubkey").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + match pubkey { + Some(pk) => vec![Effect::ShowUserProfile { + account, + pubkey: pk, + }], + None => vec![], + } + } KeyCode::Char('j') | KeyCode::Down => { if !self.follows.is_empty() { self.selected_follow = (self.selected_follow + 1).min(self.follows.len() - 1); @@ -1203,6 +2110,18 @@ impl App { None => vec![], } } + KeyCode::Char('Q') => { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + self.popup = Some(Popup::Confirm { + title: "Logout".into(), + message: "Log out of this account? (y/n)".into(), + purpose: ConfirmPurpose::Logout { account }, + }); + vec![] + } _ => vec![], } } @@ -1231,8 +2150,7 @@ impl App { }; self.screen = back_screen; self.search_input.clear(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); vec![Effect::UnsubscribeSearch] } KeyCode::Enter => { @@ -1256,8 +2174,7 @@ impl App { // Go back to group detail self.screen = Screen::GroupDetail; self.search_input.clear(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); return vec![ Effect::UnsubscribeSearch, Effect::AddMember { @@ -1276,8 +2193,7 @@ impl App { None => return vec![], }; let query = self.search_input.value.clone(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); vec![Effect::SearchUsers { account, query }] } else { vec![] @@ -1295,6 +2211,45 @@ impl App { vec![] } KeyCode::Tab => self.toggle_follow_selected(), + KeyCode::F(2) => { + // Show selected user's profile + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + match self.selected_search_pubkey() { + Some(pk) => vec![Effect::ShowUserProfile { + account, + pubkey: pk, + }], + None => vec![], + } + } + KeyCode::F(3) => { + // Create group with selected user + let pubkey = self.selected_search_pubkey(); + if let Some(pk) = pubkey { + self.popup = Some(Popup::TextInput { + title: "Create Group with User".into(), + input: Input::new(), + purpose: InputPurpose::CreateGroupWithUser { pubkey: pk }, + }); + } + vec![] + } + KeyCode::F(4) => { + // Add selected user to existing group + let pubkey = self.selected_search_pubkey(); + if let Some(pk) = pubkey { + if !self.chats.is_empty() { + self.popup = Some(Popup::GroupPicker { + pubkey: pk, + selected: 0, + }); + } + } + vec![] + } KeyCode::Char(ch) => { self.search_input.insert(ch); vec![] @@ -1327,6 +2282,17 @@ impl App { } } + fn selected_search_pubkey(&self) -> Option { + self.search_results + .get(self.selected_result) + .and_then(|u| { + u.get("pubkey") + .or_else(|| u.get("npub")) + .and_then(|v| v.as_str()) + }) + .map(|s| s.to_string()) + } + fn toggle_follow_selected(&mut self) -> Vec { let account = match &self.account { Some(a) => a.clone(), @@ -1346,8 +2312,10 @@ impl App { }; if self.is_following(&pubkey) { + self.follow_checks.insert(pubkey.clone(), false); vec![Effect::UnfollowUser { account, pubkey }] } else { + self.follow_checks.insert(pubkey.clone(), true); vec![Effect::FollowUser { account, pubkey }] } } @@ -1376,6 +2344,38 @@ impl App { vec![] } } + KeyCode::Char('c') => { + self.login_mode = LoginMode::Loading("Creating identity...".into()); + self.status_message = None; + vec![Effect::CreateIdentity] + } + KeyCode::Char('d') => { + let idx = *selected; + if let Some(account_id) = accounts.get(idx).and_then(extract_account_id) { + let name = accounts + .get(idx) + .and_then(|a| { + a.get("display_name") + .or_else(|| a.get("name")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("this account"); + self.popup = Some(Popup::Confirm { + title: "Logout".into(), + message: format!("Log out of \"{name}\"? (y/n)"), + purpose: ConfirmPurpose::Logout { + account: account_id, + }, + }); + } + vec![] + } + KeyCode::Char('l') => { + self.login_mode = LoginMode::NsecInput; + self.nsec_input.clear(); + self.status_message = None; + vec![] + } KeyCode::Char('q') => { self.running = false; vec![] @@ -1454,24 +2454,50 @@ impl App { // ── Drawing ────────────────────────────────────────────────────── - pub fn draw(&self, frame: &mut Frame) { - let area = frame.area(); + pub fn draw(&mut self, frame: &mut Frame) { + use ratatui::layout::{Constraint, Layout}; + + let full_area = frame.area(); + + // Split for log panel (shown on non-main screens; main screen handles its own) + let show_global_logs = self.show_logs && self.screen != Screen::Main; + let log_height = if show_global_logs { + (full_area.height / 3).max(5) + } else { + 0 + }; + let vertical = Layout::vertical([Constraint::Fill(1), Constraint::Length(log_height)]) + .split(full_area); + let area = vertical[0]; + + // Update message viewport height estimate for scroll calculations + if self.screen == Screen::Main { + // full_area minus: log panel, hints (1), status bar (1), borders (2), composer (~5) + let chrome = log_height + 1 + 1 + 2 + 5; + self.message_viewport_height = + (full_area.height.saturating_sub(chrome) as usize).max(3); + } + match &self.screen { Screen::Login => crate::screen::login::draw(self, frame, area), - Screen::Main => crate::screen::main_screen::draw(self, frame, area), + Screen::Main => crate::screen::main_screen::draw(self, frame, full_area), Screen::GroupDetail => crate::screen::group_detail::draw(self, frame, area), Screen::Profile => crate::screen::profile::draw(self, frame, area), Screen::Settings => crate::screen::settings::draw(self, frame, area), Screen::UserSearch => crate::screen::user_search::draw(self, frame, area), } + if show_global_logs { + crate::screen::main_screen::draw_log_panel(self, frame, vertical[1]); + } + // Popup overlay (renders on top of any screen) - if let Some(popup) = &self.popup { - self.draw_popup(popup, frame, area); + if let Some(popup) = self.popup.clone() { + self.draw_popup(&popup, frame, full_area); } } - fn draw_popup(&self, popup: &Popup, frame: &mut Frame, area: ratatui::layout::Rect) { + fn draw_popup(&mut self, popup: &Popup, frame: &mut Frame, area: ratatui::layout::Rect) { use crate::widget::popup::PopupWidget; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; @@ -1519,6 +2545,225 @@ impl App { .size(55, 8); frame.render_widget(widget, area); } + Popup::Info { title, message } => { + let widget = PopupWidget::new(title) + .body(vec![ + Line::raw(""), + Line::from(Span::styled( + message.as_str(), + Style::default().fg(Color::Yellow), + )), + ]) + .hints(vec![("Any key", "Dismiss")]) + .size(70, 8); + frame.render_widget(widget, area); + } + Popup::UserProfile { data } => { + use crate::widget::popup::centered_rect; + use ratatui::widgets::{Paragraph, Wrap}; + + let meta = data.get("metadata"); + let f = |key: &str| -> &str { + meta.and_then(|m| m.get(key)) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("(not set)") + }; + let pubkey = data.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""); + let npub = if pubkey.is_empty() { + "(unknown)".to_string() + } else { + hex_to_npub(pubkey) + }; + let label = Style::default().fg(Color::DarkGray); + let val = Style::default().fg(Color::White); + + let has_image = self.popup_image.is_some(); + let img_cols = if has_image { 20u16 } else { 0 }; + let popup_width = if has_image { 90 } else { 70 }; + + let body_lines = vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(" Name: ", label), + Span::styled(f("name"), val), + ]), + Line::from(vec![ + Span::styled(" Display Name: ", label), + Span::styled(f("display_name"), val), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" About: ", label), + Span::styled(f("about"), val), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" Picture: ", label), + Span::styled(f("picture"), val), + ]), + Line::from(vec![ + Span::styled(" Website: ", label), + Span::styled(f("website"), val), + ]), + Line::from(vec![ + Span::styled(" NIP-05: ", label), + Span::styled(f("nip05"), val), + ]), + Line::from(vec![ + Span::styled(" Lightning: ", label), + Span::styled(f("lud16"), val), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" npub: ", label), + Span::styled(&npub, val), + ]), + ]; + let body_height = body_lines.len() as u16; + let height = (body_height + 4).min(22); + + // Render popup frame with empty body — we'll place text manually + let widget = PopupWidget::new("User Profile") + .hints(vec![("Any key", "Close")]) + .size(popup_width, height); + frame.render_widget(widget, area); + + // Compute popup inner area for manual content placement + let popup_area = centered_rect(popup_width, height, area); + let inner = ratatui::layout::Rect::new( + popup_area.x + 1, + popup_area.y + 1, + popup_area.width.saturating_sub(2), + popup_area.height.saturating_sub(2), + ); + + // Text area: offset by image columns so wrapping stays in bounds + let text_area = ratatui::layout::Rect::new( + inner.x + img_cols, + inner.y, + inner.width.saturating_sub(img_cols), + inner.height.saturating_sub(1), // leave room for hints + ); + frame.render_widget( + Paragraph::new(body_lines).wrap(Wrap { trim: false }), + text_area, + ); + + // Render image on the left side + if let Some(proto) = &mut self.popup_image { + use ratatui_image::StatefulImage; + let img_h = inner.height.saturating_sub(2); + if img_cols > 0 && img_h > 0 { + let img_area = ratatui::layout::Rect::new( + inner.x, + inner.y + 1, // skip the empty first line + img_cols, + img_h, + ); + frame.render_stateful_widget(StatefulImage::default(), img_area, proto); + } + } + } + Popup::GroupInfo { data } => { + let label = Style::default().fg(Color::DarkGray); + let val = Style::default().fg(Color::White); + + let name = data + .get("name") + .or_else(|| data.get("group_name")) + .and_then(|v| v.as_str()) + .unwrap_or("(unknown)"); + let description = data + .get("description") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("(none)"); + let state = data + .get("state") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let epoch = data + .get("epoch") + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_else(|| "?".into()); + + let to_hex_display = |val: &Value| -> String { + crate::util::value_to_hex(val).unwrap_or_else(|| "(unavailable)".into()) + }; + let mls_id = data + .get("mls_group_id") + .map(to_hex_display) + .unwrap_or_else(|| "(unavailable)".into()); + let nostr_id = data + .get("nostr_group_id") + .map(to_hex_display) + .unwrap_or_else(|| "(unavailable)".into()); + + let admin_count = data + .get("admin_pubkeys") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + + let last_msg = data + .get("last_message_at") + .and_then(|v| v.as_i64()) + .map(|ts| { + chrono::DateTime::from_timestamp(ts, 0) + .map(|dt| { + dt.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S") + .to_string() + }) + .unwrap_or_else(|| ts.to_string()) + }) + .unwrap_or_else(|| "(none)".into()); + + let body = vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(" Name: ", label), + Span::styled(name, val), + ]), + Line::from(vec![ + Span::styled(" Description: ", label), + Span::styled(description, val), + ]), + Line::from(vec![ + Span::styled(" State: ", label), + Span::styled(state, val), + ]), + Line::from(vec![ + Span::styled(" Epoch: ", label), + Span::styled(&epoch, val), + ]), + Line::from(vec![ + Span::styled(" Admins: ", label), + Span::styled(admin_count.to_string(), val), + ]), + Line::from(vec![ + Span::styled(" Last message: ", label), + Span::styled(&last_msg, val), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" MLS Group ID: ", label), + Span::styled(&mls_id, val), + ]), + Line::from(vec![ + Span::styled(" Nostr Group ID: ", label), + Span::styled(&nostr_id, val), + ]), + ]; + let height = (body.len() as u16 + 4).min(22); + let widget = PopupWidget::new("Group Info") + .body(body) + .hints(vec![("Any key", "Close")]) + .size(75, height); + frame.render_widget(widget, area); + } Popup::Invites { items, selected } => { let body: Vec = items .iter() @@ -1544,21 +2789,201 @@ impl App { .size(50, height); frame.render_widget(widget, area); } - } - } -} + Popup::GroupPicker { selected, .. } => { + let body: Vec = if self.chats.is_empty() { + vec![Line::styled( + " No groups available", + Style::default().fg(Color::DarkGray), + )] + } else { + self.chats + .iter() + .enumerate() + .map(|(i, chat)| { + let name = chat + .get("name") + .or_else(|| chat.get("group_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let marker = if i == *selected { ">" } else { " " }; + let style = if i == *selected { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::White) + }; + Line::from(vec![ + Span::styled( + format!(" {marker} "), + Style::default().fg(Color::Cyan), + ), + Span::styled(name.to_string(), style), + ]) + }) + .collect() + }; + let height = (body.len() as u16 + 5).min(20); + let widget = PopupWidget::new("Add to Group") + .body(body) + .hints(vec![ + ("Enter", "Add"), + ("j/k", "Navigate"), + ("Esc", "Cancel"), + ]) + .size(50, height); + frame.render_widget(widget, area); + } + Popup::ImageViewer { + img_width, + img_height, + } => { + use crate::widget::popup::centered_rect; + use ratatui::layout::Rect; + use ratatui::widgets::{Block, Paragraph}; + use ratatui_image::StatefulImage; + + // Size popup to image aspect ratio. + // Terminal cells are ~2:1 (twice as tall as wide), so divide + // pixel height by 2 when converting to cell rows. + let max_w = area.width.saturating_sub(4); + let max_h = area.height.saturating_sub(4); + let aspect = *img_width as f64 / (*img_height).max(1) as f64; + + // Start with max width, compute height from aspect ratio + // cell_height = pixel_height / 2 relative to width + let mut w = max_w; + let mut h = (w as f64 / aspect / 2.0).ceil() as u16; + + // If height exceeds max, scale down from height instead + if h > max_h { + h = max_h; + w = (h as f64 * aspect * 2.0).ceil() as u16; + } -/// Extract account identifier (pubkey or npub) from a JSON value. -fn extract_account_id(val: &Value) -> Option { - // Direct string - if let Some(s) = val.as_str() { - return Some(s.to_string()); - } - // Object with npub or pubkey - val.get("npub") - .or_else(|| val.get("pubkey")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) + // Add 2 for borders + 1 for hint line + let popup_w = (w + 2).min(area.width); + let popup_h = (h + 3).min(area.height); + let popup_area = centered_rect(popup_w, popup_h, area); + + let block = Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Image Viewer "); + let inner = block.inner(popup_area); + + // Clear background + frame.render_widget(ratatui::widgets::Clear, popup_area); + frame.render_widget(block, popup_area); + + if let Some(proto) = &mut self.popup_image { + if inner.width > 0 && inner.height > 1 { + let img_area = Rect::new( + inner.x, + inner.y, + inner.width, + inner.height.saturating_sub(1), + ); + frame.render_stateful_widget(StatefulImage::default(), img_area, proto); + } + } + + // Hint at bottom + let hint_area = Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(1), + inner.width, + 1, + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + " Any key ", + Style::default().fg(Color::Black).bg(Color::DarkGray), + ), + Span::styled(" Close", Style::default().fg(Color::DarkGray)), + ])), + hint_area, + ); + } + Popup::FileBrowser { + path, + entries, + selected, + } => { + let title = { + let p = path.display().to_string(); + if p.len() > 56 { + format!("...{}", &p[p.len() - 53..]) + } else { + p + } + }; + let body: Vec = if entries.is_empty() { + vec![Line::styled( + " (empty directory)", + Style::default().fg(Color::DarkGray), + )] + } else { + entries + .iter() + .enumerate() + .map(|(i, (name, is_dir))| { + let marker = if i == *selected { ">" } else { " " }; + let display = if *is_dir { + format!("{name}/") + } else { + name.clone() + }; + let style = if i == *selected { + if *is_dir { + Style::default() + .fg(Color::Cyan) + .add_modifier(ratatui::style::Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + } + } else if *is_dir { + Style::default().fg(Color::Blue) + } else { + Style::default().fg(Color::White) + }; + Line::from(vec![ + Span::styled( + format!(" {marker} "), + Style::default().fg(Color::Cyan), + ), + Span::styled(display, style), + ]) + }) + .collect() + }; + let height = (body.len() as u16 + 5).min(20); + let widget = PopupWidget::new(&title) + .body(body) + .hints(vec![ + ("Enter", "Open"), + ("j/k", "Navigate"), + ("Bksp", "Parent"), + ("~", "Home"), + ("Esc", "Cancel"), + ]) + .size(60, height); + frame.render_widget(widget, area); + } + } + } +} + +/// Extract account identifier (pubkey or npub) from a JSON value. +fn extract_account_id(val: &Value) -> Option { + // Direct string + if let Some(s) = val.as_str() { + return Some(s.to_string()); + } + // Object with npub or pubkey + val.get("npub") + .or_else(|| val.get("pubkey")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) } /// Convert a hex pubkey to bech32 npub format. Returns the input unchanged on failure. @@ -1597,6 +3022,8 @@ fn help_lines(screen: &Screen) -> Vec> { lines.push(hint("Enter", "Select chat")); lines.push(hint("Tab", "Switch focus")); lines.push(hint("i / Enter", "Start typing (messages)")); + lines.push(hint("r", "React to message")); + lines.push(hint("u", "Unreact")); lines.push(hint("Esc", "Unfocus / back")); lines.push(hint("n", "New group")); lines.push(hint("g", "Group info")); @@ -1614,14 +3041,22 @@ fn help_lines(screen: &Screen) -> Vec> { lines.push(hint("A", "Add by pubkey/npub")); lines.push(hint("x", "Remove member")); lines.push(hint("R", "Rename group")); + lines.push(hint("i", "Group info (epoch, IDs)")); lines.push(hint("L", "Leave group")); lines.push(hint("Esc", "Back")); } Screen::Profile => { lines.push(hint("n", "Edit name")); + lines.push(hint("D", "Edit display name")); lines.push(hint("a", "Edit about")); + lines.push(hint("P", "Edit picture URL")); + lines.push(hint("5", "Edit NIP-05")); + lines.push(hint("$", "Edit lightning address")); + lines.push(hint("e", "Show nsec")); lines.push(hint("j / k", "Navigate follows")); + lines.push(hint("Enter", "View profile")); lines.push(hint("d", "Unfollow")); + lines.push(hint("Q", "Logout")); lines.push(hint("Esc", "Back")); } Screen::Settings => { @@ -1636,6 +3071,9 @@ fn help_lines(screen: &Screen) -> Vec> { lines.push(hint("Enter", "Search")); lines.push(hint("↑ / ↓", "Navigate results")); lines.push(hint("Tab", "Follow / Unfollow")); + lines.push(hint("F2", "View profile")); + lines.push(hint("F3", "New group with user")); + lines.push(hint("F4", "Add to existing group")); lines.push(hint("Esc", "Back")); } } @@ -1927,6 +3365,22 @@ mod tests { assert_eq!(app.messages.len(), 0); } + #[test] + fn message_update_skips_reaction_and_deletion_events() { + let mut app = app_on_main(); + app.active_group_id = Some("g1".into()); + app.update(msg_update( + json!({"id": "msg1", "content": "hello", "kind": 9}), + )); + app.update(msg_update(json!({"id": "r1", "content": "+", "kind": 7}))); + app.update(msg_update(json!({"id": "d1", "content": "", "kind": 5}))); + assert_eq!( + app.messages.len(), + 1, + "Only kind-9 chat messages should be kept" + ); + } + // ── Notifications ──────────────────────────────────────────────── #[test] @@ -2130,6 +3584,24 @@ mod tests { assert_eq!(app.screen, Screen::Main); } + #[test] + fn leave_group_removes_from_chat_list() { + let mut app = app_on_group_detail(); + app.chats = vec![ + json!({"name": "Other", "mls_group_id": "other1"}), + json!({"name": "Coffee Chat", "mls_group_id": "g1"}), + json!({"name": "Third", "mls_group_id": "g3"}), + ]; + app.selected_chat = 1; + app.update(Action::GroupActionSuccess("Left group".into())); + assert_eq!(app.chats.len(), 2); + assert!(app + .chats + .iter() + .all(|c| chat_list::group_id(c).as_deref() != Some("g1"))); + assert!(app.selected_chat < app.chats.len()); + } + #[test] fn group_action_success_reloads_detail() { let mut app = app_on_group_detail(); @@ -2247,6 +3719,38 @@ mod tests { assert_eq!(app.profile.as_ref().unwrap()["name"], "Alice"); } + #[test] + fn profile_loaded_with_picture_emits_fetch() { + let mut app = app_on_main(); + app.screen = Screen::Profile; + let effects = app.update(Action::ProfileLoaded( + json!({"name": "Alice", "picture": "https://example.com/pic.jpg"}), + )); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::FetchProfileImage { .. }))); + } + + #[test] + fn profile_loaded_without_picture_no_fetch() { + let mut app = app_on_main(); + app.screen = Screen::Profile; + let effects = app.update(Action::ProfileLoaded( + json!({"name": "Alice", "about": "Hi"}), + )); + assert!(!effects + .iter() + .any(|e| matches!(e, Effect::FetchProfileImage { .. }))); + } + + #[test] + fn profile_image_fetched_without_picker_is_noop() { + let mut app = app_on_main(); + app.picker = None; + app.update(Action::ProfileImageFetched(vec![0, 1, 2])); + assert!(app.profile_image.is_none()); + } + #[test] fn profile_n_opens_edit_name() { let mut app = app_on_main(); @@ -2475,6 +3979,21 @@ mod tests { assert_eq!(app.selected_follow, 1); } + #[test] + fn profile_enter_views_selected_follow() { + let mut app = app_on_main(); + app.screen = Screen::Profile; + app.follows = vec![ + json!({"pubkey": "abc", "name": "Alice"}), + json!({"pubkey": "def", "name": "Bob"}), + ]; + app.selected_follow = 1; + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::ShowUserProfile { ref pubkey, .. } if pubkey == "def"))); + } + #[test] fn profile_d_unfollows_selected() { let mut app = app_on_main(); @@ -2732,6 +4251,122 @@ mod tests { assert_eq!(app.message_scroll, 0, "should auto-scroll"); } + // ── Reactions ───────────────────────────────────────────────────── + + #[test] + fn react_opens_emoji_popup() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![ + json!({"id": "msg1", "content": "hello", "author": "a"}), + json!({"id": "msg2", "content": "world", "author": "b"}), + ]; + app.update(Action::Key(key(KeyCode::Char('r')))); + match &app.popup { + Some(Popup::TextInput { purpose, input, .. }) => { + assert_eq!(*purpose, InputPurpose::ReactionEmoji); + assert_eq!(input.value, "+"); // pre-filled with default + } + other => panic!("Expected TextInput popup, got {:?}", other), + } + } + + #[test] + fn react_submit_emits_effect() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({"id": "msg1", "content": "hello", "author": "a"})]; + app.selected_message = Some(0); + // Open popup and submit + app.update(Action::Key(key(KeyCode::Char('r')))); + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::ReactToMessage { ref message_id, ref emoji, .. } + if message_id == "msg1" && emoji == "+" + ))); + } + + #[test] + fn k_moves_selection_up() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![ + json!({"id": "msg1", "content": "a", "author": "a"}), + json!({"id": "msg2", "content": "b", "author": "a"}), + json!({"id": "msg3", "content": "c", "author": "a"}), + ]; + // Default: no selection = newest (index 2) + app.update(Action::Key(key(KeyCode::Char('k')))); + assert_eq!(app.selected_message, Some(1)); + app.update(Action::Key(key(KeyCode::Char('k')))); + assert_eq!(app.selected_message, Some(0)); + // Clamps at 0 + app.update(Action::Key(key(KeyCode::Char('k')))); + assert_eq!(app.selected_message, Some(0)); + } + + #[test] + fn j_moves_selection_down() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![ + json!({"id": "msg1", "content": "a", "author": "a"}), + json!({"id": "msg2", "content": "b", "author": "a"}), + ]; + app.selected_message = Some(0); + app.update(Action::Key(key(KeyCode::Char('j')))); + assert_eq!(app.selected_message, Some(1)); + // Clamps at max + app.update(Action::Key(key(KeyCode::Char('j')))); + assert_eq!(app.selected_message, Some(1)); + } + + #[test] + fn unreact_emits_effect() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({"id": "msg1", "content": "hi", "author": "a"})]; + app.message_scroll = 0; + let effects = app.update(Action::Key(key(KeyCode::Char('u')))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::UnreactToMessage { ref message_id, .. } if message_id == "msg1" + ))); + } + + #[test] + fn react_with_no_messages_is_noop() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![]; + let effects = app.update(Action::Key(key(KeyCode::Char('r')))); + assert!(effects.is_empty()); + } + + #[test] + fn react_without_group_is_noop() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = None; + app.messages = vec![json!({"id": "msg1", "content": "hi", "author": "a"})]; + let effects = app.update(Action::Key(key(KeyCode::Char('r')))); + assert!(effects.is_empty()); + } + + #[test] + fn reaction_error_shows_popup() { + let mut app = app_on_main(); + app.update(Action::ReactionError("fail".into())); + assert!(matches!(app.popup, Some(Popup::Error { .. }))); + } + // ── Invite popup navigation ────────────────────────────────────── #[test] @@ -2928,4 +4563,589 @@ mod tests { app.update(Action::Paste("should be ignored".into())); assert!(app.composer.is_empty()); } + + // ── Media / image_attachments tests ───────────────────────────── + + #[test] + fn image_attachments_extracts_image_hashes() { + let msg = json!({ + "content": "check this", + "media_attachments": [ + { + "file_hash": "abc123", + "mime_type": "image/png", + "filename": "photo.png" + } + ] + }); + let result = App::image_attachments(&msg); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "abc123"); + assert_eq!(result[0].1, "image/png"); + assert_eq!(result[0].2, "photo.png"); + } + + #[test] + fn image_attachments_skips_non_images() { + let msg = json!({ + "content": "file here", + "media_attachments": [ + { + "file_hash": "abc123", + "mime_type": "application/pdf", + "filename": "doc.pdf" + } + ] + }); + let result = App::image_attachments(&msg); + assert!(result.is_empty()); + } + + #[test] + fn image_attachments_handles_byte_vec_hash() { + let msg = json!({ + "content": "img", + "media_attachments": [ + { + "file_hash": {"value": {"vec": [0xab, 0xcd, 0xef]}}, + "mime_type": "image/jpeg", + "filename": "img.jpg" + } + ] + }); + let result = App::image_attachments(&msg); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "abcdef"); + } + + #[test] + fn image_attachments_real_cli_format() { + // Matches actual `wn messages list --json` output (verified 2026-03-07) + let msg = json!({ + "content": "", + "kind": 9, + "media_attachments": [ + { + "account_pubkey": "abc123", + "blossom_url": "https://blossom.primal.net/d012c620", + "mime_type": "image/jpeg", + "original_file_hash": [234, 240, 211, 181], + "encrypted_file_hash": [208, 18, 198, 32], + "file_metadata": { + "original_filename": "signal-2026-03-03.jpeg" + }, + "file_path": "", + "media_type": "chat_media" + } + ] + }); + let result = App::image_attachments(&msg); + assert_eq!(result.len(), 1, "Should find 1 image attachment"); + assert_eq!(result[0].0, "eaf0d3b5", "Hash should be hex of byte array"); + assert_eq!(result[0].1, "image/jpeg"); + assert_eq!( + result[0].2, "signal-2026-03-03.jpeg", + "Should use original_filename" + ); + } + + #[test] + fn image_attachments_empty_when_no_attachments() { + let msg = json!({"content": "hello"}); + assert!(App::image_attachments(&msg).is_empty()); + } + + #[test] + fn image_attachments_mixed_types() { + let msg = json!({ + "content": "mixed", + "media_attachments": [ + { + "file_hash": "hash1", + "mime_type": "image/png", + "filename": "photo.png" + }, + { + "file_hash": "hash2", + "mime_type": "application/zip", + "filename": "archive.zip" + }, + { + "file_hash": "hash3", + "mime_type": "image/gif", + "filename": "anim.gif" + } + ] + }); + let result = App::image_attachments(&msg); + assert_eq!(result.len(), 2); + assert_eq!(result[0].0, "hash1"); + assert_eq!(result[1].0, "hash3"); + } + + #[test] + fn media_downloaded_updates_state() { + let mut app = app_on_main(); + app.media_downloads + .insert("hash1".into(), MediaDownload::Downloading); + app.update(Action::MediaDownloaded { + file_hash: "hash1".into(), + file_path: "/tmp/img.png".into(), + }); + assert!(matches!( + app.media_downloads.get("hash1"), + Some(MediaDownload::Downloaded(p)) if p == "/tmp/img.png" + )); + } + + #[test] + fn media_download_failed_updates_state() { + let mut app = app_on_main(); + app.media_downloads + .insert("hash1".into(), MediaDownload::Downloading); + app.update(Action::MediaDownloadFailed { + file_hash: "hash1".into(), + error: "not found".into(), + }); + assert!(matches!( + app.media_downloads.get("hash1"), + Some(MediaDownload::Failed(_)) + )); + } + + #[test] + fn select_chat_clears_media_downloads() { + let mut app = app_on_main(); + app.chats = vec![json!({"name": "test", "mls_group_id": "aabb"})]; + app.media_downloads + .insert("hash1".into(), MediaDownload::Downloading); + app.selected_chat = 0; + app.active_group_id = Some("other".into()); + app.update(Action::Key(key(KeyCode::Enter))); + assert!(app.media_downloads.is_empty()); + } + + #[test] + fn message_update_triggers_download_for_image() { + let mut app = app_on_main(); + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + let msg = json!({ + "id": "m1", + "kind": 9, + "content": "look", + "author": "a", + "media_attachments": [ + { + "file_hash": "imghash", + "mime_type": "image/png", + "filename": "photo.png" + } + ] + }); + let effects = app.update(Action::MessageUpdate { + group_id: "g1".into(), + message: msg, + }); + assert!(effects.iter().any( + |e| matches!(e, Effect::DownloadMedia { ref file_hash, .. } if file_hash == "imghash") + )); + assert!(matches!( + app.media_downloads.get("imghash"), + Some(MediaDownload::Downloading) + )); + } + + #[test] + fn o_opens_image_when_downloaded() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "m1", + "content": "img", + "author": "a", + "media_attachments": [ + { + "file_hash": "imghash", + "mime_type": "image/png", + "filename": "photo.png" + } + ] + })]; + app.selected_message = Some(0); + app.media_downloads.insert( + "imghash".into(), + MediaDownload::Downloaded("/tmp/photo.png".into()), + ); + let effects = app.update(Action::Key(key(KeyCode::Char('o')))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::LoadMediaPopup { + ref file_path, + .. + } if file_path == "/tmp/photo.png" + ))); + } + + #[test] + fn o_noop_when_no_image_downloaded() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "m1", + "content": "text only", + "author": "a" + })]; + app.selected_message = Some(0); + let effects = app.update(Action::Key(key(KeyCode::Char('o')))); + assert!(effects.is_empty()); + } + + #[test] + fn switch_account_resets_state_and_checks_accounts() { + let mut app = app_on_main(); + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.chats = vec![json!({"name": "test"})]; + app.messages = vec![json!({"content": "hi"})]; + app.focus = Panel::ChatList; + let effects = app.update(Action::Key(key(KeyCode::Char('A')))); + assert_eq!(app.screen, Screen::Login); + assert!(app.account.is_none()); + assert!(app.chats.is_empty()); + assert!(app.messages.is_empty()); + assert!(app.active_group_id.is_none()); + assert!(effects.iter().any(|e| matches!(e, Effect::CheckAccounts))); + } + + #[test] + fn d_opens_confirm_for_own_message() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.account = Some("mypub".into()); + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "msg1", + "content": "my message", + "author": "mypub" + })]; + app.selected_message = Some(0); + app.update(Action::Key(key(KeyCode::Char('d')))); + assert!(matches!( + app.popup, + Some(Popup::Confirm { + purpose: ConfirmPurpose::DeleteMessage { .. }, + .. + }) + )); + } + + #[test] + fn d_noop_for_others_message() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.account = Some("mypub".into()); + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "msg1", + "content": "their message", + "author": "otherpub" + })]; + app.selected_message = Some(0); + app.update(Action::Key(key(KeyCode::Char('d')))); + assert!(app.popup.is_none()); + } + + #[test] + fn confirm_delete_emits_effect() { + let mut app = app_on_main(); + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::Confirm { + title: "Delete".into(), + message: "Delete?".into(), + purpose: ConfirmPurpose::DeleteMessage { + message_id: "msg1".into(), + }, + }); + let effects = app.update(Action::Key(key(KeyCode::Char('y')))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::DeleteMessage { + ref message_id, + .. + } if message_id == "msg1" + ))); + } + + #[test] + fn message_deleted_removes_from_list() { + let mut app = app_on_main(); + app.messages = vec![ + json!({"id": "m1", "content": "first"}), + json!({"id": "m2", "content": "second"}), + ]; + app.update(Action::MessageDeleted { + message_id: "m1".into(), + }); + assert_eq!(app.messages.len(), 1); + assert_eq!(app.messages[0].get("id").unwrap().as_str().unwrap(), "m2"); + } + + #[test] + fn reply_sets_context_and_focuses_composer() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "evt1", + "content": "hello world", + "author": "somepub", + "display_name": "Alice" + })]; + app.selected_message = Some(0); + app.update(Action::Key(key(KeyCode::Char('R')))); + assert_eq!(app.focus, Panel::Composer); + let (event_id, author, preview) = app.reply_to.as_ref().unwrap(); + assert_eq!(event_id, "evt1"); + assert_eq!(author, "Alice"); + assert_eq!(preview, "hello world"); + } + + #[test] + fn send_with_reply_includes_reply_to() { + let mut app = app_on_main(); + app.focus = Panel::Composer; + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.reply_to = Some(("evt1".into(), "Alice".into(), "hello".into())); + app.composer.insert('h'); + app.composer.insert('i'); + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::SendMessage { + reply_to: Some(ref id), + .. + } if id == "evt1" + ))); + assert!( + app.reply_to.is_none(), + "reply_to should be cleared after send" + ); + } + + #[test] + fn send_without_reply_has_none() { + let mut app = app_on_main(); + app.focus = Panel::Composer; + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.composer.insert('h'); + app.composer.insert('i'); + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::SendMessage { reply_to: None, .. }))); + } + + #[test] + fn esc_from_composer_clears_reply() { + let mut app = app_on_main(); + app.focus = Panel::Composer; + app.active_group_id = Some("g1".into()); + app.reply_to = Some(("evt1".into(), "Alice".into(), "hello".into())); + app.update(Action::Key(key(KeyCode::Esc))); + assert!(app.reply_to.is_none()); + assert_eq!(app.focus, Panel::Messages); + } + + #[test] + fn u_uppercase_opens_file_browser() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + let effects = app.update(Action::Key(key(KeyCode::Char('U')))); + assert!(effects.is_empty()); + match &app.popup { + Some(Popup::FileBrowser { + path, + entries, + selected, + }) => { + assert_eq!(*selected, 0); + assert!(path.exists()); + // entries should be sorted: dirs first + let dir_section_ended = entries.iter().position(|(_, is_dir)| !is_dir); + if let Some(first_file) = dir_section_ended { + for (_, is_dir) in &entries[..first_file] { + assert!(is_dir); + } + } + } + other => panic!("Expected FileBrowser popup, got {:?}", other), + } + } + + #[test] + fn u_uppercase_noop_without_active_group() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = None; + app.update(Action::Key(key(KeyCode::Char('U')))); + assert!(app.popup.is_none()); + } + + #[test] + fn file_browser_select_file_emits_upload() { + let mut app = app_on_main(); + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::FileBrowser { + path: std::path::PathBuf::from("/tmp"), + entries: vec![("docs".into(), true), ("photo.png".into(), false)], + selected: 1, // selecting the file + }); + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::UploadMedia { + ref account, + ref group_id, + ref file_path, + } if account == "acc1" && group_id == "g1" && file_path == "/tmp/photo.png" + ))); + assert!(app.popup.is_none()); + } + + #[test] + fn file_browser_enter_directory() { + let mut app = app_on_main(); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::FileBrowser { + path: std::path::PathBuf::from("/tmp"), + entries: vec![("subdir".into(), true), ("file.txt".into(), false)], + selected: 0, // selecting the directory + }); + app.update(Action::Key(key(KeyCode::Enter))); + match &app.popup { + Some(Popup::FileBrowser { path, selected, .. }) => { + assert_eq!(path, &std::path::PathBuf::from("/tmp/subdir")); + assert_eq!(*selected, 0); + } + other => panic!( + "Expected FileBrowser popup after dir enter, got {:?}", + other + ), + } + } + + #[test] + fn file_browser_navigation() { + let mut app = app_on_main(); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::FileBrowser { + path: std::path::PathBuf::from("/tmp"), + entries: vec![ + ("a".into(), false), + ("b".into(), false), + ("c".into(), false), + ], + selected: 0, + }); + // j moves down + app.update(Action::Key(key(KeyCode::Char('j')))); + match &app.popup { + Some(Popup::FileBrowser { selected, .. }) => assert_eq!(*selected, 1), + _ => panic!("expected FileBrowser"), + } + // k moves up + app.update(Action::Key(key(KeyCode::Char('k')))); + match &app.popup { + Some(Popup::FileBrowser { selected, .. }) => assert_eq!(*selected, 0), + _ => panic!("expected FileBrowser"), + } + // G jumps to bottom + app.update(Action::Key(key(KeyCode::Char('G')))); + match &app.popup { + Some(Popup::FileBrowser { selected, .. }) => assert_eq!(*selected, 2), + _ => panic!("expected FileBrowser"), + } + // g jumps to top + app.update(Action::Key(key(KeyCode::Char('g')))); + match &app.popup { + Some(Popup::FileBrowser { selected, .. }) => assert_eq!(*selected, 0), + _ => panic!("expected FileBrowser"), + } + // Esc closes + app.update(Action::Key(key(KeyCode::Esc))); + assert!(app.popup.is_none()); + } + + #[test] + fn file_browser_backspace_goes_to_parent() { + let mut app = app_on_main(); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::FileBrowser { + path: std::path::PathBuf::from("/tmp/subdir"), + entries: vec![("file.txt".into(), false)], + selected: 0, + }); + app.update(Action::Key(key(KeyCode::Backspace))); + match &app.popup { + Some(Popup::FileBrowser { path, selected, .. }) => { + assert_eq!(path, &std::path::PathBuf::from("/tmp")); + assert_eq!(*selected, 0); + } + other => panic!("Expected FileBrowser after backspace, got {:?}", other), + } + } + + #[test] + fn scan_dir_sorts_dirs_first_and_hides_dotfiles() { + use std::fs; + let tmp = std::env::temp_dir().join("wn_tui_test_scan_dir"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write(tmp.join("banana.txt"), "").unwrap(); + fs::write(tmp.join("apple.txt"), "").unwrap(); + fs::create_dir(tmp.join("zebra_dir")).unwrap(); + fs::create_dir(tmp.join("alpha_dir")).unwrap(); + fs::write(tmp.join(".hidden"), "").unwrap(); + + let entries = App::scan_dir(&tmp); + // Hidden files excluded + assert!(!entries.iter().any(|(n, _)| n.starts_with('.'))); + // Dirs come first + assert_eq!(entries[0], ("alpha_dir".into(), true)); + assert_eq!(entries[1], ("zebra_dir".into(), true)); + // Files after dirs + assert_eq!(entries[2], ("apple.txt".into(), false)); + assert_eq!(entries[3], ("banana.txt".into(), false)); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn upload_error_shows_popup() { + let mut app = app_on_main(); + app.update(Action::MediaUploadError("file not found".into())); + match &app.popup { + Some(Popup::Error { message }) => { + assert!(message.contains("file not found")); + } + other => panic!("Expected Error popup, got {:?}", other), + } + } + + #[test] + fn upload_success_logs() { + let mut app = app_on_main(); + app.update(Action::MediaUploaded); + assert!(app.logs.iter().any(|l| l.contains("uploaded successfully"))); + } } diff --git a/src/main.rs b/src/main.rs index e338c11..aef1ca3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod app; mod event; mod screen; mod tui; +pub mod util; mod widget; mod wn; @@ -91,6 +92,23 @@ async fn main() -> Result<()> { tui::install_panic_hook(); let mut terminal = tui::init()?; let mut app = App::new(); + + // Initialize image protocol detection (must happen after terminal setup) + match ratatui_image::picker::Picker::from_query_stdio() { + Ok(mut picker) => { + // iTerm2 misdetects as Kitty — override when ITERM_SESSION_ID is set + if std::env::var("ITERM_SESSION_ID").is_ok() { + picker.set_protocol_type(ratatui_image::picker::ProtocolType::Iterm2); + } + app.logs + .push(format!("Image protocol: {:?}", picker.protocol_type())); + app.picker = Some(picker); + } + Err(e) => { + app.logs + .push(format!("Image protocol detection failed: {e}")); + } + } let mut events = EventLoop::new(250); let action_tx = events.sender(); let mut streams = StreamHandles::new(); @@ -149,6 +167,10 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::SubscribeMessages { .. } => "SubscribeMessages", Effect::UnsubscribeMessages => "UnsubscribeMessages", Effect::SendMessage { .. } => "SendMessage", + Effect::LoadMessages { .. } => "LoadMessages", + Effect::ReactToMessage { .. } => "ReactToMessage", + Effect::UnreactToMessage { .. } => "UnreactToMessage", + Effect::DeleteMessage { .. } => "DeleteMessage", Effect::LoadGroupDetail { .. } => "LoadGroupDetail", Effect::LoadGroupMembers { .. } => "LoadGroupMembers", Effect::LoadInvites { .. } => "LoadInvites", @@ -161,13 +183,22 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::DeclineInvite { .. } => "DeclineInvite", Effect::LoadProfile { .. } => "LoadProfile", Effect::UpdateProfile { .. } => "UpdateProfile", + Effect::ExportNsec { .. } => "ExportNsec", + Effect::FetchProfileImage { .. } => "FetchProfileImage", Effect::LoadSettings { .. } => "LoadSettings", Effect::UpdateSetting { .. } => "UpdateSetting", Effect::LoadFollows { .. } => "LoadFollows", Effect::FollowUser { .. } => "FollowUser", Effect::UnfollowUser { .. } => "UnfollowUser", + Effect::CheckFollow { .. } => "CheckFollow", Effect::SearchUsers { .. } => "SearchUsers", Effect::UnsubscribeSearch => "UnsubscribeSearch", + Effect::ShowUserProfile { .. } => "ShowUserProfile", + Effect::Logout { .. } => "Logout", + Effect::DownloadMedia { .. } => "DownloadMedia", + Effect::LoadMediaImage { .. } => "LoadMediaImage", + Effect::LoadMediaPopup { .. } => "LoadMediaPopup", + Effect::UploadMedia { .. } => "UploadMedia", Effect::TailDaemonLog => "TailDaemonLog", }; send_log(tx, format!("Effect: {effect_name}")); @@ -348,20 +379,118 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m account, group_id, text, + reply_to, } => { + let tx = tx.clone(); + tokio::spawn(async move { + let mut args = vec![ + "--account".to_string(), + account, + "messages".to_string(), + "send".to_string(), + group_id, + text, + ]; + if let Some(event_id) = reply_to { + args.push("--reply-to".to_string()); + args.push(event_id); + } + let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let action = match wn::exec(&args_ref).await { + Ok(_) => Action::MessageSent, + Err(e) => Action::MessageSendError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::LoadMessages { account, group_id } => { let tx = tx.clone(); tokio::spawn(async move { let action = - match wn::exec(&["--account", &account, "messages", "send", &group_id, &text]) - .await - { - Ok(_) => Action::MessageSent, - Err(e) => Action::MessageSendError(e.to_string()), + match wn::exec(&["--account", &account, "messages", "list", &group_id]).await { + Ok(serde_json::Value::Array(arr)) => Action::MessagesLoaded(arr), + Ok(val) => Action::MessagesLoaded(vec![val]), + Err(e) => Action::Log(format!("Failed to reload messages: {e}")), }; let _ = tx.send(Event::Action(action)); }); } + Effect::ReactToMessage { + account, + group_id, + message_id, + emoji, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "messages", + "react", + &group_id, + &message_id, + &emoji, + ]) + .await + { + Ok(_) => Action::ReactionSuccess, + Err(e) => Action::ReactionError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::UnreactToMessage { + account, + group_id, + message_id, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "messages", + "unreact", + &group_id, + &message_id, + ]) + .await + { + Ok(_) => Action::ReactionSuccess, + Err(e) => Action::ReactionError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::DeleteMessage { + account, + group_id, + message_id, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "messages", + "delete", + &group_id, + &message_id, + ]) + .await + { + Ok(_) => Action::MessageDeleted { message_id }, + Err(e) => Action::MessageDeleteError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + Effect::LoadGroupDetail { account, group_id } => { let tx = tx.clone(); tokio::spawn(async move { @@ -404,7 +533,62 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m let tx = tx.clone(); tokio::spawn(async move { let action = match wn::exec(&["--account", &account, "groups", "invites"]).await { - Ok(serde_json::Value::Array(arr)) => Action::InvitesLoaded(arr), + Ok(serde_json::Value::Array(mut arr)) => { + // Resolve display names for DM invites (empty group name). + // The welcomer_pubkey in membership is the DM counterpart. + for inv in arr.iter_mut() { + let name = inv + .get("group") + .and_then(|g| g.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or(""); + if !name.is_empty() { + continue; + } + let Some(pubkey) = inv + .get("membership") + .and_then(|m| m.get("welcomer_pubkey")) + .and_then(|v| v.as_str()) + else { + continue; + }; + if let Ok(user_val) = + wn::exec(&["--account", &account, "users", "show", pubkey]).await + { + let display_name = user_val + .get("metadata") + .and_then(|m| { + m.get("display_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| { + m.get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) + }) + .or_else(|| { + user_val + .get("display_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| { + user_val + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) + }); + if let Some(resolved) = display_name { + if let Some(group) = inv.get_mut("group") { + group["name"] = + serde_json::Value::String(resolved.to_string()); + } + } + } + } + Action::InvitesLoaded(arr) + } Ok(val) => Action::InvitesLoaded(vec![val]), Err(e) => Action::GroupActionError(e.to_string()), }; @@ -412,14 +596,21 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } - Effect::CreateGroup { account, name } => { + Effect::CreateGroup { + account, + name, + members, + } => { let tx = tx.clone(); tokio::spawn(async move { - let action = - match wn::exec(&["--account", &account, "groups", "create", &name]).await { - Ok(_) => Action::GroupActionSuccess("Group created".into()), - Err(e) => Action::GroupActionError(e.to_string()), - }; + let mut args = vec!["--account", &account, "groups", "create", &name]; + for m in &members { + args.push(m); + } + let action = match wn::exec(&args).await { + Ok(_) => Action::GroupActionSuccess("Group created".into()), + Err(e) => Action::GroupActionError(e.to_string()), + }; let _ = tx.send(Event::Action(action)); }); } @@ -536,10 +727,22 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::LoadProfile { account } => { let tx = tx.clone(); tokio::spawn(async move { - let action = match wn::exec(&["--account", &account, "profile", "show"]).await { - Ok(val) => Action::ProfileLoaded(val), - Err(e) => Action::ProfileUpdateError(e.to_string()), - }; + // Use `users show ` for full metadata (picture, nip05, lud16) + // that `profile show` doesn't return. + let action = + match wn::exec(&["--account", &account, "users", "show", &account]).await { + Ok(val) => { + // Flatten: merge metadata fields to top level for easy access + let mut profile = val.clone(); + if let Some(meta) = val.get("metadata").and_then(|m| m.as_object()) { + for (k, v) in meta { + profile[k.clone()] = v.clone(); + } + } + Action::ProfileLoaded(profile) + } + Err(e) => Action::ProfileUpdateError(e.to_string()), + }; let _ = tx.send(Event::Action(action)); }); } @@ -547,24 +750,36 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::UpdateProfile { account, name, + display_name, about, + picture, + nip05, + lud16, } => { let tx = tx.clone(); tokio::spawn(async move { - let mut args = vec!["--account", &account, "profile", "update"]; - let name_val; - let about_val; - if let Some(ref n) = name { - name_val = n.clone(); - args.push("--name"); - args.push(&name_val); - } - if let Some(ref a) = about { - about_val = a.clone(); - args.push("--about"); - args.push(&about_val); + let mut args = vec![ + "--account".to_string(), + account, + "profile".into(), + "update".into(), + ]; + let fields: &[(&str, &Option)] = &[ + ("--name", &name), + ("--display-name", &display_name), + ("--about", &about), + ("--picture", &picture), + ("--nip05", &nip05), + ("--lud16", &lud16), + ]; + for (flag, val) in fields { + if let Some(v) = val { + args.push(flag.to_string()); + args.push(v.clone()); + } } - let action = match wn::exec(&args).await { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let action = match wn::exec(&arg_refs).await { Ok(_) => Action::ProfileUpdateSuccess("Profile updated".into()), Err(e) => Action::ProfileUpdateError(e.to_string()), }; @@ -572,6 +787,43 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::ExportNsec { account } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&["export-nsec", &account]).await { + Ok(val) => { + let nsec = val + .as_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| val.to_string()); + Action::NsecExported(nsec) + } + Err(e) => Action::NsecExportError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::FetchProfileImage { url } => { + let tx = tx.clone(); + tokio::spawn(async move { + let result = tokio::process::Command::new("curl") + .args(["-s", "-L", "--fail", "--max-time", "10", &url]) + .output() + .await; + match result { + Ok(output) if output.status.success() && !output.stdout.is_empty() => { + let _ = tx.send(Event::Action(Action::ProfileImageFetched(output.stdout))); + } + _ => { + let _ = tx.send(Event::Action(Action::Log( + "Failed to fetch profile image".into(), + ))); + } + } + }); + } + Effect::LoadSettings { account } => { let tx = tx.clone(); tokio::spawn(async move { @@ -641,6 +893,24 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::CheckFollow { account, pubkey } => { + let tx = tx.clone(); + tokio::spawn(async move { + let following = + match wn::exec(&["--account", &account, "follows", "check", &pubkey]).await { + Ok(val) => val + .get("following") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + Err(_) => false, + }; + let _ = tx.send(Event::Action(Action::FollowCheckResult { + pubkey, + following, + })); + }); + } + Effect::SearchUsers { account, query } => { streams.kill_search(); let tx = tx.clone(); @@ -680,6 +950,141 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m streams.kill_search(); } + Effect::ShowUserProfile { account, pubkey } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = + match wn::exec(&["--account", &account, "users", "show", &pubkey]).await { + Ok(val) => Action::UserProfileLoaded(val), + Err(e) => Action::UserProfileError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::Logout { account } => { + streams.kill_all(); + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&["logout", &account]).await { + Ok(_) => Action::LogoutSuccess, + Err(e) => Action::LogoutError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::DownloadMedia { + account, + group_id, + file_hash, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "media", + "download", + &group_id, + &file_hash, + ]) + .await + { + Ok(val) => { + let path = val + .get("file_path") + .and_then(|v| v.as_str()) + .or_else(|| val.as_str()); + match path { + Some(p) => Action::MediaDownloaded { + file_hash: file_hash.clone(), + file_path: p.to_string(), + }, + None => Action::MediaDownloadFailed { + file_hash, + error: format!("Unexpected response: {val}"), + }, + } + } + Err(e) => Action::MediaDownloadFailed { + file_hash, + error: e.to_string(), + }, + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::LoadMediaImage { + file_hash, + file_path, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + match tokio::fs::read(&file_path).await { + Ok(bytes) => { + let _ = + tx.send(Event::Action(Action::MediaImageLoaded { file_hash, bytes })); + } + Err(e) => { + send_log(&tx, format!("Failed to read media file: {e}")); + } + } + }); + } + + Effect::LoadMediaPopup { file_path } => { + let tx = tx.clone(); + tokio::spawn(async move { + match tokio::fs::read(&file_path).await { + Ok(bytes) => { + // Decode to get dimensions, then send bytes for popup + match image::load_from_memory(&bytes) { + Ok(img) => { + let _ = tx.send(Event::Action(Action::MediaPopupReady { + img_width: img.width(), + img_height: img.height(), + bytes, + })); + } + Err(e) => { + send_log(&tx, format!("Popup image decode failed: {e}")); + } + } + } + Err(e) => { + send_log(&tx, format!("Failed to read media file for popup: {e}")); + } + } + }); + } + + Effect::UploadMedia { + account, + group_id, + file_path, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "media", + "upload", + "--send", + &group_id, + &file_path, + ]) + .await + { + Ok(_) => Action::MediaUploaded, + Err(e) => Action::MediaUploadError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + Effect::TailDaemonLog => { let tx = tx.clone(); tokio::spawn(async move { diff --git a/src/screen/group_detail.rs b/src/screen/group_detail.rs index 5c97a8a..f632d5f 100644 --- a/src/screen/group_detail.rs +++ b/src/screen/group_detail.rs @@ -235,6 +235,8 @@ fn draw_hints(frame: &mut Frame, area: Rect) { let line2 = Line::from(vec![ Span::styled(" [R] ", Style::default().fg(Color::Cyan)), Span::raw("Rename "), + Span::styled("[i] ", Style::default().fg(Color::Cyan)), + Span::raw("Info "), Span::styled("[L] ", Style::default().fg(Color::Cyan)), Span::raw("Leave "), Span::styled("[Esc] ", Style::default().fg(Color::Cyan)), diff --git a/src/screen/login.rs b/src/screen/login.rs index 9e6d85c..46d2f98 100644 --- a/src/screen/login.rs +++ b/src/screen/login.rs @@ -31,7 +31,7 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { frame.render_widget(outer, area); let content_height = match &app.login_mode { - LoginMode::AccountSelect { accounts, .. } => (accounts.len() as u16 + 5).min(inner.height), + LoginMode::AccountSelect { accounts, .. } => (accounts.len() as u16 + 6).min(inner.height), _ => 7, }; let vertical = Layout::vertical([ @@ -174,7 +174,8 @@ fn draw_account_select( constraints.push(Constraint::Length(1)); } constraints.push(Constraint::Length(1)); // Spacer - constraints.push(Constraint::Length(1)); // Hints + constraints.push(Constraint::Length(1)); // Hints row 1 + constraints.push(Constraint::Length(1)); // Hints row 2 let rows = Layout::vertical(constraints).split(area); @@ -227,17 +228,28 @@ fn draw_account_select( frame.render_widget(line, rows[2 + i]); } - let hint_row = rows.len() - 1; - let hint = Paragraph::new(Line::from(vec![ + let hint_row1 = rows.len() - 2; + let hint_row2 = rows.len() - 1; + let line1 = Paragraph::new(Line::from(vec![ Span::styled("[j/k] ", Style::default().fg(Color::Cyan)), Span::raw("Navigate "), Span::styled("[Enter] ", Style::default().fg(Color::Cyan)), Span::raw("Select "), + Span::styled("[c] ", Style::default().fg(Color::Cyan)), + Span::raw("New identity "), + Span::styled("[l] ", Style::default().fg(Color::Cyan)), + Span::raw("Login nsec"), + ])) + .centered(); + frame.render_widget(line1, rows[hint_row1]); + let line2 = Paragraph::new(Line::from(vec![ + Span::styled("[d] ", Style::default().fg(Color::Cyan)), + Span::raw("Logout "), Span::styled("[q] ", Style::default().fg(Color::Cyan)), Span::raw("Quit"), ])) .centered(); - frame.render_widget(hint, rows[hint_row]); + frame.render_widget(line2, rows[hint_row2]); } fn draw_loading(msg: &str, frame: &mut Frame, area: Rect) { diff --git a/src/screen/main_screen.rs b/src/screen/main_screen.rs index a0c3676..2c7140c 100644 --- a/src/screen/main_screen.rs +++ b/src/screen/main_screen.rs @@ -5,6 +5,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, Frame, }; +use ratatui_image::StatefulImage; use crate::app::{App, LogTab, Panel}; use crate::widget::chat_list::ChatListWidget; @@ -12,7 +13,7 @@ use crate::widget::input::InputWidget; use crate::widget::message_list::MessageListWidget; use crate::widget::status_bar::StatusBarWidget; -pub fn draw(app: &App, frame: &mut Frame, area: Rect) { +pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { // Vertical: content [+ log panel] + hints + status bar let log_height = if app.show_logs { (area.height / 3).max(5) // ~1/3 of screen, minimum 5 rows @@ -67,7 +68,7 @@ fn draw_chat_list(app: &App, frame: &mut Frame, area: Rect) { /// Maximum total rows for the composer (including borders). const MAX_COMPOSER_HEIGHT: u16 = 8; -fn draw_message_panel(app: &App, frame: &mut Frame, area: Rect) { +fn draw_message_panel(app: &mut App, frame: &mut Frame, area: Rect) { // Calculate dynamic composer height based on content. // inner_width = total width minus 2 border columns let inner_width = area.width.saturating_sub(2); @@ -84,7 +85,7 @@ fn draw_message_panel(app: &App, frame: &mut Frame, area: Rect) { draw_composer(app, frame, composer_area); } -fn draw_messages(app: &App, frame: &mut Frame, area: Rect) { +fn draw_messages(app: &mut App, frame: &mut Frame, area: Rect) { let title = if let Some(ref gid) = app.active_group_id { // Try to find the chat name from the selected chat let name = app @@ -133,10 +134,34 @@ fn draw_messages(app: &App, frame: &mut Frame, area: Rect) { return; } + // Show selection highlight when Messages panel is focused + let selected = if app.focus == Panel::Messages { + app.selected_message.or_else(|| { + if app.messages.is_empty() { + None + } else { + Some(app.messages.len() - 1) + } + }) + } else { + None + }; + let widget = MessageListWidget::new(&app.messages, app.message_scroll) .block(block) - .my_pubkey(app.account.as_deref()); + .my_pubkey(app.account.as_deref()) + .selected(selected) + .media_downloads(&app.media_downloads) + .inline_images(&app.inline_images); + let image_rects = widget.image_positions(area); frame.render_widget(widget, area); + + // Render inline images on top of the reserved blank rows + for (hash, rect) in image_rects { + if let Some(proto) = app.inline_images.get_mut(&hash) { + frame.render_stateful_widget(StatefulImage::default(), rect, proto); + } + } } fn draw_composer(app: &App, frame: &mut Frame, area: Rect) { @@ -147,14 +172,21 @@ fn draw_composer(app: &App, frame: &mut Frame, area: Rect) { Color::DarkGray }; - let placeholder = if app.active_group_id.is_some() { + let placeholder: String = if let Some((_, ref author, ref preview)) = app.reply_to { + let truncated = if preview.chars().count() > 30 { + format!("{}...", preview.chars().take(27).collect::()) + } else { + preview.clone() + }; + format!(" Replying to {author}: \"{truncated}\" ") + } else if app.active_group_id.is_some() { if focused { - " Type a message... " + " Type a message... ".to_string() } else { - " [i] to compose " + " [i] to compose ".to_string() } } else { - "" + String::new() }; let block = Block::default() @@ -206,6 +238,12 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { key("S"), label("Settings"), sep(), + key("A"), + label("Switch account"), + sep(), + key("C"), + label("New identity"), + sep(), key("`"), label("Logs"), sep(), @@ -214,10 +252,28 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { ], Panel::Messages => vec![ key("j/k"), - label("Scroll"), + label("Select"), + sep(), + key("g/G"), + label("Top/Bottom"), + sep(), + key("r"), + label("React"), + sep(), + key("R"), + label("Reply"), + sep(), + key("u"), + label("Unreact"), + sep(), + key("d"), + label("Delete"), + sep(), + key("o"), + label("Open image"), sep(), - key("G"), - label("Bottom"), + key("U"), + label("Upload"), sep(), key("i"), label("Compose"), @@ -237,7 +293,7 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { frame.render_widget(Paragraph::new(Line::from(spans)), area); } -fn draw_log_panel(app: &App, frame: &mut Frame, area: Rect) { +pub fn draw_log_panel(app: &App, frame: &mut Frame, area: Rect) { let (active_label, inactive_label) = match app.log_tab { LogTab::Activity => ("Activity", "Daemon"), LogTab::Daemon => ("Daemon", "Activity"), diff --git a/src/screen/profile.rs b/src/screen/profile.rs index fe18169..2f789e5 100644 --- a/src/screen/profile.rs +++ b/src/screen/profile.rs @@ -5,20 +5,20 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget}, Frame, }; +use ratatui_image::StatefulImage; use crate::app::{hex_to_npub, App}; -/// Extract a profile field with fallback keys. -fn field(profile: &serde_json::Value, keys: &[&str]) -> String { - for key in keys { - if let Some(s) = profile.get(*key).and_then(|v| v.as_str()) { - return s.to_string(); - } +/// Format a profile field for display, showing "(not set)" when empty. +fn display<'a>(val: &'a str, fallback: &'a str) -> &'a str { + if val.is_empty() { + fallback + } else { + val } - String::new() } -pub fn draw(app: &App, frame: &mut Frame, area: Rect) { +pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { let outer = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)) @@ -43,57 +43,102 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { } let profile = app.profile.as_ref().unwrap(); + let f = |key: &str| -> String { + profile + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + + let has_image = app.profile_image.is_some(); + let info_height = 9; let vertical = Layout::vertical([ - Constraint::Length(7), // Profile info - Constraint::Length(1), // Follows header - Constraint::Fill(1), // Follows list - Constraint::Length(1), // Hints + Constraint::Length(info_height), // Profile info (with optional image) + Constraint::Length(1), // Follows header + Constraint::Fill(1), // Follows list + Constraint::Length(2), // Hints (2 rows) ]) .split(inner); - // Profile info - let name = field(profile, &["name", "display_name"]); - let about = field(profile, &["about"]); - let npub = field(profile, &["npub"]); - let npub = if npub.is_empty() { - app.account.as_deref().map(hex_to_npub).unwrap_or_default() - } else if !npub.starts_with("npub") { - hex_to_npub(&npub) + // Profile info section: image (left) + text fields (right) + let name = f("name"); + let display_name = f("display_name"); + let about = f("about"); + let picture = f("picture"); + let nip05 = f("nip05"); + let lud16 = f("lud16"); + let npub = { + let raw = f("npub"); + if raw.is_empty() { + app.account.as_deref().map(hex_to_npub).unwrap_or_default() + } else if !raw.starts_with("npub") { + hex_to_npub(&raw) + } else { + raw + } + }; + + let label = Style::default().fg(Color::DarkGray); + let val = Style::default().fg(Color::White); + let not_set = "(not set)"; + + // Split info area horizontally if we have an image + let (image_area, text_area) = if has_image && vertical[0].width >= 48 { + let cols = Layout::horizontal([ + Constraint::Length(20), // Image column + Constraint::Fill(1), // Text fields + ]) + .split(vertical[0]); + (Some(cols[0]), cols[1]) } else { - npub + (None, vertical[0]) }; + // Render profile image + if let Some(img_area) = image_area { + if let Some(protocol) = &mut app.profile_image { + let image_widget = StatefulImage::default(); + frame.render_stateful_widget(image_widget, img_area, protocol); + } + } + let lines = vec![ - Line::raw(""), Line::from(vec![ - Span::styled(" Name: ", Style::default().fg(Color::DarkGray)), - Span::styled( - if name.is_empty() { "(not set)" } else { &name }, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), + Span::styled(" Name: ", label), + Span::styled(display(&name, not_set), val.add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" Display name: ", label), + Span::styled(display(&display_name, not_set), val), + ]), + Line::from(vec![ + Span::styled(" About: ", label), + Span::styled(display(&about, not_set), val), ]), - Line::raw(""), Line::from(vec![ - Span::styled(" About: ", Style::default().fg(Color::DarkGray)), + Span::styled(" Picture: ", label), Span::styled( - if about.is_empty() { - "(not set)" - } else { - &about - }, - Style::default().fg(Color::White), + display(&picture, not_set), + Style::default().fg(Color::DarkGray), ), ]), + Line::from(vec![ + Span::styled(" NIP-05: ", label), + Span::styled(display(&nip05, not_set), val), + ]), + Line::from(vec![ + Span::styled(" Lightning: ", label), + Span::styled(display(&lud16, not_set), val), + ]), Line::raw(""), Line::from(vec![ - Span::styled(" npub: ", Style::default().fg(Color::DarkGray)), - Span::styled(npub, Style::default().fg(Color::White)), + Span::styled(" npub: ", label), + Span::styled(npub, val), ]), ]; - frame.render_widget(Paragraph::new(lines), vertical[0]); + frame.render_widget(Paragraph::new(lines), text_area); // Follows header let follows_header = Line::from(vec![Span::styled( @@ -155,24 +200,46 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { StatefulWidget::render(list, vertical[2], frame.buffer_mut(), &mut state); } - // Hints - let mut hints = vec![ + // Hints (2 rows) + let hint_rows = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(vertical[3]); + + let line1 = Line::from(vec![ Span::styled(" [n] ", Style::default().fg(Color::Cyan)), - Span::raw("Edit name "), + Span::raw("Name "), + Span::styled("[D] ", Style::default().fg(Color::Cyan)), + Span::raw("Display name "), Span::styled("[a] ", Style::default().fg(Color::Cyan)), - Span::raw("Edit about "), - ]; + Span::raw("About "), + Span::styled("[P] ", Style::default().fg(Color::Cyan)), + Span::raw("Picture "), + Span::styled("[5] ", Style::default().fg(Color::Cyan)), + Span::raw("NIP-05 "), + Span::styled("[$] ", Style::default().fg(Color::Cyan)), + Span::raw("Lightning "), + Span::styled("[e] ", Style::default().fg(Color::Cyan)), + Span::raw("Show nsec"), + ]); + frame.render_widget(Paragraph::new(line1), hint_rows[0]); + + let mut line2_spans = vec![]; if !app.follows.is_empty() { - hints.extend([ - Span::styled("[j/k] ", Style::default().fg(Color::Cyan)), + line2_spans.extend([ + Span::styled(" [j/k] ", Style::default().fg(Color::Cyan)), Span::raw("Navigate "), + Span::styled("[Enter] ", Style::default().fg(Color::Cyan)), + Span::raw("View "), Span::styled("[d] ", Style::default().fg(Color::Cyan)), Span::raw("Unfollow "), ]); + } else { + line2_spans.push(Span::raw(" ")); } - hints.extend([ + line2_spans.extend([ + Span::styled("[Q] ", Style::default().fg(Color::Cyan)), + Span::raw("Logout "), Span::styled("[Esc] ", Style::default().fg(Color::Cyan)), Span::raw("Back"), ]); - frame.render_widget(Paragraph::new(Line::from(hints)), vertical[3]); + frame.render_widget(Paragraph::new(Line::from(line2_spans)), hint_rows[1]); } diff --git a/src/screen/user_search.rs b/src/screen/user_search.rs index 1e75a9e..be2dcd4 100644 --- a/src/screen/user_search.rs +++ b/src/screen/user_search.rs @@ -123,7 +123,13 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { if !app.search_results.is_empty() { hint_spans.extend([ Span::styled("[Tab] ", Style::default().fg(Color::Cyan)), - Span::raw("Follow/Unfollow "), + Span::raw("Follow "), + Span::styled("[F2] ", Style::default().fg(Color::Cyan)), + Span::raw("Profile "), + Span::styled("[F3] ", Style::default().fg(Color::Cyan)), + Span::raw("New group "), + Span::styled("[F4] ", Style::default().fg(Color::Cyan)), + Span::raw("Add to group "), ]); } hint_spans.extend([ diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..d77ad5a --- /dev/null +++ b/src/util.rs @@ -0,0 +1,31 @@ +use serde_json::Value; + +/// Extract a named field from a JSON object and convert it to a hex string. +/// Handles plain strings, `{"value":{"vec":[u8,...]}}` objects, and direct `[u8,...]` arrays. +pub fn extract_hash_hex(obj: &Value, field: &str) -> Option { + value_to_hex(obj.get(field)?) +} + +/// Convert a JSON value to a hex string. +/// Handles plain strings, `{"value":{"vec":[u8,...]}}` objects, and direct `[u8,...]` arrays. +pub fn value_to_hex(val: &Value) -> Option { + if let Some(s) = val.as_str() { + if !s.is_empty() { + return Some(s.to_string()); + } + } + let arr = val + .get("value") + .and_then(|v| v.get("vec")) + .and_then(|v| v.as_array()) + .or_else(|| val.as_array())?; + let bytes: Vec = arr + .iter() + .map(|b| b.as_u64().and_then(|n| u8::try_from(n).ok())) + .collect::>>()?; + if bytes.is_empty() { + None + } else { + Some(bytes.iter().map(|b| format!("{b:02x}")).collect()) + } +} diff --git a/src/widget/chat_list.rs b/src/widget/chat_list.rs index 83168c9..dc113c7 100644 --- a/src/widget/chat_list.rs +++ b/src/widget/chat_list.rs @@ -34,23 +34,7 @@ fn last_message(chat: &Value) -> Option<&str> { /// since CLI commands expect hex-encoded group IDs. pub fn group_id(chat: &Value) -> Option { let val = chat.get("mls_group_id").or_else(|| chat.get("group_id"))?; - if let Some(s) = val.as_str() { - return Some(s.to_string()); - } - // Handle object format: {"value": {"vec": [u8, ...]}} - let bytes = val - .get("value") - .and_then(|v| v.get("vec")) - .and_then(|v| v.as_array())?; - let hex: String = bytes - .iter() - .filter_map(|b| b.as_u64().map(|n| format!("{:02x}", n))) - .collect(); - if hex.is_empty() { - None - } else { - Some(hex) - } + crate::util::value_to_hex(val) } /// Renders the chat list sidebar. diff --git a/src/widget/message_list.rs b/src/widget/message_list.rs index a05044f..e0a5f2e 100644 --- a/src/widget/message_list.rs +++ b/src/widget/message_list.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use ratatui::{ buffer::Buffer, layout::Rect, @@ -8,6 +10,12 @@ use ratatui::{ use serde_json::Value; use unicode_width::UnicodeWidthStr; +use crate::app::MediaDownload; +use ratatui_image::protocol::StatefulProtocol; + +/// Height in terminal rows for inline image thumbnails. +const INLINE_IMAGE_ROWS: u16 = 8; + /// Extract display name from a message JSON value. fn author_name(msg: &Value) -> &str { msg.get("display_name") @@ -80,10 +88,147 @@ fn format_reactions(msg: &Value, indent: usize) -> Option> { Some(Line::from(spans)) } +use crate::util::extract_hash_hex; + +/// Extract display filename from an attachment. +fn attachment_filename(att: &Value) -> String { + // Try file_metadata.original_filename, then direct filename, then blossom_url basename + att.get("file_metadata") + .and_then(|m| m.get("original_filename").or_else(|| m.get("filename"))) + .and_then(|v| v.as_str()) + .or_else(|| att.get("filename").and_then(|v| v.as_str())) + .map(|s| s.to_string()) + .or_else(|| { + att.get("blossom_url") + .and_then(|v| v.as_str()) + .and_then(|url| url.rsplit('/').next()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "file".to_string()) +} + +/// Format attachment placeholder lines for a message. +/// For images with an available inline protocol, emits blank rows (the actual +/// image is rendered separately by the caller). Otherwise emits text placeholders. +fn format_attachments( + msg: &Value, + indent: usize, + media_downloads: Option<&HashMap>, + inline_images: Option<&HashMap>, +) -> Vec> { + let attachments = match msg.get("media_attachments").and_then(|v| v.as_array()) { + Some(arr) if !arr.is_empty() => arr, + _ => return vec![], + }; + let mut lines = Vec::new(); + for att in attachments { + let mime = att.get("mime_type").and_then(|v| v.as_str()).unwrap_or(""); + let filename = attachment_filename(att); + let is_image = mime.starts_with("image/"); + + if !is_image { + lines.push(Line::from(vec![ + Span::raw(" ".repeat(indent)), + Span::styled( + format!("[file {filename}]"), + Style::default().fg(Color::DarkGray), + ), + ])); + continue; + } + + // Try original_file_hash, file_hash, encrypted_file_hash + let hash_hex = extract_hash_hex(att, "original_file_hash") + .or_else(|| extract_hash_hex(att, "file_hash")) + .or_else(|| extract_hash_hex(att, "encrypted_file_hash")) + .unwrap_or_default(); + + // If we have an inline image, reserve blank rows (rendered later) + let has_inline = inline_images + .map(|m| m.contains_key(&hash_hex)) + .unwrap_or(false); + if has_inline { + for _ in 0..INLINE_IMAGE_ROWS { + lines.push(Line::raw("")); + } + continue; + } + + let status = media_downloads.and_then(|m| m.get(&hash_hex)); + let label = match status { + Some(MediaDownload::Downloading) => format!("[downloading {filename}...]"), + Some(MediaDownload::Downloaded(_)) => format!("[loading {filename}...]"), + Some(MediaDownload::Failed(err)) => format!("[{filename} failed: {err}]"), + None => format!("[img {filename}]"), + }; + let color = match status { + Some(MediaDownload::Downloading) => Color::Yellow, + Some(MediaDownload::Downloaded(_)) => Color::Cyan, + Some(MediaDownload::Failed(_)) => Color::Red, + None => Color::DarkGray, + }; + + lines.push(Line::from(vec![ + Span::raw(" ".repeat(indent)), + Span::styled(label, Style::default().fg(color)), + ])); + } + lines +} + +/// Check if a message is a reply and return the replied-to event ID. +fn reply_to_id(msg: &Value) -> Option<&str> { + let reply_id = msg.get("reply_to").and_then(|v| v.as_str())?; + if reply_id.is_empty() { + return None; + } + Some(reply_id) +} + +/// Format a reply indicator line by looking up the parent message. +fn format_reply_line(reply_id: &str, messages: &[Value], indent: usize) -> Line<'static> { + let parent = messages + .iter() + .find(|m| m.get("id").and_then(|v| v.as_str()) == Some(reply_id)); + + let label = if let Some(parent) = parent { + let name = author_name(parent); + let preview: String = content(parent).chars().take(30).collect(); + if preview.chars().count() >= 30 { + format!("reply to {name}: \"{preview}...\"") + } else { + format!("reply to {name}: \"{preview}\"") + } + } else { + let short_id = if reply_id.len() > 12 { + format!("{}...", &reply_id[..12]) + } else { + reply_id.to_string() + }; + format!("reply to {short_id}") + }; + + Line::from(vec![ + Span::raw(" ".repeat(indent)), + Span::styled( + format!("\u{21a9} {label}"), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + ]) +} + /// Build the display lines for a message. /// All messages use the same layout: `[HH:MM] author: content` /// Own messages are distinguished by green author color. -fn format_message(msg: &Value, my_pubkey: Option<&str>) -> Vec> { +fn format_message( + msg: &Value, + my_pubkey: Option<&str>, + media_downloads: Option<&HashMap>, + inline_images: Option<&HashMap>, + all_messages: &[Value], +) -> Vec> { let ts = timestamp(msg); let author = author_name(msg); let text = content(msg); @@ -105,6 +250,12 @@ fn format_message(msg: &Value, my_pubkey: Option<&str>) -> Vec> { let content_lines: Vec<&str> = text.split('\n').collect(); let mut lines = Vec::new(); + + // Reply indicator (before content) + if let Some(reply_id) = reply_to_id(msg) { + lines.push(format_reply_line(reply_id, all_messages, indent)); + } + for (i, line_text) in content_lines.iter().enumerate() { if i == 0 { lines.push(Line::from(vec![ @@ -124,6 +275,13 @@ fn format_message(msg: &Value, my_pubkey: Option<&str>) -> Vec> { lines.push(reaction_line); } + lines.extend(format_attachments( + msg, + indent, + media_downloads, + inline_images, + )); + lines } @@ -137,7 +295,44 @@ fn has_reactions(msg: &Value) -> bool { /// Estimate the rendered height of a message at a given terminal width. /// Accounts for explicit newlines, line wrapping, and a reaction line if present. -fn message_height(msg: &Value, width: usize) -> usize { +/// Calculate total rows needed for attachments, accounting for inline images. +fn attachment_rows( + msg: &Value, + inline_images: Option<&HashMap>, +) -> usize { + let attachments = match msg.get("media_attachments").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => return 0, + }; + let mut rows = 0; + for att in attachments { + let mime = att.get("mime_type").and_then(|v| v.as_str()).unwrap_or(""); + let is_image = mime.starts_with("image/"); + if is_image { + let hash = extract_hash_hex(att, "original_file_hash") + .or_else(|| extract_hash_hex(att, "file_hash")) + .or_else(|| extract_hash_hex(att, "encrypted_file_hash")) + .unwrap_or_default(); + let has_inline = inline_images + .map(|m| m.contains_key(&hash)) + .unwrap_or(false); + rows += if has_inline { + INLINE_IMAGE_ROWS as usize + } else { + 1 + }; + } else { + rows += 1; // text placeholder for non-image + } + } + rows +} + +fn message_height_with_images( + msg: &Value, + width: usize, + inline_images: Option<&HashMap>, +) -> usize { let ts = timestamp(msg); let author = author_name(msg); let text = content(msg); @@ -146,6 +341,11 @@ fn message_height(msg: &Value, width: usize) -> usize { let content_lines: Vec<&str> = text.split('\n').collect(); let mut total_rows = 0; + + if reply_to_id(msg).is_some() { + total_rows += 1; + } + for line_text in &content_lines { total_rows += wrapped_line_count(prefix_width + line_text.width(), width); } @@ -154,9 +354,16 @@ fn message_height(msg: &Value, width: usize) -> usize { total_rows += 1; } + total_rows += attachment_rows(msg, inline_images); + total_rows.max(1) } +#[cfg(test)] +fn message_height(msg: &Value, width: usize) -> usize { + message_height_with_images(msg, width, None) +} + /// Renders the message list. /// `scroll_from_bottom` is how many messages to scroll up from the bottom (0 = at bottom). pub struct MessageListWidget<'a> { @@ -164,6 +371,10 @@ pub struct MessageListWidget<'a> { scroll_from_bottom: usize, block: Option>, my_pubkey: Option<&'a str>, + /// If set, highlight this message index (absolute index into messages slice). + selected: Option, + media_downloads: Option<&'a HashMap>, + inline_images: Option<&'a HashMap>, } impl<'a> MessageListWidget<'a> { @@ -173,6 +384,9 @@ impl<'a> MessageListWidget<'a> { scroll_from_bottom, block: None, my_pubkey: None, + selected: None, + media_downloads: None, + inline_images: None, } } @@ -185,6 +399,134 @@ impl<'a> MessageListWidget<'a> { self.my_pubkey = pubkey; self } + + pub fn selected(mut self, selected: Option) -> Self { + self.selected = selected; + self + } + + pub fn media_downloads(mut self, downloads: &'a HashMap) -> Self { + self.media_downloads = Some(downloads); + self + } + + pub fn inline_images(mut self, images: &'a HashMap) -> Self { + self.inline_images = Some(images); + self + } +} + +impl MessageListWidget<'_> { + /// Compute which messages are visible and their Y positions. + /// Returns (visible_msg_indices, inner_area). + fn compute_visible(&self, area: Rect) -> (Vec<(usize, usize)>, Rect) { + let inner = if let Some(block) = &self.block { + block.inner(area) + } else { + area + }; + + if inner.height == 0 || inner.width == 0 || self.messages.is_empty() { + return (vec![], inner); + } + + let visible_height = inner.height as usize; + let width = inner.width as usize; + let total = self.messages.len(); + let skip_messages = self.scroll_from_bottom.min(total); + + let mut visible_msgs: Vec = Vec::new(); + let mut used_rows = 0; + + let end = total.saturating_sub(skip_messages); + for i in (0..end).rev() { + let h = message_height_with_images(&self.messages[i], width, self.inline_images); + if used_rows + h > visible_height { + break; + } + used_rows += h; + visible_msgs.push(i); + } + visible_msgs.reverse(); + + // Compute Y positions + let mut result = Vec::new(); + let mut y = inner.y as usize; + for idx in visible_msgs { + result.push((idx, y)); + let h = message_height_with_images(&self.messages[idx], width, self.inline_images); + y += h; + } + + (result, inner) + } + + /// Compute the positions where inline images should be rendered. + /// Returns (file_hash_hex, Rect) pairs. + pub fn image_positions(&self, area: Rect) -> Vec<(String, Rect)> { + let (visible, inner) = self.compute_visible(area); + let width = inner.width as usize; + let mut positions = Vec::new(); + + for (idx, base_y) in visible { + let msg = &self.messages[idx]; + let attachments = match msg.get("media_attachments").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => continue, + }; + + // Compute the Y offset where attachments start (after reply + content + reactions) + let text = content(msg); + let ts = timestamp(msg); + let author = author_name(msg); + let ts_prefix = format!("[{ts}] "); + let ts_cols = ts_prefix.width() as u16; + let prefix_width = ts_prefix.width() + format!("{author}: ").width(); + let content_lines: Vec<&str> = text.split('\n').collect(); + let mut row_offset = 0usize; + if reply_to_id(msg).is_some() { + row_offset += 1; + } + for line_text in &content_lines { + row_offset += wrapped_line_count(prefix_width + line_text.width(), width); + } + if has_reactions(msg) { + row_offset += 1; + } + + for att in attachments { + let mime = att.get("mime_type").and_then(|v| v.as_str()).unwrap_or(""); + if !mime.starts_with("image/") { + row_offset += 1; // non-image placeholder line + continue; + } + let hash = extract_hash_hex(att, "original_file_hash") + .or_else(|| extract_hash_hex(att, "file_hash")) + .or_else(|| extract_hash_hex(att, "encrypted_file_hash")) + .unwrap_or_default(); + + let has_inline = self + .inline_images + .map(|m| m.contains_key(&hash)) + .unwrap_or(false); + + if has_inline { + let img_y = base_y + row_offset; + let img_x = inner.x + ts_cols; + let img_w = inner.width.saturating_sub(ts_cols); + positions.push(( + hash, + Rect::new(img_x, img_y as u16, img_w, INLINE_IMAGE_ROWS), + )); + row_offset += INLINE_IMAGE_ROWS as usize; + } else { + row_offset += 1; // text placeholder + } + } + } + + positions + } } impl Widget for MessageListWidget<'_> { @@ -211,19 +553,15 @@ impl Widget for MessageListWidget<'_> { let visible_height = inner.height as usize; let width = inner.width as usize; - - // Walk backwards from the end to find which messages fit in the viewport, - // accounting for wrapped line heights and scroll offset. let total = self.messages.len(); let skip_messages = self.scroll_from_bottom.min(total); - // Collect messages that fit in the visible area, walking from bottom to top - let mut visible_msgs: Vec = Vec::new(); // indices into self.messages + let mut visible_msgs: Vec = Vec::new(); let mut used_rows = 0; let end = total.saturating_sub(skip_messages); for i in (0..end).rev() { - let h = message_height(&self.messages[i], width); + let h = message_height_with_images(&self.messages[i], width, self.inline_images); if used_rows + h > visible_height { break; } @@ -236,14 +574,36 @@ impl Widget for MessageListWidget<'_> { let mut y = inner.y; for &idx in &visible_msgs { let msg = &self.messages[idx]; - let lines = format_message(msg, self.my_pubkey); - let h = message_height(msg, width); + let lines = format_message( + msg, + self.my_pubkey, + self.media_downloads, + self.inline_images, + self.messages, + ); + let h = message_height_with_images(msg, width, self.inline_images); + let is_selected = self.selected == Some(idx); let msg_area = Rect::new(inner.x, y, inner.width, h as u16); + Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(msg_area, buf); + if is_selected { + for row in msg_area.y..msg_area.y + msg_area.height { + for col in msg_area.x..msg_area.x + msg_area.width { + if let Some(cell) = buf.cell_mut((col, row)) { + cell.set_bg(Color::DarkGray); + // Ensure fg contrast: DarkGray-on-DarkGray is invisible + if cell.fg == Color::DarkGray { + cell.set_fg(Color::Gray); + } + } + } + } + } + y += h as u16; if y >= inner.y + inner.height { break; @@ -372,7 +732,7 @@ mod tests { "author": "alice", "created_at_local": "2026-01-01 10:00:00" }); - let lines = format_message(&msg, None); + let lines = format_message(&msg, None, None, None, &[]); assert_eq!(lines.len(), 2, "Should produce 2 Line entries"); } @@ -380,7 +740,7 @@ mod tests { fn format_reactions_empty() { let msg = json!({"content": "hi", "author": "a", "created_at_local": "2026-01-01 10:00:00"}); - let lines = format_message(&msg, None); + let lines = format_message(&msg, None, None, None, &[]); assert_eq!(lines.len(), 1, "No reactions = no extra line"); } @@ -396,7 +756,7 @@ mod tests { } } }); - let lines = format_message(&msg, None); + let lines = format_message(&msg, None, None, None, &[]); assert_eq!(lines.len(), 2, "Should have content + reaction line"); let reaction_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(reaction_text.contains("👍"), "Should contain the emoji"); @@ -417,7 +777,7 @@ mod tests { } } }); - let lines = format_message(&msg, None); + let lines = format_message(&msg, None, None, None, &[]); assert_eq!(lines.len(), 2); let reaction_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(reaction_text.contains("👍")); @@ -454,10 +814,190 @@ mod tests { "author": "me", "created_at_local": "2026-01-01 10:00:00" }); - let lines = format_message(&msg, Some("me")); + let lines = format_message(&msg, Some("me"), None, None, &[]); // Own messages use same layout: [HH:MM] author: content assert_eq!(lines.len(), 1); // First span is timestamp, second is author, third is content assert_eq!(lines[0].spans.len(), 3); } + + #[test] + fn format_message_includes_attachment_lines() { + let downloads = HashMap::from([( + "hash1".to_string(), + MediaDownload::Downloaded("/tmp/photo.png".into()), + )]); + let msg = json!({ + "content": "check this", + "author": "alice", + "created_at_local": "2026-01-01 10:00:00", + "media_attachments": [ + { + "original_file_hash": "hash1", + "mime_type": "image/png", + "filename": "photo.png" + } + ] + }); + let lines = format_message(&msg, None, Some(&downloads), None, &[]); + // 1 content line + 1 attachment line + assert_eq!(lines.len(), 2); + let att_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!( + att_text.contains("[loading photo.png...]"), + "got: {att_text}" + ); + } + + #[test] + fn format_message_shows_downloading_status() { + let downloads = HashMap::from([("hash1".to_string(), MediaDownload::Downloading)]); + let msg = json!({ + "content": "wait", + "author": "a", + "created_at_local": "2026-01-01 10:00:00", + "media_attachments": [ + { + "original_file_hash": "hash1", + "mime_type": "image/jpeg", + "filename": "img.jpg" + } + ] + }); + let lines = format_message(&msg, None, Some(&downloads), None, &[]); + let att_text: String = lines + .last() + .unwrap() + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert!(att_text.contains("downloading"), "got: {att_text}"); + } + + #[test] + fn format_message_shows_non_image_as_file() { + let msg = json!({ + "content": "doc", + "author": "a", + "created_at_local": "2026-01-01 10:00:00", + "media_attachments": [ + { + "original_file_hash": "hash1", + "mime_type": "application/pdf", + "filename": "doc.pdf" + } + ] + }); + let lines = format_message(&msg, None, None, None, &[]); + let att_text: String = lines + .last() + .unwrap() + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert!(att_text.contains("[file doc.pdf]"), "got: {att_text}"); + } + + #[test] + fn message_height_includes_attachments() { + let msg = json!({ + "content": "img", + "author": "a", + "created_at_local": "2026-01-01 10:00:00", + "media_attachments": [ + { + "file_hash": "h1", + "mime_type": "image/png", + "filename": "a.png" + }, + { + "file_hash": "h2", + "mime_type": "image/gif", + "filename": "b.gif" + } + ] + }); + // 1 content line + 2 attachment lines + assert_eq!(message_height(&msg, 80), 3); + } + + #[test] + fn format_message_reply_shows_indicator() { + let parent = json!({ + "id": "parent1", + "content": "original message", + "author": "alice", + "display_name": "Alice", + "created_at_local": "2026-01-01 09:00:00" + }); + let reply = json!({ + "id": "reply1", + "content": "my reply", + "author": "bob", + "reply_to": "parent1", + "is_reply": true, + "created_at_local": "2026-01-01 10:00:00" + }); + let messages = vec![parent, reply.clone()]; + let lines = format_message(&reply, None, None, None, &messages); + // Should have reply indicator line + content line = 2 lines + assert_eq!(lines.len(), 2); + let reply_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!( + reply_text.contains("Alice"), + "should show parent author: {reply_text}" + ); + assert!( + reply_text.contains("original message"), + "should show parent content: {reply_text}" + ); + } + + #[test] + fn format_message_reply_fallback_when_parent_missing() { + let reply = json!({ + "id": "reply1", + "content": "orphaned reply", + "author": "bob", + "reply_to": "unknown_event_id_12345", + "is_reply": true, + "created_at_local": "2026-01-01 10:00:00" + }); + let lines = format_message(&reply, None, None, None, &[]); + assert_eq!(lines.len(), 2); + let reply_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!( + reply_text.contains("unknown_even"), + "should show truncated event id: {reply_text}" + ); + } + + #[test] + fn message_height_includes_reply_line() { + let msg = json!({ + "content": "reply text", + "author": "a", + "reply_to": "parent1", + "is_reply": true, + "created_at_local": "2026-01-01 10:00:00" + }); + // 1 reply indicator + 1 content = 2 + assert_eq!(message_height(&msg, 80), 2); + } + + #[test] + fn non_reply_no_indicator() { + let msg = json!({ + "content": "normal", + "author": "a", + "reply_to": null, + "is_reply": false, + "created_at_local": "2026-01-01 10:00:00" + }); + let lines = format_message(&msg, None, None, None, &[]); + assert_eq!(lines.len(), 1); + assert_eq!(message_height(&msg, 80), 1); + } } diff --git a/src/widget/popup.rs b/src/widget/popup.rs index e4363b6..e3d6c18 100644 --- a/src/widget/popup.rs +++ b/src/widget/popup.rs @@ -137,7 +137,7 @@ impl Widget for PopupWidget<'_> { } /// Calculate a centered rectangle within the given area. -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { +pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { let w = width.min(area.width.saturating_sub(2)); let h = height.min(area.height.saturating_sub(2)); let x = area.x + (area.width.saturating_sub(w)) / 2;