diff --git a/Cargo.lock b/Cargo.lock index 00aa962..2449d7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -38,6 +47,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_colours" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14eec43e0298190790f41679fe69ef7a829d2a2ddd78c8c00339e84710e435fe" +dependencies = [ + "rgb", +] + [[package]] name = "anstream" version = "0.6.19" @@ -121,12 +139,30 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -139,6 +175,18 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytemuck" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -230,6 +278,12 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -250,6 +304,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -266,15 +332,65 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[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.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.9.1", "crossterm_winapi", - "mio", + "mio 1.0.4", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -291,6 +407,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "darling" version = "0.20.11" @@ -343,6 +465,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -368,12 +496,46 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -410,6 +572,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -472,6 +644,16 @@ dependencies = [ "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.31.1" @@ -497,6 +679,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.15.4" @@ -514,6 +706,32 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "html2text" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf7722c2ffdd62628b6e13065b6ab6cf154a236bd476c6e89af1352d745b83e" +dependencies = [ + "html5ever", + "markup5ever", + "nom", + "tendril", + "thiserror", + "unicode-width 0.2.0", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.3.1" @@ -612,7 +830,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -769,6 +987,24 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png", + "qoi", + "tiff", +] + [[package]] name = "indexmap" version = "2.10.0" @@ -804,7 +1040,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "libc", ] @@ -846,6 +1082,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -856,6 +1101,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + [[package]] name = "libc" version = "0.2.174" @@ -905,6 +1162,26 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + [[package]] name = "mastui" version = "0.1.0" @@ -912,8 +1189,11 @@ dependencies = [ "anyhow", "chrono", "clap", - "crossterm", + "crossterm 0.28.1", + "html2text", + "image", "ratatui", + "regex", "reqwest", "serde", "serde_json", @@ -921,6 +1201,18 @@ dependencies = [ "tokio", "url", "urlencoding", + "viuer", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -935,6 +1227,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[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" @@ -942,6 +1240,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", ] [[package]] @@ -973,6 +1284,22 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[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-traits" version = "0.2.19" @@ -1009,7 +1336,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -1082,6 +1409,44 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1100,6 +1465,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -1109,6 +1487,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1118,6 +1502,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quote" version = "1.0.40" @@ -1133,16 +1526,31 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cassowary", "compact_str", - "crossterm", + "crossterm 0.28.1", "indoc", "instability", "itertools", @@ -1154,22 +1562,71 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3a5d9f0aba1dbcec1cc47f0ff94a4b778fe55bca98a6dfa92e4e094e57b1c4" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "reqwest" version = "0.12.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -1203,6 +1660,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -1229,7 +1695,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1242,7 +1708,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", @@ -1315,7 +1781,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation", "core-foundation-sys", "libc", @@ -1399,7 +1865,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.0.4", "signal-hook", ] @@ -1412,6 +1879,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.10" @@ -1452,6 +1931,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1523,7 +2027,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation", "system-configuration-sys", ] @@ -1551,6 +2055,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -1562,6 +2086,37 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1582,7 +2137,7 @@ dependencies = [ "bytes", "io-uring", "libc", - "mio", + "mio 1.0.4", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -1657,7 +2212,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags", + "bitflags 2.9.1", "bytes", "futures-util", "http", @@ -1770,6 +2325,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1788,6 +2349,22 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "viuer" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec2ede5c8814363f92f862892dfe71a266f6816b649ca435aed1ff5e2cf3454e" +dependencies = [ + "ansi_colours", + "base64 0.21.7", + "console", + "crossterm 0.27.0", + "image", + "lazy_static", + "tempfile", + "termcolor", +] + [[package]] name = "want" version = "0.3.1" @@ -1893,6 +2470,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + [[package]] name = "winapi" version = "0.3.9" @@ -1909,6 +2492,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1985,6 +2577,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2012,6 +2613,21 @@ dependencies = [ "windows-targets 0.53.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2044,6 +2660,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2056,6 +2678,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2068,6 +2696,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2092,6 +2726,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2104,6 +2744,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2116,6 +2762,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2128,6 +2780,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2146,7 +2804,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -2238,3 +2896,12 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 0169ba2..36ae77f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,7 @@ anyhow = "1.0" clap = { version = "4.0", features = ["derive"] } urlencoding = "2.1" textwrap = "0.16" +html2text = "0.13" +regex = "1.10" +image = "0.24" +viuer = "0.7" diff --git a/README.md b/README.md index 0da5c65..51beec3 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ mastui post "Hello from the terminal! #mastui" - `n` - Compose new post - `t` - Switch between timelines (Home → Local → Federated) - `m` - View notifications +- `v` - View media attachments - `Ctrl+f` - Favorite/unfavorite status - `Ctrl+b` - Boost/unboost status - `F5` - Refresh timeline @@ -93,6 +94,13 @@ mastui post "Hello from the terminal! #mastui" - `r` - Reply to status - `Escape` / `q` - Return to timeline +### Media View + +- `j` / `↓` - Next media item +- `k` / `↑` - Previous media item +- `Enter` / `o` - Open media externally +- `Escape` / `q` - Close media view + ### General - `Ctrl+C` - Force quit from any view @@ -114,9 +122,10 @@ The configuration file is created automatically during the authentication proces - ✅ Post creation and replies - ✅ Status interactions (favorite, boost) - ✅ Notification viewing -- ✅ Media attachment display (URLs shown) +- ✅ Media attachment viewing and external opening +- ✅ Improved HTML content parsing with styled links - ✅ Account information display -- ⚠️ Image viewing (planned) +- ⚠️ Inline image display (works in compatible terminals) - ⚠️ Search functionality (planned) - ⚠️ Direct messages (planned) diff --git a/src/ui/app.rs b/src/ui/app.rs index e08ac21..04a0734 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,4 +1,6 @@ use crate::api::{MastodonClient, Status, Notification}; +use crate::ui::content::{ContentParser, ExtractedLink}; +use crate::ui::image::{MediaViewer, ImageViewer}; use anyhow::Result; use crossterm::event::KeyEvent; use std::sync::Arc; @@ -9,6 +11,8 @@ pub enum AppMode { Compose, StatusDetail, Notifications, + MediaView, + LinksView, Search, } @@ -33,6 +37,9 @@ pub struct App { pub running: bool, pub loading: bool, pub error_message: Option, + pub media_viewer: MediaViewer, + pub current_links: Vec, + pub selected_link_index: usize, } impl App { @@ -51,6 +58,9 @@ impl App { running: true, loading: false, error_message: None, + media_viewer: MediaViewer::new(), + current_links: Vec::new(), + selected_link_index: 0, } } @@ -193,6 +203,128 @@ impl App { }; } + pub fn view_media(&mut self) { + if let Some(status) = self.statuses.get(self.selected_status_index) { + let display_status = if let Some(reblog) = &status.reblog { + reblog.as_ref() + } else { + status + }; + + if !display_status.media_attachments.is_empty() { + self.media_viewer.show_attachments(display_status.media_attachments.clone()); + self.mode = AppMode::MediaView; + } else { + // No media attachments found + self.error_message = Some("No media attachments in this post".to_string()); + } + } else { + self.error_message = Some("No post selected".to_string()); + } + } + + pub fn open_current_media(&mut self) { + if let Some(status) = self.statuses.get(self.selected_status_index) { + let display_status = if let Some(reblog) = &status.reblog { + reblog.as_ref() + } else { + status + }; + + if !display_status.media_attachments.is_empty() { + if let Some(attachment) = display_status.media_attachments.first() { + if let Err(e) = ImageViewer::open_image_externally(&attachment.url) { + self.error_message = Some(format!("Failed to open media: {}", e)); + } + } + } else { + self.error_message = Some("No media in this post".to_string()); + } + } + } + + pub fn view_links(&mut self) { + if let Some(status) = self.statuses.get(self.selected_status_index) { + let display_status = if let Some(reblog) = &status.reblog { + reblog.as_ref() + } else { + status + }; + + let parser = ContentParser::new(); + let parsed_content = parser.parse_content(&display_status.content); + + // Debug: show what we found + if parsed_content.links.is_empty() { + // Debug message showing the raw HTML for troubleshooting + let debug_msg = if display_status.content.contains(" tags but no parsed links. Raw content length: {}", display_status.content.len()) + } else { + "No tags found in HTML content".to_string() + }; + self.error_message = Some(debug_msg); + } else { + self.current_links = parsed_content.links; + self.selected_link_index = 0; + self.mode = AppMode::LinksView; + } + } else { + self.error_message = Some("No post selected".to_string()); + } + } + + pub fn open_current_link(&mut self) { + if let Some(link) = self.current_links.get(self.selected_link_index) { + if let Err(e) = ImageViewer::open_image_externally(&link.url) { + self.error_message = Some(format!("Failed to open link: {}", e)); + } + } + } + + pub fn next_link(&mut self) { + if self.selected_link_index < self.current_links.len().saturating_sub(1) { + self.selected_link_index += 1; + } + } + + pub fn previous_link(&mut self) { + if self.selected_link_index > 0 { + self.selected_link_index -= 1; + } + } + + pub fn debug_post(&mut self) { + if let Some(status) = self.statuses.get(self.selected_status_index) { + let display_status = if let Some(reblog) = &status.reblog { + reblog.as_ref() + } else { + status + }; + + // Extract just the link tags for debugging + let content = &display_status.content; + let mut link_snippets = Vec::new(); + let mut start = 0; + + while let Some(pos) = content[start..].find("").map(|i| actual_pos + i + 4).unwrap_or(content.len().min(actual_pos + 200)); + let snippet = &content[actual_pos..end_tag]; + link_snippets.push(snippet.to_string()); + start = end_tag; + if link_snippets.len() >= 3 { break; } // Limit to first 3 links + } + + if link_snippets.is_empty() { + self.error_message = Some("No tags found in content".to_string()); + } else { + self.error_message = Some(format!("Link tags: {}", link_snippets.join(" | "))); + } + } else { + self.error_message = Some("No post selected".to_string()); + } + } + pub fn quit(&mut self) { self.running = false; } @@ -203,6 +335,8 @@ impl App { AppMode::Compose => self.handle_compose_key(key), AppMode::StatusDetail => self.handle_detail_key(key), AppMode::Notifications => self.handle_notifications_key(key), + AppMode::MediaView => self.handle_media_key(key), + AppMode::LinksView => self.handle_links_key(key), AppMode::Search => self.handle_search_key(key), } } @@ -223,6 +357,10 @@ impl App { } (KeyCode::Char('t'), _) => self.next_timeline(), (KeyCode::Char('m'), _) => self.mode = AppMode::Notifications, + (KeyCode::Char('v'), _) => self.view_media(), + (KeyCode::Char('l'), _) => self.view_links(), + (KeyCode::Char('d'), _) => self.debug_post(), + (KeyCode::Char('o'), _) => self.open_current_media(), (KeyCode::Enter, _) => self.mode = AppMode::StatusDetail, _ => {} } @@ -278,6 +416,60 @@ impl App { } } + fn handle_media_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => { + self.media_viewer.close(); + self.mode = AppMode::Timeline; + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.media_viewer.close(); + self.mode = AppMode::Timeline; + } + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { + self.media_viewer.next_attachment(); + } + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { + self.media_viewer.previous_attachment(); + } + (KeyCode::Enter, _) | (KeyCode::Char('o'), _) => { + if let Err(e) = self.media_viewer.view_current() { + self.error_message = Some(format!("Failed to open media: {}", e)); + } + } + _ => {} + } + } + + fn handle_links_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => { + self.current_links.clear(); + self.selected_link_index = 0; + self.mode = AppMode::Timeline; + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.current_links.clear(); + self.selected_link_index = 0; + self.mode = AppMode::Timeline; + } + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { + self.next_link(); + } + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { + self.previous_link(); + } + (KeyCode::Enter, _) | (KeyCode::Char('o'), _) => { + self.open_current_link(); + } + _ => {} + } + } + fn handle_search_key(&mut self, key: KeyEvent) { use crossterm::event::{KeyCode, KeyModifiers}; diff --git a/src/ui/content.rs b/src/ui/content.rs new file mode 100644 index 0000000..a102a21 --- /dev/null +++ b/src/ui/content.rs @@ -0,0 +1,198 @@ +use html2text::from_read; +use regex::Regex; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use std::io::Cursor; + +pub struct ContentParser { + link_regex: Regex, + mention_regex: Regex, + hashtag_regex: Regex, +} + +#[derive(Debug, Clone)] +pub struct ParsedContent { + pub lines: Vec>, + pub links: Vec, +} + +#[derive(Debug, Clone)] +pub struct ExtractedLink { + pub url: String, + pub display_text: String, + pub link_type: LinkType, +} + +#[derive(Debug, Clone)] +pub enum LinkType { + Regular, + Mention, + Hashtag, +} + +impl ContentParser { + pub fn new() -> Self { + Self { + // More flexible regex patterns + link_regex: Regex::new(r#"]*href=['"]([^'"]*)['"][^>]*>([^<]*)"#).unwrap(), + mention_regex: Regex::new(r#"]*class=['"][^'"]*mention[^'"]*['"][^>]*href=['"]([^'"]*)['"][^>]*>([^<]*)"#).unwrap(), + hashtag_regex: Regex::new(r#"]*class=['"][^'"]*hashtag[^'"]*['"][^>]*href=['"]([^'"]*)['"][^>]*>#?([^<]*)"#).unwrap(), + } + } + + pub fn parse_content(&self, html: &str) -> ParsedContent { + self.parse_content_with_links(html) + } + + pub fn parse_content_simple(&self, html: &str) -> Vec> { + self.parse_content_with_links(html).lines + } + + pub fn parse_content_with_links(&self, html: &str) -> ParsedContent { + let mut content = html.to_string(); + let mut extracted_links = Vec::new(); + + // More flexible regex that handles malformed attributes and various quote styles + let link_regex = Regex::new(r#"]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)"#).unwrap(); + + for cap in link_regex.captures_iter(html) { + if let (Some(url_match), Some(text_match)) = (cap.get(1), cap.get(2)) { + let url = url_match.as_str().to_string(); + let text = text_match.as_str().to_string(); + let full_match = cap.get(0).unwrap().as_str(); + + // Determine link type based on class attributes or content + let link_type = if full_match.contains("class=") { + if full_match.contains("mention") { + LinkType::Mention + } else if full_match.contains("hashtag") { + LinkType::Hashtag + } else { + LinkType::Regular + } + } else if text.starts_with('@') { + LinkType::Mention + } else if text.starts_with('#') { + LinkType::Hashtag + } else { + LinkType::Regular + }; + + let display_text = if text.trim().is_empty() { + Self::shorten_url(&url) + } else { + text.trim().to_string() + }; + + // Replace in content for display + let marker = match &link_type { + LinkType::Mention => format!("MENTION:{}", text.trim()), + LinkType::Hashtag => format!("HASHTAG:{}", text.trim()), + LinkType::Regular => format!("LINK:{}", if text.trim().is_empty() { Self::shorten_url(&url) } else { text.trim().to_string() }), + }; + + extracted_links.push(ExtractedLink { + url, + display_text, + link_type, + }); + content = content.replace(full_match, &marker); + } + } + + // Convert remaining HTML to plain text + let plain_text = match from_read(Cursor::new(content.as_bytes()), 80) { + Ok(text) => text, + Err(_) => content, // Fallback to original if parsing fails + }; + + // Split into lines and apply styling + let lines: Vec> = plain_text + .lines() + .map(|line| self.style_line(line)) + .collect(); + + ParsedContent { + lines, + links: extracted_links, + } + } + + fn style_line(&self, line: &str) -> Line<'static> { + let mut spans = Vec::new(); + let mut current_pos = 0; + + // Find all special markers + let markers = [ + ("MENTION:", Color::Blue), + ("HASHTAG:", Color::Cyan), + ("LINK:", Color::Yellow), + ]; + + for &(marker, color) in &markers { + while let Some(pos) = line[current_pos..].find(marker) { + let actual_pos = current_pos + pos; + + // Add text before the marker + if actual_pos > current_pos { + spans.push(Span::raw(line[current_pos..actual_pos].to_string())); + } + + // Find the end of the marked text (next space or end of line) + let start = actual_pos + marker.len(); + let end = line[start..] + .find(' ') + .map(|i| start + i) + .unwrap_or(line.len()); + + let marked_text = line[start..end].to_string(); + spans.push(Span::styled( + marked_text, + Style::default() + .fg(color) + .add_modifier(Modifier::UNDERLINED) + .add_modifier(Modifier::BOLD), + )); + + current_pos = end; + } + } + + // Add any remaining text + if current_pos < line.len() { + spans.push(Span::raw(line[current_pos..].to_string())); + } + + // If no special content was found, just return the whole line + if spans.is_empty() { + Line::from(Span::raw(line.to_string())) + } else { + Line::from(spans) + } + } + + fn shorten_url(url: &str) -> String { + if url.len() <= 40 { + return url.to_string(); + } + + // Try to extract domain for better readability + if let Ok(parsed_url) = url::Url::parse(url) { + if let Some(domain) = parsed_url.domain() { + if domain.len() < 30 { + return format!("{}...", domain); + } + } + } + + format!("{}...", &url[..37]) + } +} + +impl Default for ContentParser { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/src/ui/detail.rs b/src/ui/detail.rs index 9e16d7a..fcb48b5 100644 --- a/src/ui/detail.rs +++ b/src/ui/detail.rs @@ -1,4 +1,6 @@ use crate::ui::app::App; +use crate::ui::content::ContentParser; +use crate::ui::image::ImageViewer; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, @@ -70,30 +72,20 @@ fn render_detail_content(f: &mut Frame, app: &App, area: Rect) { lines.push(Line::from("")); - let content = strip_html(&display_status.content); - for line in content.lines() { - lines.push(Line::from(Span::raw(line))); - } + // Parse content with improved HTML handling + let parser = ContentParser::new(); + let content_lines = parser.parse_content_simple(&display_status.content); + lines.extend(content_lines); if !display_status.media_attachments.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Media Attachments:", - Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), - ))); - - for (i, attachment) in display_status.media_attachments.iter().enumerate() { - lines.push(Line::from(vec![ - Span::styled(format!("{}. ", i + 1), Style::default().fg(Color::Gray)), - Span::raw(format!("{:?}: ", attachment.media_type)), - Span::styled(&attachment.url, Style::default().fg(Color::Blue)), - ])); - - if let Some(description) = &attachment.description { - lines.push(Line::from(vec![ - Span::raw(" Alt text: "), - Span::styled(description, Style::default().fg(Color::Gray)), - ])); + let media_info = ImageViewer::show_media_attachments(&display_status.media_attachments); + for media_line in media_info.lines() { + if !media_line.trim().is_empty() { + lines.push(Line::from(Span::styled( + media_line.to_string(), + Style::default().fg(Color::Magenta), + ))); } } } diff --git a/src/ui/image.rs b/src/ui/image.rs new file mode 100644 index 0000000..52aebb3 --- /dev/null +++ b/src/ui/image.rs @@ -0,0 +1,169 @@ +use crate::api::MediaAttachment; +use anyhow::Result; +use std::process::Command; + +pub struct ImageViewer; + +impl ImageViewer { + pub fn can_display_images() -> bool { + // Check if we're in a terminal that supports images (kitty, iTerm2, etc.) + std::env::var("TERM_PROGRAM").map_or(false, |term| { + matches!(term.as_str(), "iTerm.app" | "WezTerm") + }) || std::env::var("KITTY_WINDOW_ID").is_ok() + } + + pub fn display_image_inline(url: &str) -> Result<()> { + // For now, always open externally since inline display has compatibility issues + Self::open_image_externally(url) + } + + pub fn open_image_externally(url: &str) -> Result<()> { + // Try to open with system default image viewer + let result = if cfg!(target_os = "macos") { + Command::new("open").arg(url).spawn() + } else if cfg!(target_os = "windows") { + Command::new("cmd").args(["/c", "start", "", url]).spawn() + } else { + // Linux/Unix + Command::new("xdg-open").arg(url).spawn() + }; + + match result { + Ok(mut child) => { + // Don't wait for the process to finish, just let it run + let _ = child.wait(); + Ok(()) + } + Err(e) => { + // Fallback: try other common browsers/viewers + if cfg!(target_os = "linux") { + // Try firefox as fallback + if let Ok(mut child) = Command::new("firefox").arg(url).spawn() { + let _ = child.wait(); + return Ok(()); + } + // Try chromium as fallback + if let Ok(mut child) = Command::new("chromium").arg(url).spawn() { + let _ = child.wait(); + return Ok(()); + } + } + Err(anyhow::anyhow!("Failed to open URL: {}", e)) + } + } + } + + pub fn show_media_attachments(attachments: &[MediaAttachment]) -> String { + if attachments.is_empty() { + return String::new(); + } + + let mut result = String::new(); + result.push_str("📎 Media:\n"); + + for (i, attachment) in attachments.iter().enumerate() { + let media_type = match attachment.media_type { + crate::api::MediaType::Image => "🖼️ Image", + crate::api::MediaType::Video => "🎥 Video", + crate::api::MediaType::Audio => "🔊 Audio", + crate::api::MediaType::Gifv => "🎞️ GIF", + crate::api::MediaType::Unknown => "📎 File", + }; + + result.push_str(&format!(" {}. {}", i + 1, media_type)); + + if let Some(description) = &attachment.description { + result.push_str(&format!(" - {}", description)); + } + + result.push('\n'); + result.push_str(&format!(" {}", Self::shorten_url(&attachment.url))); + result.push('\n'); + } + + result + } + + fn shorten_url(url: &str) -> String { + if url.len() <= 60 { + return url.to_string(); + } + format!("{}...", &url[..57]) + } +} + +// Media viewing mode for the app +#[derive(Debug, Clone, PartialEq)] +pub enum MediaViewMode { + None, + List, + Viewing { index: usize }, +} + +pub struct MediaViewer { + pub mode: MediaViewMode, + pub attachments: Vec, + pub selected_index: usize, +} + +impl MediaViewer { + pub fn new() -> Self { + Self { + mode: MediaViewMode::None, + attachments: Vec::new(), + selected_index: 0, + } + } + + pub fn show_attachments(&mut self, attachments: Vec) { + self.attachments = attachments; + self.selected_index = 0; + self.mode = if self.attachments.is_empty() { + MediaViewMode::None + } else { + MediaViewMode::List + }; + } + + pub fn view_current(&mut self) -> Result<()> { + if let Some(attachment) = self.attachments.get(self.selected_index) { + self.mode = MediaViewMode::Viewing { + index: self.selected_index, + }; + + match attachment.media_type { + crate::api::MediaType::Image | crate::api::MediaType::Gifv => { + ImageViewer::display_image_inline(&attachment.url)?; + } + _ => { + ImageViewer::open_image_externally(&attachment.url)?; + } + } + } + Ok(()) + } + + pub fn next_attachment(&mut self) { + if self.selected_index < self.attachments.len().saturating_sub(1) { + self.selected_index += 1; + } + } + + pub fn previous_attachment(&mut self) { + if self.selected_index > 0 { + self.selected_index -= 1; + } + } + + pub fn close(&mut self) { + self.mode = MediaViewMode::None; + self.attachments.clear(); + self.selected_index = 0; + } +} + +impl Default for MediaViewer { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6d4c970..fc2a927 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,8 @@ pub mod app; pub mod compose; +pub mod content; pub mod detail; +pub mod image; pub mod timeline; use crate::ui::app::{App, AppMode}; @@ -15,7 +17,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Paragraph, Wrap}, Frame, Terminal, }; use std::io; @@ -109,6 +111,12 @@ fn ui(f: &mut Frame, app: &App) { AppMode::Notifications => { render_notifications(f, app, size); } + AppMode::MediaView => { + render_media_view(f, app, size); + } + AppMode::LinksView => { + render_links_view(f, app, size); + } AppMode::Search => { render_search(f, app, size); } @@ -130,6 +138,12 @@ fn render_help(f: &mut Frame, area: Rect) { Span::raw(" timeline • "), Span::styled("Enter", Style::default().fg(Color::Cyan)), Span::raw(" detail • "), + Span::styled("v", Style::default().fg(Color::Cyan)), + Span::raw(" media • "), + Span::styled("l", Style::default().fg(Color::Cyan)), + Span::raw(" links • "), + Span::styled("o", Style::default().fg(Color::Cyan)), + Span::raw(" open • "), Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)), Span::raw(" fav • "), Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)), @@ -190,6 +204,141 @@ fn render_notifications(f: &mut Frame, app: &App, area: Rect) { } } +fn render_media_view(f: &mut Frame, app: &App, area: Rect) { + use crate::ui::image::{MediaViewMode, ImageViewer}; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)].as_ref()) + .split(area); + + // Header + let header = Paragraph::new("Media Viewer") + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Magenta)); + f.render_widget(header, chunks[0]); + + // Content + match &app.media_viewer.mode { + MediaViewMode::None => { + let paragraph = Paragraph::new("No media to display") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(paragraph, chunks[1]); + } + MediaViewMode::List => { + let media_info = ImageViewer::show_media_attachments(&app.media_viewer.attachments); + let mut content = format!("Selected: {} of {}\n\n", + app.media_viewer.selected_index + 1, + app.media_viewer.attachments.len() + ); + content.push_str(&media_info); + + let paragraph = Paragraph::new(content) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Color::Magenta)); + f.render_widget(paragraph, chunks[1]); + } + MediaViewMode::Viewing { index } => { + let content = format!("Viewing media {} of {} (opened externally)", + index + 1, + app.media_viewer.attachments.len() + ); + + let paragraph = Paragraph::new(content) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Green)); + f.render_widget(paragraph, chunks[1]); + } + } + + // Instructions + let instructions = vec![ + Line::from(vec![ + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::raw("/"), + Span::styled("↑↓", Style::default().fg(Color::Cyan)), + Span::raw(" navigate • "), + Span::styled("Enter/o", Style::default().fg(Color::Cyan)), + Span::raw(" open • "), + Span::styled("Escape/q", Style::default().fg(Color::Cyan)), + Span::raw(" close"), + ]), + ]; + + let help = Paragraph::new(instructions) + .block(Block::default().borders(Borders::ALL).title("Controls")) + .style(Style::default().fg(Color::Gray)); + f.render_widget(help, chunks[2]); +} + +fn render_links_view(f: &mut Frame, app: &App, area: Rect) { + use crate::ui::content::LinkType; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)].as_ref()) + .split(area); + + // Header + let header = Paragraph::new("Links in Post") + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(header, chunks[0]); + + // Content + if app.current_links.is_empty() { + let paragraph = Paragraph::new("No links to display") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(paragraph, chunks[1]); + } else { + let mut content = format!("Selected: {} of {}\n\n", + app.selected_link_index + 1, + app.current_links.len() + ); + + for (i, link) in app.current_links.iter().enumerate() { + let prefix = if i == app.selected_link_index { "▶ " } else { " " }; + let link_type_icon = match link.link_type { + LinkType::Regular => "🔗", + LinkType::Mention => "👤", + LinkType::Hashtag => "#️⃣", + }; + + content.push_str(&format!("{}{}. {} {}\n", + prefix, i + 1, link_type_icon, link.display_text)); + content.push_str(&format!(" {}\n\n", link.url)); + } + + let paragraph = Paragraph::new(content) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(paragraph, chunks[1]); + } + + // Instructions + let instructions = vec![ + Line::from(vec![ + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::raw("/"), + Span::styled("↑↓", Style::default().fg(Color::Cyan)), + Span::raw(" navigate • "), + Span::styled("Enter/o", Style::default().fg(Color::Cyan)), + Span::raw(" open • "), + Span::styled("Escape/q", Style::default().fg(Color::Cyan)), + Span::raw(" close"), + ]), + ]; + + let help = Paragraph::new(instructions) + .block(Block::default().borders(Borders::ALL).title("Controls")) + .style(Style::default().fg(Color::Gray)); + f.render_widget(help, chunks[2]); +} + fn render_search(f: &mut Frame, _app: &App, _area: Rect) { let paragraph = Paragraph::new("Search functionality coming soon!") .block(Block::default().borders(Borders::ALL).title("Search")) diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index 770fe19..c1fd336 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -1,5 +1,7 @@ use crate::api::Status; use crate::ui::app::{App, TimelineType}; +use crate::ui::content::ContentParser; +use crate::ui::image::ImageViewer; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, @@ -9,13 +11,27 @@ use ratatui::{ }; pub fn render_timeline(f: &mut Frame, app: &App, area: Rect) { + let constraints: &[Constraint] = if app.error_message.is_some() { + &[Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)] + } else { + &[Constraint::Length(3), Constraint::Min(0)] + }; + let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) + .constraints(constraints) .split(area); render_header(f, app, chunks[0]); render_status_list(f, app, chunks[1]); + + if let Some(error) = &app.error_message { + let error_widget = Paragraph::new(error.clone()) + .block(Block::default().borders(Borders::ALL).title("Debug/Error")) + .style(Style::default().fg(Color::Red)) + .wrap(Wrap { trim: true }); + f.render_widget(error_widget, chunks[2]); + } } fn render_header(f: &mut Frame, app: &App, area: Rect) { @@ -111,20 +127,32 @@ fn format_status(status: &Status, is_selected: bool) -> ListItem { ), ])); - let content = strip_html(&display_status.content); - let content_lines: Vec = textwrap::wrap(&content, 70) - .into_iter() - .map(|cow| cow.into_owned()) - .collect(); + // Parse content with improved HTML handling + let parser = ContentParser::new(); + let content_lines = parser.parse_content_simple(&display_status.content); + // Show first 3 lines of content for line in content_lines.iter().take(3) { - lines.push(Line::from(Span::raw(line.clone()))); + lines.push(line.clone()); } if content_lines.len() > 3 { lines.push(Line::from(Span::styled("...", Style::default().fg(Color::Gray)))); } + // Show media attachments with better formatting + if !display_status.media_attachments.is_empty() { + let media_info = ImageViewer::show_media_attachments(&display_status.media_attachments); + for media_line in media_info.lines() { + if !media_line.trim().is_empty() { + lines.push(Line::from(Span::styled( + media_line.to_string(), + Style::default().fg(Color::Magenta), + ))); + } + } + } + let mut stats = Vec::new(); if display_status.replies_count > 0 { @@ -146,13 +174,6 @@ fn format_status(status: &Status, is_selected: bool) -> ListItem { ))); } - if !display_status.media_attachments.is_empty() { - lines.push(Line::from(Span::styled( - format!("📎 {} attachment(s)", display_status.media_attachments.len()), - Style::default().fg(Color::Magenta), - ))); - } - lines.push(Line::from(Span::raw(""))); ListItem::new(Text::from(lines))