diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5267bdd --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Rust +/target/ +**/*.rs.bk + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Configuration (contains sensitive data) +config.json +*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..00aa962 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2240 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[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 = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mastui" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "crossterm", + "ratatui", + "reqwest", + "serde", + "serde_json", + "textwrap", + "tokio", + "url", + "urlencoding", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +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 = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3a5d9f0aba1dbcec1cc47f0ff94a4b778fe55bca98a6dfa92e4e094e57b1c4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.23.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.0.8", + "windows-sys 0.59.0", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.0", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0169ba2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mastui" +version = "0.1.0" +edition = "2024" + +[dependencies] +ratatui = "0.29" +crossterm = "0.28" +tokio = { version = "1.0", features = ["full"] } +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +url = "2.5" +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1.0" +clap = { version = "4.0", features = ["derive"] } +urlencoding = "2.1" +textwrap = "0.16" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0da5c65 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Mastui - A TUI Client for Mastodon + +Mastui is a terminal user interface (TUI) client for Mastodon, built in Rust using the ratatui library. It provides a fast, keyboard-driven way to interact with Mastodon instances directly from your terminal. + +## Features + +- 🏠 **Timeline Viewing**: Browse Home, Local, and Federated timelines +- ✏️ **Post Creation**: Compose new posts and replies +- 💬 **Interactive Navigation**: Keyboard shortcuts for efficient navigation +- ⭐ **Status Interactions**: Like, boost, and reply to posts +- 🔐 **OAuth Authentication**: Secure authentication with Mastodon instances +- 📱 **Multiple Views**: Timeline, compose, status detail, and notifications + +## Installation + +### Prerequisites + +- Rust 1.70+ (for compiling from source) + +### Building from Source + +```bash +git clone https://github.com/yourusername/mastui.git +cd mastui +cargo build --release +``` + +The binary will be available at `target/release/mastui`. + +## Usage + +### Authentication + +Before using Mastui, you need to authenticate with a Mastodon instance: + +```bash +mastui auth mastodon.social +``` + +Replace `mastodon.social` with your preferred Mastodon instance. This will: + +1. Register Mastui as an application on your instance +2. Open a browser window for authorization +3. Prompt you to enter the authorization code +4. Save your credentials for future use + +### Starting the TUI + +Once authenticated, start the TUI interface: + +```bash +mastui +``` + +Or explicitly run the timeline view: + +```bash +mastui timeline +``` + +### Command Line Posting + +You can also post directly from the command line: + +```bash +mastui post "Hello from the terminal! #mastui" +``` + +## Keyboard Shortcuts + +### Timeline View + +- `j` / `↓` - Move down to next status +- `k` / `↑` - Move up to previous status +- `Enter` - View status details +- `r` - Reply to selected status +- `n` - Compose new post +- `t` - Switch between timelines (Home → Local → Federated) +- `m` - View notifications +- `Ctrl+f` - Favorite/unfavorite status +- `Ctrl+b` - Boost/unboost status +- `F5` - Refresh timeline +- `q` - Quit application + +### Compose View + +- Type to compose your post +- `Ctrl+Enter` - Send post +- `Escape` - Cancel and return to timeline + +### Status Detail View + +- `r` - Reply to status +- `Escape` / `q` - Return to timeline + +### General + +- `Ctrl+C` - Force quit from any view + +## Configuration + +Mastui stores its configuration in `~/.config/mastui/config.json`. This file contains: + +- Instance URL +- Client credentials +- Access token +- User information + +The configuration file is created automatically during the authentication process. + +## Supported Mastodon Features + +- ✅ Timeline viewing (Home, Local, Federated) +- ✅ Post creation and replies +- ✅ Status interactions (favorite, boost) +- ✅ Notification viewing +- ✅ Media attachment display (URLs shown) +- ✅ Account information display +- ⚠️ Image viewing (planned) +- ⚠️ Search functionality (planned) +- ⚠️ Direct messages (planned) + +## Architecture + +Mastui is built with: + +- **Rust** - Systems programming language +- **ratatui** - Terminal user interface library +- **tokio** - Async runtime +- **reqwest** - HTTP client for API requests +- **serde** - Serialization/deserialization +- **crossterm** - Cross-platform terminal manipulation + +The application follows a modular architecture: + +- `api/` - Mastodon API client and data types +- `config/` - Configuration management +- `ui/` - TUI components and rendering + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. + +### Development Setup + +1. Clone the repository +2. Run `cargo build` to compile +3. Run `cargo test` to run tests +4. Use `cargo run -- auth ` to test authentication +5. Use `cargo run` to test the TUI + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- The Mastodon team for creating an amazing decentralized social platform +- The ratatui community for the excellent TUI framework +- The Rust community for the robust ecosystem \ No newline at end of file diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 0000000..52a93fe --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,284 @@ +use crate::api::types::*; +use anyhow::{anyhow, Result}; +use reqwest::{Client, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use url::Url; + +#[derive(Debug, Clone)] +pub struct MastodonClient { + client: Client, + instance_url: String, + access_token: Option, +} + +impl MastodonClient { + pub fn new(instance_url: String) -> Self { + Self { + client: Client::new(), + instance_url: instance_url.trim_end_matches('/').to_string(), + access_token: None, + } + } + + pub fn with_token(instance_url: String, access_token: String) -> Self { + Self { + client: Client::new(), + instance_url: instance_url.trim_end_matches('/').to_string(), + access_token: Some(access_token), + } + } + + fn build_url(&self, endpoint: &str) -> String { + format!("{}{}", self.instance_url, endpoint) + } + + async fn get(&self, endpoint: &str) -> Result { + let url = self.build_url(endpoint); + let mut req = self.client.get(&url); + + if let Some(token) = &self.access_token { + req = req.header("Authorization", format!("Bearer {}", token)); + } + + let response = req.send().await?; + + if !response.status().is_success() { + return Err(anyhow!("API request failed: {}", response.status())); + } + + Ok(response) + } + + async fn post(&self, endpoint: &str, data: Option<&Value>) -> Result { + let url = self.build_url(endpoint); + let mut req = self.client.post(&url); + + if let Some(token) = &self.access_token { + req = req.header("Authorization", format!("Bearer {}", token)); + } + + if let Some(data) = data { + req = req.json(data); + } + + let response = req.send().await?; + + if !response.status().is_success() { + return Err(anyhow!("API request failed: {}", response.status())); + } + + Ok(response) + } + + pub async fn register_app( + &self, + app_name: &str, + redirect_uris: &str, + scopes: &str, + website: Option<&str>, + ) -> Result { + let mut data = HashMap::new(); + data.insert("client_name", app_name); + data.insert("redirect_uris", redirect_uris); + data.insert("scopes", scopes); + + if let Some(website) = website { + data.insert("website", website); + } + + let response = self + .client + .post(&self.build_url("/api/v1/apps")) + .json(&data) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow!("Failed to register app: {}", response.status())); + } + + let app: AppRegistration = response.json().await?; + Ok(app) + } + + pub fn get_auth_url(&self, client_id: &str, redirect_uri: &str, scopes: &str) -> Result { + let mut url = Url::parse(&format!("{}/oauth/authorize", self.instance_url))?; + url.query_pairs_mut() + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", scopes); + + Ok(url.to_string()) + } + + pub async fn get_access_token( + &self, + client_id: &str, + client_secret: &str, + redirect_uri: &str, + code: &str, + ) -> Result { + let mut data = HashMap::new(); + data.insert("client_id", client_id); + data.insert("client_secret", client_secret); + data.insert("redirect_uri", redirect_uri); + data.insert("grant_type", "authorization_code"); + data.insert("code", code); + + let response = self + .client + .post(&self.build_url("/oauth/token")) + .form(&data) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow!("Failed to get access token: {}", response.status())); + } + + let token: AccessToken = response.json().await?; + Ok(token) + } + + pub async fn verify_credentials(&self) -> Result { + let response = self.get("/api/v1/accounts/verify_credentials").await?; + let account: Account = response.json().await?; + Ok(account) + } + + pub async fn get_home_timeline(&self, limit: Option) -> Result> { + let mut endpoint = "/api/v1/timelines/home".to_string(); + if let Some(limit) = limit { + endpoint.push_str(&format!("?limit={}", limit)); + } + + let response = self.get(&endpoint).await?; + let statuses: Vec = response.json().await?; + Ok(statuses) + } + + pub async fn get_public_timeline(&self, local: bool, limit: Option) -> Result> { + let mut endpoint = "/api/v1/timelines/public".to_string(); + let mut params = vec![]; + + if local { + params.push("local=true".to_string()); + } + + if let Some(limit) = limit { + params.push(format!("limit={}", limit)); + } + + if !params.is_empty() { + endpoint.push('?'); + endpoint.push_str(¶ms.join("&")); + } + + let response = self.get(&endpoint).await?; + let statuses: Vec = response.json().await?; + Ok(statuses) + } + + pub async fn get_status(&self, id: &str) -> Result { + let endpoint = format!("/api/v1/statuses/{}", id); + let response = self.get(&endpoint).await?; + let status: Status = response.json().await?; + Ok(status) + } + + pub async fn get_status_context(&self, id: &str) -> Result { + let endpoint = format!("/api/v1/statuses/{}/context", id); + let response = self.get(&endpoint).await?; + let context: Context = response.json().await?; + Ok(context) + } + + pub async fn post_status( + &self, + content: &str, + in_reply_to_id: Option<&str>, + visibility: Option, + ) -> Result { + let mut data = HashMap::new(); + data.insert("status", content); + + if let Some(reply_id) = in_reply_to_id { + data.insert("in_reply_to_id", reply_id); + } + + if let Some(vis) = visibility { + let vis_str = match vis { + Visibility::Public => "public", + Visibility::Unlisted => "unlisted", + Visibility::Private => "private", + Visibility::Direct => "direct", + }; + data.insert("visibility", vis_str); + } + + let json_data = serde_json::to_value(data)?; + let response = self.post("/api/v1/statuses", Some(&json_data)).await?; + let status: Status = response.json().await?; + Ok(status) + } + + pub async fn favourite_status(&self, id: &str) -> Result { + let endpoint = format!("/api/v1/statuses/{}/favourite", id); + let response = self.post(&endpoint, None).await?; + let status: Status = response.json().await?; + Ok(status) + } + + pub async fn unfavourite_status(&self, id: &str) -> Result { + let endpoint = format!("/api/v1/statuses/{}/unfavourite", id); + let response = self.post(&endpoint, None).await?; + let status: Status = response.json().await?; + Ok(status) + } + + pub async fn reblog_status(&self, id: &str) -> Result { + let endpoint = format!("/api/v1/statuses/{}/reblog", id); + let response = self.post(&endpoint, None).await?; + let status: Status = response.json().await?; + Ok(status) + } + + pub async fn unreblog_status(&self, id: &str) -> Result { + let endpoint = format!("/api/v1/statuses/{}/unreblog", id); + let response = self.post(&endpoint, None).await?; + let status: Status = response.json().await?; + Ok(status) + } + + pub async fn get_notifications(&self, limit: Option) -> Result> { + let mut endpoint = "/api/v1/notifications".to_string(); + if let Some(limit) = limit { + endpoint.push_str(&format!("?limit={}", limit)); + } + + let response = self.get(&endpoint).await?; + let notifications: Vec = response.json().await?; + Ok(notifications) + } + + pub async fn search(&self, query: &str, resolve: bool) -> Result { + let mut endpoint = format!("/api/v2/search?q={}", urlencoding::encode(query)); + if resolve { + endpoint.push_str("&resolve=true"); + } + + let response = self.get(&endpoint).await?; + let results: SearchResults = response.json().await?; + Ok(results) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SearchResults { + pub accounts: Vec, + pub statuses: Vec, + pub hashtags: Vec, +} \ No newline at end of file diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..c93eff2 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod types; + +pub use client::MastodonClient; +pub use types::*; \ No newline at end of file diff --git a/src/api/types.rs b/src/api/types.rs new file mode 100644 index 0000000..fde56cf --- /dev/null +++ b/src/api/types.rs @@ -0,0 +1,241 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Account { + pub id: String, + pub username: String, + pub acct: String, + pub display_name: String, + pub locked: bool, + pub bot: bool, + pub discoverable: Option, + pub group: bool, + pub created_at: DateTime, + pub note: String, + pub url: String, + pub avatar: String, + pub avatar_static: String, + pub header: String, + pub header_static: String, + pub followers_count: u64, + pub following_count: u64, + pub statuses_count: u64, + pub last_status_at: Option, + pub source: Option, + pub emojis: Vec, + pub fields: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AccountSource { + pub privacy: String, + pub sensitive: bool, + pub language: Option, + pub note: String, + pub fields: Vec, + pub follow_requests_count: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Field { + pub name: String, + pub value: String, + pub verified_at: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Status { + pub id: String, + pub created_at: DateTime, + pub in_reply_to_id: Option, + pub in_reply_to_account_id: Option, + pub sensitive: bool, + pub spoiler_text: String, + pub visibility: Visibility, + pub language: Option, + pub uri: String, + pub url: Option, + pub replies_count: u64, + pub reblogs_count: u64, + pub favourites_count: u64, + pub edited_at: Option>, + pub favourited: Option, + pub reblogged: Option, + pub muted: Option, + pub bookmarked: Option, + pub content: String, + pub reblog: Option>, + pub account: Account, + pub media_attachments: Vec, + pub mentions: Vec, + pub tags: Vec, + pub emojis: Vec, + pub card: Option, + pub poll: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Visibility { + Public, + Unlisted, + Private, + Direct, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MediaAttachment { + pub id: String, + #[serde(rename = "type")] + pub media_type: MediaType, + pub url: String, + pub preview_url: String, + pub remote_url: Option, + pub text_url: Option, + pub meta: Option, + pub description: Option, + pub blurhash: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaType { + Unknown, + Image, + Gifv, + Video, + Audio, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MediaMeta { + pub original: Option, + pub small: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MediaInfo { + pub width: Option, + pub height: Option, + pub size: Option, + pub aspect: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Mention { + pub id: String, + pub username: String, + pub url: String, + pub acct: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Tag { + pub name: String, + pub url: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Emoji { + pub shortcode: String, + pub url: String, + pub static_url: String, + pub visible_in_picker: bool, + pub category: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PreviewCard { + pub url: String, + pub title: String, + pub description: String, + #[serde(rename = "type")] + pub card_type: String, + pub author_name: String, + pub author_url: String, + pub provider_name: String, + pub provider_url: String, + pub html: String, + pub width: u32, + pub height: u32, + pub image: Option, + pub embed_url: String, + pub blurhash: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Poll { + pub id: String, + pub expires_at: Option>, + pub expired: bool, + pub multiple: bool, + pub votes_count: u64, + pub voters_count: Option, + pub voted: Option, + pub own_votes: Option>, + pub options: Vec, + pub emojis: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PollOption { + pub title: String, + pub votes_count: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Application { + pub name: String, + pub website: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AppRegistration { + pub id: String, + pub name: String, + pub website: Option, + pub redirect_uri: String, + pub client_id: String, + pub client_secret: String, + pub vapid_key: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AccessToken { + pub access_token: String, + pub token_type: String, + pub scope: String, + pub created_at: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Notification { + pub id: String, + #[serde(rename = "type")] + pub notification_type: NotificationType, + pub created_at: DateTime, + pub account: Account, + pub status: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationType { + Follow, + FollowRequest, + Mention, + Reblog, + Favourite, + Poll, + Status, + Update, + AdminSignUp, + AdminReport, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Context { + pub ancestors: Vec, + pub descendants: Vec, +} \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..fb0050a --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,77 @@ +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tokio::fs; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub instance_url: String, + pub client_id: String, + pub client_secret: String, + pub access_token: Option, + pub username: Option, +} + +impl Config { + pub fn new(instance_url: String, client_id: String, client_secret: String) -> Self { + Self { + instance_url, + client_id, + client_secret, + access_token: None, + username: None, + } + } + + pub fn with_token( + instance_url: String, + client_id: String, + client_secret: String, + access_token: String, + username: Option, + ) -> Self { + Self { + instance_url, + client_id, + client_secret, + access_token: Some(access_token), + username, + } + } + + pub async fn load() -> Result { + let config_path = Self::config_path()?; + + if !config_path.exists() { + return Err(anyhow!("Config file not found. Run 'mastui auth' to authenticate.")); + } + + let content = fs::read_to_string(&config_path).await?; + let config: Config = serde_json::from_str(&content)?; + Ok(config) + } + + pub async fn save(&self) -> Result<()> { + let config_path = Self::config_path()?; + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).await?; + } + + let content = serde_json::to_string_pretty(self)?; + fs::write(&config_path, content).await?; + Ok(()) + } + + fn config_path() -> Result { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map_err(|_| anyhow!("Unable to determine home directory"))?; + + Ok(PathBuf::from(home).join(".config").join("mastui").join("config.json")) + } + + pub fn is_authenticated(&self) -> bool { + self.access_token.is_some() + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..11171d8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,178 @@ +mod api; +mod config; +mod ui; + +use anyhow::{anyhow, Result}; +use api::MastodonClient; +use clap::{Parser, Subcommand}; +use config::Config; +use std::io::{self, Write}; +use ui::{app::App, run_tui}; + +#[derive(Parser)] +#[command(name = "mastui")] +#[command(about = "A TUI client for Mastodon")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + Auth { + #[arg(help = "Mastodon instance URL (e.g., mastodon.social)")] + instance: String, + }, + Timeline, + Post { + #[arg(help = "Content of the post")] + content: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Some(Commands::Auth { instance }) => { + auth_flow(instance).await?; + } + Some(Commands::Timeline) => { + let config = Config::load().await?; + if !config.is_authenticated() { + eprintln!("Not authenticated. Run 'mastui auth ' first."); + return Ok(()); + } + + let client = MastodonClient::with_token( + config.instance_url, + config.access_token.unwrap(), + ); + + let app = App::new(client); + run_tui(app).await?; + } + Some(Commands::Post { content }) => { + let config = Config::load().await?; + if !config.is_authenticated() { + eprintln!("Not authenticated. Run 'mastui auth ' first."); + return Ok(()); + } + + let client = MastodonClient::with_token( + config.instance_url, + config.access_token.unwrap(), + ); + + match client.post_status(&content, None, None).await { + Ok(status) => println!("Posted: {}", status.url.unwrap_or(status.uri)), + Err(e) => eprintln!("Failed to post: {}", e), + } + } + None => { + // Default to timeline view + match Config::load().await { + Ok(config) => { + if !config.is_authenticated() { + eprintln!("Not authenticated. Run 'mastui auth ' first."); + return Ok(()); + } + + let client = MastodonClient::with_token( + config.instance_url, + config.access_token.unwrap(), + ); + + let app = App::new(client); + run_tui(app).await?; + } + Err(_) => { + eprintln!("No configuration found. Run 'mastui auth ' to get started."); + } + } + } + } + + Ok(()) +} + +async fn auth_flow(instance: String) -> Result<()> { + let instance_url = if instance.starts_with("http") { + instance + } else { + format!("https://{}", instance) + }; + + println!("Authenticating with {}...", instance_url); + + let client = MastodonClient::new(instance_url.clone()); + + println!("Registering application..."); + let app_registration = client + .register_app( + "Mastui", + "urn:ietf:wg:oauth:2.0:oob", + "read write follow push", + Some("https://github.com/yourusername/mastui"), + ) + .await?; + + println!("Application registered successfully!"); + + let auth_url = client.get_auth_url( + &app_registration.client_id, + "urn:ietf:wg:oauth:2.0:oob", + "read write follow push", + )?; + + println!("\nPlease visit the following URL to authorize the application:"); + println!("{}", auth_url); + println!(); + + print!("Enter the authorization code: "); + io::stdout().flush()?; + + let mut code = String::new(); + io::stdin().read_line(&mut code)?; + let code = code.trim(); + + if code.is_empty() { + return Err(anyhow!("No authorization code provided")); + } + + println!("Exchanging authorization code for access token..."); + let access_token = client + .get_access_token( + &app_registration.client_id, + &app_registration.client_secret, + "urn:ietf:wg:oauth:2.0:oob", + code, + ) + .await?; + + let authenticated_client = MastodonClient::with_token( + instance_url.clone(), + access_token.access_token.clone(), + ); + + println!("Verifying credentials..."); + let account = authenticated_client.verify_credentials().await?; + + let config = Config::with_token( + instance_url, + app_registration.client_id, + app_registration.client_secret, + access_token.access_token, + Some(account.username.clone()), + ); + + config.save().await?; + + println!("Authentication successful!"); + println!("Logged in as: {} (@{})", account.display_name, account.acct); + println!(); + println!("You can now run 'mastui' or 'mastui timeline' to start using the TUI."); + + Ok(()) +} diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..e08ac21 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,296 @@ +use crate::api::{MastodonClient, Status, Notification}; +use anyhow::Result; +use crossterm::event::KeyEvent; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq)] +pub enum AppMode { + Timeline, + Compose, + StatusDetail, + Notifications, + Search, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TimelineType { + Home, + Local, + Federated, +} + +pub struct App { + pub client: Arc, + pub mode: AppMode, + pub timeline_type: TimelineType, + pub statuses: Vec, + pub notifications: Vec, + pub selected_status_index: usize, + pub status_scroll: usize, + pub compose_text: String, + pub reply_to_id: Option, + pub search_query: String, + pub running: bool, + pub loading: bool, + pub error_message: Option, +} + +impl App { + pub fn new(client: MastodonClient) -> Self { + Self { + client: Arc::new(client), + mode: AppMode::Timeline, + timeline_type: TimelineType::Home, + statuses: Vec::new(), + notifications: Vec::new(), + selected_status_index: 0, + status_scroll: 0, + compose_text: String::new(), + reply_to_id: None, + search_query: String::new(), + running: true, + loading: false, + error_message: None, + } + } + + pub async fn load_timeline(&mut self) -> Result<()> { + self.loading = true; + self.error_message = None; + + let result = match self.timeline_type { + TimelineType::Home => self.client.get_home_timeline(Some(40)).await, + TimelineType::Local => self.client.get_public_timeline(true, Some(40)).await, + TimelineType::Federated => self.client.get_public_timeline(false, Some(40)).await, + }; + + match result { + Ok(new_statuses) => { + self.statuses = new_statuses; + self.selected_status_index = 0; + self.status_scroll = 0; + } + Err(e) => { + self.error_message = Some(format!("Failed to load timeline: {}", e)); + } + } + + self.loading = false; + Ok(()) + } + + pub async fn load_notifications(&mut self) -> Result<()> { + self.loading = true; + self.error_message = None; + + match self.client.get_notifications(Some(40)).await { + Ok(new_notifications) => { + self.notifications = new_notifications; + } + Err(e) => { + self.error_message = Some(format!("Failed to load notifications: {}", e)); + } + } + + self.loading = false; + Ok(()) + } + + pub async fn post_status(&mut self) -> Result<()> { + if self.compose_text.trim().is_empty() { + return Ok(()); + } + + self.loading = true; + self.error_message = None; + + let result = self + .client + .post_status(&self.compose_text, self.reply_to_id.as_deref(), None) + .await; + + match result { + Ok(_) => { + self.compose_text.clear(); + self.reply_to_id = None; + self.mode = AppMode::Timeline; + self.load_timeline().await?; + } + Err(e) => { + self.error_message = Some(format!("Failed to post status: {}", e)); + } + } + + self.loading = false; + Ok(()) + } + + pub async fn toggle_favourite(&mut self) -> Result<()> { + if let Some(status) = self.statuses.get(self.selected_status_index) { + let status_id = status.id.clone(); + let is_favourited = status.favourited.unwrap_or(false); + + let result = if is_favourited { + self.client.unfavourite_status(&status_id).await + } else { + self.client.favourite_status(&status_id).await + }; + + if let Ok(updated_status) = result { + if let Some(status) = self.statuses.get_mut(self.selected_status_index) { + *status = updated_status; + } + } + } + Ok(()) + } + + pub async fn toggle_reblog(&mut self) -> Result<()> { + if let Some(status) = self.statuses.get(self.selected_status_index) { + let status_id = status.id.clone(); + let is_reblogged = status.reblogged.unwrap_or(false); + + let result = if is_reblogged { + self.client.unreblog_status(&status_id).await + } else { + self.client.reblog_status(&status_id).await + }; + + if let Ok(updated_status) = result { + if let Some(status) = self.statuses.get_mut(self.selected_status_index) { + *status = updated_status; + } + } + } + Ok(()) + } + + pub fn start_reply(&mut self) { + if let Some(status) = self.statuses.get(self.selected_status_index) { + self.reply_to_id = Some(status.id.clone()); + self.compose_text = format!("@{} ", status.account.acct); + self.mode = AppMode::Compose; + } + } + + pub fn next_status(&mut self) { + if self.selected_status_index < self.statuses.len().saturating_sub(1) { + self.selected_status_index += 1; + } + } + + pub fn previous_status(&mut self) { + if self.selected_status_index > 0 { + self.selected_status_index -= 1; + } + } + + pub fn next_timeline(&mut self) { + self.timeline_type = match self.timeline_type { + TimelineType::Home => TimelineType::Local, + TimelineType::Local => TimelineType::Federated, + TimelineType::Federated => TimelineType::Home, + }; + } + + pub fn quit(&mut self) { + self.running = false; + } + + pub fn handle_key_event(&mut self, key: KeyEvent) { + match self.mode { + AppMode::Timeline => self.handle_timeline_key(key), + AppMode::Compose => self.handle_compose_key(key), + AppMode::StatusDetail => self.handle_detail_key(key), + AppMode::Notifications => self.handle_notifications_key(key), + AppMode::Search => self.handle_search_key(key), + } + } + + fn handle_timeline_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) => self.quit(), + (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.quit(), + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.next_status(), + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.previous_status(), + (KeyCode::Char('r'), _) => self.start_reply(), + (KeyCode::Char('n'), _) => { + self.compose_text.clear(); + self.reply_to_id = None; + self.mode = AppMode::Compose; + } + (KeyCode::Char('t'), _) => self.next_timeline(), + (KeyCode::Char('m'), _) => self.mode = AppMode::Notifications, + (KeyCode::Enter, _) => self.mode = AppMode::StatusDetail, + _ => {} + } + } + + fn handle_compose_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + self.mode = AppMode::Timeline; + if self.reply_to_id.is_none() { + self.compose_text.clear(); + } + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.mode = AppMode::Timeline; + if self.reply_to_id.is_none() { + self.compose_text.clear(); + } + } + (KeyCode::Char(c), _) => { + self.compose_text.push(c); + } + (KeyCode::Backspace, _) => { + self.compose_text.pop(); + } + (KeyCode::Enter, KeyModifiers::CONTROL) => { + // Post the status - will be handled by the main loop + } + _ => {} + } + } + + fn handle_detail_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.mode = AppMode::Timeline, + (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline, + (KeyCode::Char('r'), _) => self.start_reply(), + _ => {} + } + } + + fn handle_notifications_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.mode = AppMode::Timeline, + (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline, + _ => {} + } + } + + fn handle_search_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => self.mode = AppMode::Timeline, + (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline, + (KeyCode::Char(c), _) => { + self.search_query.push(c); + } + (KeyCode::Backspace, _) => { + self.search_query.pop(); + } + _ => {} + } + } +} \ No newline at end of file diff --git a/src/ui/compose.rs b/src/ui/compose.rs new file mode 100644 index 0000000..339c7ad --- /dev/null +++ b/src/ui/compose.rs @@ -0,0 +1,94 @@ +use crate::ui::app::App; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +pub fn render_compose(f: &mut Frame, app: &App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(5), // Compose area + Constraint::Length(3), // Instructions + ].as_ref()) + .split(area); + + render_compose_header(f, app, chunks[0]); + render_compose_area(f, app, chunks[1]); + render_compose_instructions(f, chunks[2]); +} + +fn render_compose_header(f: &mut Frame, app: &App, area: Rect) { + let title = if app.reply_to_id.is_some() { + "Compose Reply" + } else { + "Compose New Post" + }; + + let header = Paragraph::new(title) + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Green)); + + f.render_widget(header, area); +} + +fn render_compose_area(f: &mut Frame, app: &App, area: Rect) { + let char_count = app.compose_text.chars().count(); + let char_limit = 500; // Mastodon's default character limit + + let char_style = if char_count > char_limit { + Style::default().fg(Color::Red) + } else if char_count > char_limit - 50 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }; + + let title = format!("Content ({}/{})", char_count, char_limit); + + let compose_text = if app.compose_text.is_empty() { + "Start typing your post...".to_string() + } else { + app.compose_text.clone() + }; + + let text_style = if app.compose_text.is_empty() { + Style::default().fg(Color::Gray) + } else { + Style::default().fg(Color::White) + }; + + let paragraph = Paragraph::new(compose_text) + .block( + Block::default() + .borders(Borders::ALL) + .title(Line::from(vec![ + Span::styled(title, char_style), + ])) + ) + .style(text_style) + .wrap(Wrap { trim: false }); + + f.render_widget(paragraph, area); +} + +fn render_compose_instructions(f: &mut Frame, area: Rect) { + let instructions = vec![ + Line::from(vec![ + Span::styled("Ctrl+Enter", Style::default().fg(Color::Cyan)), + Span::raw(" to post • "), + Span::styled("Escape", Style::default().fg(Color::Cyan)), + Span::raw(" to cancel"), + ]), + ]; + + let paragraph = Paragraph::new(instructions) + .block(Block::default().borders(Borders::ALL).title("Instructions")) + .style(Style::default().fg(Color::Gray)); + + f.render_widget(paragraph, area); +} \ No newline at end of file diff --git a/src/ui/detail.rs b/src/ui/detail.rs new file mode 100644 index 0000000..9e16d7a --- /dev/null +++ b/src/ui/detail.rs @@ -0,0 +1,176 @@ +use crate::ui::app::App; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +pub fn render_status_detail(f: &mut Frame, app: &App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Content + Constraint::Length(3), // Instructions + ].as_ref()) + .split(area); + + render_detail_header(f, chunks[0]); + render_detail_content(f, app, chunks[1]); + render_detail_instructions(f, chunks[2]); +} + +fn render_detail_header(f: &mut Frame, area: Rect) { + let header = Paragraph::new("Status Detail") + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Cyan)); + + f.render_widget(header, area); +} + +fn render_detail_content(f: &mut Frame, app: &App, area: Rect) { + if let Some(status) = app.statuses.get(app.selected_status_index) { + let display_status = if let Some(reblog) = &status.reblog { + reblog.as_ref() + } else { + status + }; + + let mut lines = Vec::new(); + + if status.reblog.is_some() { + lines.push(Line::from(vec![ + Span::styled("🔄 Reblogged by ", Style::default().fg(Color::Green)), + Span::styled( + &status.account.display_name, + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ), + ])); + lines.push(Line::from("")); + } + + lines.push(Line::from(vec![ + Span::styled( + &display_status.account.display_name, + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("@{}", display_status.account.acct), + Style::default().fg(Color::Gray), + ), + ])); + + lines.push(Line::from(Span::styled( + format_detailed_time(&display_status.created_at), + Style::default().fg(Color::Gray), + ))); + + lines.push(Line::from("")); + + let content = strip_html(&display_status.content); + for line in content.lines() { + lines.push(Line::from(Span::raw(line))); + } + + 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)), + ])); + } + } + } + + lines.push(Line::from("")); + + let mut stats = Vec::new(); + stats.push(format!("💬 {} replies", display_status.replies_count)); + stats.push(format!("🔄 {} reblogs", display_status.reblogs_count)); + stats.push(format!("⭐ {} favourites", display_status.favourites_count)); + + lines.push(Line::from(Span::styled( + stats.join(" • "), + Style::default().fg(Color::Gray), + ))); + + if display_status.favourited.unwrap_or(false) { + lines.push(Line::from(Span::styled( + "⭐ You favourited this post", + Style::default().fg(Color::Yellow), + ))); + } + + if display_status.reblogged.unwrap_or(false) { + lines.push(Line::from(Span::styled( + "🔄 You reblogged this post", + Style::default().fg(Color::Green), + ))); + } + + let paragraph = Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: false }); + + f.render_widget(paragraph, area); + } else { + let paragraph = Paragraph::new("No status selected") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Yellow)); + + f.render_widget(paragraph, area); + } +} + +fn render_detail_instructions(f: &mut Frame, area: Rect) { + let instructions = vec![ + Line::from(vec![ + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" to reply • "), + Span::styled("Escape/q", Style::default().fg(Color::Cyan)), + Span::raw(" to go back"), + ]), + ]; + + let paragraph = Paragraph::new(instructions) + .block(Block::default().borders(Borders::ALL).title("Instructions")) + .style(Style::default().fg(Color::Gray)); + + f.render_widget(paragraph, area); +} + +fn strip_html(html: &str) -> String { + html.replace("
", "\n") + .replace("
", "\n") + .replace("
", "\n") + .replace("

", "\n\n") + .replace("

", "") + .chars() + .filter(|&c| c != '<' && c != '>') + .collect::() + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") +} + +fn format_detailed_time(time: &chrono::DateTime) -> String { + time.format("%Y-%m-%d %H:%M:%S UTC").to_string() +} \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..6d4c970 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,214 @@ +pub mod app; +pub mod compose; +pub mod detail; +pub mod timeline; + +use crate::ui::app::{App, AppMode}; +use anyhow::Result; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; +use std::io; +use std::time::Duration; + +pub async fn run_tui(mut app: App) -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + app.load_timeline().await?; + + let result = run_app(&mut terminal, &mut app).await; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + result +} + +async fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> { + loop { + terminal.draw(|f| ui(f, app))?; + + if !app.running { + break; + } + + let event_available = event::poll(Duration::from_millis(100))?; + + if event_available { + let event = event::read()?; + match event { + Event::Key(key) => { + match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::CONTROL) if app.mode == AppMode::Compose => { + app.post_status().await?; + } + (KeyCode::Char('f'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => { + app.toggle_favourite().await?; + } + (KeyCode::Char('b'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => { + app.toggle_reblog().await?; + } + (KeyCode::F(5), _) => { + if app.mode == AppMode::Timeline { + app.load_timeline().await?; + } else if app.mode == AppMode::Notifications { + app.load_notifications().await?; + } + } + _ => { + app.handle_key_event(key); + } + } + } + _ => {} + } + } + } + + Ok(()) +} + +fn ui(f: &mut Frame, app: &App) { + let size = f.area(); + + match app.mode { + AppMode::Timeline => { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref()) + .split(size); + + timeline::render_timeline(f, app, chunks[0]); + render_help(f, chunks[1]); + } + AppMode::Compose => { + compose::render_compose(f, app, size); + } + AppMode::StatusDetail => { + detail::render_status_detail(f, app, size); + } + AppMode::Notifications => { + render_notifications(f, app, size); + } + AppMode::Search => { + render_search(f, app, size); + } + } +} + +fn render_help(f: &mut Frame, area: Rect) { + let help_text = 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("r", Style::default().fg(Color::Cyan)), + Span::raw(" reply • "), + Span::styled("n", Style::default().fg(Color::Cyan)), + Span::raw(" new post • "), + Span::styled("t", Style::default().fg(Color::Cyan)), + Span::raw(" timeline • "), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::raw(" detail • "), + Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)), + Span::raw(" fav • "), + Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)), + Span::raw(" boost • "), + Span::styled("F5", Style::default().fg(Color::Cyan)), + Span::raw(" refresh • "), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::raw(" quit"), + ]), + ]; + + let paragraph = Paragraph::new(help_text) + .block(Block::default().borders(Borders::ALL).title("Help")) + .style(Style::default().fg(Color::Gray)); + + f.render_widget(paragraph, area); +} + +fn render_notifications(f: &mut Frame, app: &App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) + .split(area); + + let header = Paragraph::new("Notifications") + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Magenta)); + + f.render_widget(header, chunks[0]); + + let notifications = &app.notifications; + + if notifications.is_empty() { + let paragraph = Paragraph::new("No notifications") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Yellow)); + + f.render_widget(paragraph, chunks[1]); + } else { + let content = notifications + .iter() + .map(|notif| { + format!( + "{:?}: {} ({})", + notif.notification_type, + notif.account.display_name, + format_time(¬if.created_at) + ) + }) + .collect::>() + .join("\n"); + + let paragraph = Paragraph::new(content) + .block(Block::default().borders(Borders::ALL)) + .wrap(ratatui::widgets::Wrap { trim: true }); + + f.render_widget(paragraph, chunks[1]); + } +} + +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")) + .style(Style::default().fg(Color::Yellow)); + + f.render_widget(paragraph, _area); +} + +fn format_time(time: &chrono::DateTime) -> String { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(*time); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m", duration.num_minutes()) + } else { + "now".to_string() + } +} \ No newline at end of file diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs new file mode 100644 index 0000000..770fe19 --- /dev/null +++ b/src/ui/timeline.rs @@ -0,0 +1,189 @@ +use crate::api::Status; +use crate::ui::app::{App, TimelineType}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, + Frame, +}; + +pub fn render_timeline(f: &mut Frame, app: &App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) + .split(area); + + render_header(f, app, chunks[0]); + render_status_list(f, app, chunks[1]); +} + +fn render_header(f: &mut Frame, app: &App, area: Rect) { + let timeline_name = match app.timeline_type { + TimelineType::Home => "Home", + TimelineType::Local => "Local", + TimelineType::Federated => "Federated", + }; + + let header_text = if app.loading { + format!("{} Timeline (Loading...)", timeline_name) + } else { + format!("{} Timeline", timeline_name) + }; + + let header = Paragraph::new(header_text) + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Cyan)); + + f.render_widget(header, area); +} + +fn render_status_list(f: &mut Frame, app: &App, area: Rect) { + let statuses = &app.statuses; + + if statuses.is_empty() && !app.loading { + let empty_msg = if let Some(error) = &app.error_message { + format!("Error: {}", error) + } else { + "No statuses to display".to_string() + }; + + let paragraph = Paragraph::new(empty_msg) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Color::Yellow)); + + f.render_widget(paragraph, area); + return; + } + + let items: Vec = statuses + .iter() + .enumerate() + .map(|(i, status)| format_status(status, i == app.selected_status_index)) + .collect(); + + let mut list_state = ListState::default(); + list_state.select(Some(app.selected_status_index)); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL)) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, area, &mut list_state); +} + +fn format_status(status: &Status, is_selected: bool) -> ListItem { + let display_status = if let Some(reblog) = &status.reblog { + reblog.as_ref() + } else { + status + }; + + let mut lines = Vec::new(); + + if status.reblog.is_some() { + lines.push(Line::from(vec![ + Span::styled("🔄 ", Style::default().fg(Color::Green)), + Span::styled( + format!("{} reblogged", status.account.display_name), + Style::default().fg(Color::Green), + ), + ])); + } + + let username_style = if is_selected { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + + lines.push(Line::from(vec![ + Span::styled( + format!("@{}", display_status.account.acct), + username_style, + ), + Span::raw(" • "), + Span::styled( + format_time(&display_status.created_at), + Style::default().fg(Color::Gray), + ), + ])); + + let content = strip_html(&display_status.content); + let content_lines: Vec = textwrap::wrap(&content, 70) + .into_iter() + .map(|cow| cow.into_owned()) + .collect(); + + for line in content_lines.iter().take(3) { + lines.push(Line::from(Span::raw(line.clone()))); + } + + if content_lines.len() > 3 { + lines.push(Line::from(Span::styled("...", Style::default().fg(Color::Gray)))); + } + + let mut stats = Vec::new(); + + if display_status.replies_count > 0 { + stats.push(format!("💬 {}", display_status.replies_count)); + } + + if display_status.reblogs_count > 0 { + stats.push(format!("🔄 {}", display_status.reblogs_count)); + } + + if display_status.favourites_count > 0 { + stats.push(format!("⭐ {}", display_status.favourites_count)); + } + + if !stats.is_empty() { + lines.push(Line::from(Span::styled( + stats.join(" • "), + Style::default().fg(Color::Gray), + ))); + } + + 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)) +} + +fn strip_html(html: &str) -> String { + html.replace("
", "\n") + .replace("
", "\n") + .replace("
", "\n") + .replace("

", "\n") + .chars() + .filter(|&c| c != '<' && c != '>') + .collect::() + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") +} + +fn format_time(time: &chrono::DateTime) -> String { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(*time); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m", duration.num_minutes()) + } else { + "now".to_string() + } +} \ No newline at end of file