From 83e032a5900fe411f9f4e86399e1acad3d9df607 Mon Sep 17 00:00:00 2001 From: Felipe Contreras Salinas Date: Sat, 14 Jun 2025 18:25:56 -0400 Subject: [PATCH] feat: initial TUI for administration (#49) Allows listing and deleting places. Edit mode is not implemented yet. Reviewed-on: https://oolong.ludwig.dog/pitbuster/huellas/pulls/49 Co-authored-by: Felipe Contreras Salinas Co-committed-by: Felipe Contreras Salinas --- Cargo.lock | 476 +++++++++++++++++++++++++++++++++++- Cargo.toml | 11 + Dockerfile | 2 +- src/cli.rs | 20 ++ src/main.rs | 21 ++ src/places/db_repository.rs | 33 +++ src/places/repository.rs | 24 ++ src/tui/keys.rs | 39 +++ src/tui/mod.rs | 42 ++++ src/tui/state.rs | 117 +++++++++ src/tui/terminal.rs | 189 ++++++++++++++ src/tui/ui.rs | 196 +++++++++++++++ 12 files changed, 1165 insertions(+), 5 deletions(-) create mode 100644 src/cli.rs create mode 100644 src/tui/keys.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/state.rs create mode 100644 src/tui/terminal.rs create mode 100644 src/tui/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 715af83..e74d0e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[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" @@ -202,9 +252,9 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bitflags" -version = "2.8.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] @@ -236,6 +286,21 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.15" @@ -251,6 +316,66 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +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 = "concurrent-queue" version = "2.5.0" @@ -315,6 +440,48 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix 1.0.7", + "serde", + "signal-hook", + "signal-hook-mio", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -325,6 +492,41 @@ dependencies = [ "typenum", ] +[[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 = "der" version = "0.7.9" @@ -374,6 +576,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -395,6 +606,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -712,12 +933,17 @@ dependencies = [ "axum", "axum-msgpack", "axum-test", + "clap", + "crossterm 0.29.0", "dotenvy", "futures", + "itertools 0.14.0", + "ratatui", "serde", "sqlx", "thiserror", "tokio", + "tokio-util", "tower-http", "tracing", "tracing-subscriber", @@ -881,6 +1107,12 @@ dependencies = [ "syn", ] +[[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" @@ -912,6 +1144,49 @@ dependencies = [ "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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" @@ -950,12 +1225,30 @@ dependencies = [ "vcpkg", ] +[[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.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + [[package]] name = "lock_api" version = "0.4.12" @@ -972,6 +1265,15 @@ version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1035,6 +1337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -1107,6 +1410,12 @@ version = "1.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "parking" version = "2.2.1" @@ -1300,6 +1609,27 @@ dependencies = [ "zerocopy 0.8.20", ] +[[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 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.9" @@ -1439,6 +1769,32 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.59.0", +] + [[package]] name = "rustls" version = "0.23.23" @@ -1579,6 +1935,27 @@ 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.2" @@ -1838,6 +2215,12 @@ 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 = "stringprep" version = "0.1.5" @@ -1849,6 +2232,34 @@ dependencies = [ "unicode-properties", ] +[[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" @@ -2010,9 +2421,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.13" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" dependencies = [ "bytes", "futures-core", @@ -2180,6 +2591,35 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[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 0.13.0", + "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" @@ -2209,6 +2649,12 @@ 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 = "valuable" version = "0.1.1" @@ -2276,6 +2722,28 @@ dependencies = [ "wasite", ] +[[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-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index dac5bb5..d5caac5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,18 @@ axum = { version = "0.8.4", default-features = false, features = [ "http2", ] } axum-msgpack = "0.5.0" +clap = { version = "4.5.40", features = ["derive"] } +crossterm = { version = "0.29.0", default-features = false, features = [ + "bracketed-paste", + "event-stream", + "serde", +] } dotenvy = "0.15.7" +itertools = "0.14.0" futures = { version = "0.3.31", default-features = false } +ratatui = { version = "0.29.0", default-features = false, features = [ + "crossterm", +] } serde = { version = "1.0.219", features = ["derive"] } sqlx = { version = "0.8.6", default-features = false, features = [ "macros", @@ -28,6 +38,7 @@ tokio = { version = "1.45.1", default-features = false, features = [ "rt-multi-thread", "signal", ] } +tokio-util = "0.7.15" thiserror = "2.0.12" tower-http = { version = "0.6.6", default-features = false, features = ["fs"] } tracing = "0.1.41" diff --git a/Dockerfile b/Dockerfile index 73b590e..5474a30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,4 +61,4 @@ COPY --from=builder /usr/src/huellas/ts-client/build/client.js /usr/local/bin/st # Run the application WORKDIR /usr/local/bin -CMD ["/usr/local/bin/huellas"] +CMD ["/usr/local/bin/huellas", "server"] diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..94ece7a --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,20 @@ +//! Cli Parameters + +use clap::{Parser, Subcommand}; + +/// Server for saving places in a map +#[derive(Parser)] +#[command(version, about, long_about = None)] +pub struct CliArgs { + /// Application mode + #[command(subcommand)] + pub mode: Mode, +} + +#[derive(Subcommand)] +pub enum Mode { + /// Spins up the server + Server, + /// Fires up a TUI + Tui, +} diff --git a/src/main.rs b/src/main.rs index 150dd55..2d8e8eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,29 @@ use anyhow::Result; +use clap::Parser; +mod cli; mod db; mod logging; mod places; mod server; +mod tui; #[tokio::main] async fn main() -> Result<()> { dotenvy::dotenv().unwrap_or_default(); logging::setup()?; + let args = cli::CliArgs::parse(); + + match args.mode { + cli::Mode::Server => server_mode().await?, + cli::Mode::Tui => tui_mode().await?, + } + + Ok(()) +} + +async fn server_mode() -> Result<()> { let pool = db::pool().await?; db::run_migrations(&pool).await?; @@ -17,6 +31,13 @@ async fn main() -> Result<()> { let places_routes = places::routes::places_routes(places_repository); server::serve(places_routes).await?; + Ok(()) +} + +async fn tui_mode() -> Result<()> { + let pool = db::pool().await?; + let places_repository = places::db_repository::DbPlacesRepository::new(pool); + tui::tui(places_repository).await?; Ok(()) } diff --git a/src/places/db_repository.rs b/src/places/db_repository.rs index 8d8d91c..c22b39f 100644 --- a/src/places/db_repository.rs +++ b/src/places/db_repository.rs @@ -44,6 +44,39 @@ impl PlacesRepository for DbPlacesRepository { .map_err(|err| PlacesError::FailToGet(err.to_string())) } + async fn get_places_paginated( + &self, + offset: u32, + limit: u8, + ) -> Result, PlacesError> { + sqlx::query!( + r#"SELECT id, name, address, open_hours, icon, description, url, + longitude as "longitude: f64", latitude as "latitude: f64" + FROM places + WHERE active = TRUE + ORDER BY id + LIMIT ? + OFFSET ?"#, + limit, + offset, + ) + .fetch(&self.db_pool) + .map_ok(|p| Place { + id: p.id, + name: p.name, + address: p.address, + open_hours: p.open_hours, + icon: p.icon, + description: p.description, + latitude: p.latitude, + longitude: p.longitude, + url: p.url, + }) + .try_collect::>() + .await + .map_err(|err| PlacesError::FailToGet(err.to_string())) + } + async fn insert_place(&self, place: PlaceInsert) -> Result { let id = sqlx::query_scalar!( r#"INSERT INTO places diff --git a/src/places/repository.rs b/src/places/repository.rs index 516a32c..28f989f 100644 --- a/src/places/repository.rs +++ b/src/places/repository.rs @@ -13,6 +13,13 @@ pub trait PlacesRepository: Clone + Send + Sync + 'static { /// Get all of the Places fn get_places(&self) -> impl Future, PlacesError>> + Send; + /// Get all of the Places + fn get_places_paginated( + &self, + offset: u32, + limit: u8, + ) -> impl Future, PlacesError>> + Send; + /// Inserts a Place. fn insert_place( &self, @@ -43,6 +50,7 @@ pub enum PlacesError { #[derive(Clone)] pub struct MockPlacesRepository { get_places_count: Arc>, + get_places_paginated_count: Arc>, insert_place_count: Arc>, update_place_count: Arc>, delete_place_count: Arc>, @@ -53,6 +61,7 @@ impl MockPlacesRepository { pub fn new() -> Self { Self { get_places_count: Arc::new(RwLock::new(0)), + get_places_paginated_count: Arc::new(RwLock::new(0)), insert_place_count: Arc::new(RwLock::new(0)), update_place_count: Arc::new(RwLock::new(0)), delete_place_count: Arc::new(RwLock::new(0)), @@ -63,6 +72,11 @@ impl MockPlacesRepository { *self.get_places_count.read().await } + #[expect(dead_code)] + pub async fn get_places_paginated_count(&self) -> usize { + *self.get_places_paginated_count.read().await + } + pub async fn insert_place_count(&self) -> usize { *self.insert_place_count.read().await } @@ -84,6 +98,16 @@ impl PlacesRepository for MockPlacesRepository { Ok(Vec::new()) } + async fn get_places_paginated( + &self, + _offset: u32, + _limit: u8, + ) -> Result, PlacesError> { + let mut get_places_paginated_count = self.get_places_paginated_count.write().await; + *get_places_paginated_count += 1; + Ok(Vec::new()) + } + async fn insert_place(&self, place: super::models::PlaceInsert) -> Result { let mut insert_place_count = self.insert_place_count.write().await; *insert_place_count += 1; diff --git a/src/tui/keys.rs b/src/tui/keys.rs new file mode 100644 index 0000000..217cd64 --- /dev/null +++ b/src/tui/keys.rs @@ -0,0 +1,39 @@ +//! Keyboard handling + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::state::{Mode, State}; + +/// Event handling +pub async fn handle_key(state: &mut State, key_event: KeyEvent) { + match state.mode { + Mode::List => { + if state.confirmation.is_some() { + match key_event.code { + KeyCode::Char('y') => state.proceed_confirmation().await, + KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(), + _ => {} + } + } else { + match key_event.code { + KeyCode::Char('d') => state.confirm_deletion(), + KeyCode::Char('e') => state.mode = Mode::Edit, + KeyCode::Home => state.selected_place.select_first(), + KeyCode::End => state.selected_place.select_last(), + KeyCode::PageUp => state.prev_page(), + KeyCode::PageDown => state.next_page(), + KeyCode::Up | KeyCode::Char('k') => state.selected_place.select_previous(), + KeyCode::Down | KeyCode::Char('j') => state.selected_place.select_next(), + KeyCode::Esc | KeyCode::Char('q') => state.quit = true, + _ => {} + } + } + } + Mode::Edit => match (key_event.modifiers, key_event.code) { + (KeyModifiers::NONE, KeyCode::Esc) => state.mode = Mode::List, + (KeyModifiers::NONE, KeyCode::Tab) => {} + (KeyModifiers::SHIFT, KeyCode::Tab) => {} + _ => {} + }, + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..3ba35a8 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,42 @@ +//! TUI + +pub mod keys; +pub mod state; +pub mod terminal; +pub mod ui; + +use anyhow::Result; + +use state::State; +use terminal::Event; + +use crate::places::db_repository::DbPlacesRepository; + +/// Fires up the UI +pub async fn tui(places_repository: DbPlacesRepository) -> Result<()> { + let (_, terminal_height) = crossterm::terminal::size()?; + let mut state = State::new(places_repository, terminal_height); + let mut tui = terminal::Tui::new()?; + + let result = loop { + match tui.next().await? { + Event::Key(key_event) => keys::handle_key(&mut state, key_event).await, + Event::Render => { + tui.draw(|frame| ui::ui_draw(&mut state, frame))?; + } + Event::Tick => { + state.fetch_places().await; + } + Event::Resize(_, h) => state.height = h, + Event::Quit => state.quit = true, + _ => {} + } + if state.quit { + break Ok(()); + } + }; + + tui.stop()?; + + result +} diff --git a/src/tui/state.rs b/src/tui/state.rs new file mode 100644 index 0000000..96732be --- /dev/null +++ b/src/tui/state.rs @@ -0,0 +1,117 @@ +//! TUI state + +use ratatui::widgets::TableState; + +use crate::places::{ + db_repository::DbPlacesRepository, models::Place, repository::PlacesRepository, +}; + +pub struct State { + pub height: u16, + pub page: u32, + pub mode: Mode, + places_repository: DbPlacesRepository, + pub places: Vec, + places_status: DataStatus, + pub confirmation: Option, + pub selected_place: TableState, + pub quit: bool, +} + +pub enum Mode { + List, + Edit, +} + +enum DataStatus { + Fresh, + Old, +} + +pub enum ConfirmationStatus { + Deletion(i64), + #[expect(dead_code)] + Save(i64), +} + +impl State { + pub fn new(places_repository: DbPlacesRepository, height: u16) -> Self { + Self { + height, + page: 0, + mode: Mode::List, + places_repository, + places_status: DataStatus::Old, + places: vec![], + selected_place: TableState::default(), + confirmation: None, + quit: false, + } + } + + pub fn next_page(&mut self) { + self.page += 1; + self.places_status = DataStatus::Old; + } + + pub fn prev_page(&mut self) { + self.page = self.page.saturating_sub(1); + self.places_status = DataStatus::Old; + } + + pub async fn fetch_places(&mut self) { + if let DataStatus::Fresh = self.places_status { + return; + } + let limit = (self.height as u8).saturating_sub(3); + let offset = (limit as u32) * self.page; + match self + .places_repository + .get_places_paginated(offset, limit) + .await + { + Ok(places) => { + self.places = places; + if !self.places.is_empty() { + self.selected_place.select(Some(0)); + } + } + Err(err) => { + tracing::error!("{err}"); + } + } + self.places_status = DataStatus::Fresh; + } + + pub fn confirm_deletion(&mut self) { + if let Some(Some(id)) = self + .selected_place + .selected() + .map(|index| self.places.get(index).map(|p| p.id)) + { + self.confirmation = Some(ConfirmationStatus::Deletion(id)) + } + } + + pub fn cancel_confirmation(&mut self) { + self.confirmation = None; + } + + pub async fn proceed_confirmation(&mut self) { + let Some(confirmation) = &self.confirmation else { + return; + }; + + match confirmation { + ConfirmationStatus::Deletion(id) => { + if let Err(err) = self.places_repository.delete_place(*id).await { + tracing::error!("{err}"); + } + } + ConfirmationStatus::Save(_) => todo!(), + } + + self.confirmation = None; + self.places_status = DataStatus::Old; + } +} diff --git a/src/tui/terminal.rs b/src/tui/terminal.rs new file mode 100644 index 0000000..6d9765d --- /dev/null +++ b/src/tui/terminal.rs @@ -0,0 +1,189 @@ +use std::ops::{Deref, DerefMut}; + +use anyhow::{Result, anyhow}; +use crossterm::event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent}; +use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; +use futures::{FutureExt, StreamExt}; +use ratatui::Terminal; +use ratatui::prelude::CrosstermBackend; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +/// Terminal events. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Event { + Init, + Quit, + Error, + Closed, + /// Triggers background actions + Tick, + /// UI Render + Render, + FocusGained, + FocusLost, + Paste(String), + /// Key Press + Key(KeyEvent), + Mouse(MouseEvent), + /// Terminal Resize + Resize(u16, u16), +} + +/// Terminal event handler. +pub struct Tui { + /// Terminal backend + pub terminal: ratatui::Terminal>, + /// Event handler task. + pub task: JoinHandle>, + /// Cancelation token + pub cancellation_token: CancellationToken, + /// Event receiver channel. + receiver: UnboundedReceiver, +} + +impl Tui { + pub fn new() -> Result { + let tick_rate = 4.0; + let frame_rate = 20.0; + let terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?; + let cancellation_token = CancellationToken::new(); + + // setup the event handling task + let (sender, receiver) = unbounded_channel(); + let task = { + let cancellation_token = cancellation_token.clone(); + let sender = sender.clone(); + let tick_delay = std::time::Duration::from_secs_f64(1.0 / tick_rate); + let render_delay = std::time::Duration::from_secs_f64(1.0 / frame_rate); + tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut tick_interval = tokio::time::interval(tick_delay); + let mut render_interval = tokio::time::interval(render_delay); + sender.send(Event::Init)?; + loop { + let tick_delay = tick_interval.tick(); + let render_delay = render_interval.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = cancellation_token.cancelled() => { + break; + } + maybe_event = crossterm_event => { + match maybe_event { + Some(Ok(ev)) => { + match ev { + CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + sender.send(Event::Key(key))? + } + }, + CrosstermEvent::Mouse(mouse) => { + sender.send(Event::Mouse(mouse))? + }, + CrosstermEvent::Resize(x, y)=> { + sender.send(Event::Resize(x,y))? + }, + CrosstermEvent::FocusLost => sender.send(Event::FocusLost)?, + CrosstermEvent::FocusGained => sender.send(Event::FocusGained)?, + CrosstermEvent::Paste(s) => sender.send(Event::Paste(s))?, + } + }, + Some(Err(_)) => sender.send(Event::Error)?, + None => {} + } + }, + _ = tick_delay => { + sender.send(Event::Tick)?; + }, + _ = render_delay => { + sender.send(Event::Render)?; + } + } + } + Ok(()) + }) + }; + + // Setup the terminal + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!( + std::io::stderr(), + EnterAlternateScreen, + crossterm::cursor::Hide + )?; + + Ok(Self { + terminal, + cancellation_token, + task, + receiver, + }) + } + + fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn stop(&self) -> Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(std::time::Duration::from_millis(10)); + counter += 1; + if counter > 5 { + self.task.abort(); + } + if counter > 10 { + return Err(anyhow!( + "Failed to abort task in 100 milliseconds for unknown reason" + )); + } + } + Ok(()) + } + + fn exit(&mut self) -> Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + crossterm::execute!( + std::io::stderr(), + LeaveAlternateScreen, + crossterm::cursor::Show + )?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + /// Receive the next event from the handler task. + pub async fn next(&mut self) -> Result { + self.receiver + .recv() + .await + .ok_or_else(|| anyhow!("Events channel was closed")) + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..cf6e619 --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,196 @@ +//! UI definition and drawing + +use itertools::Itertools; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span, Text, ToSpan}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Row, Table}; + +use super::state::{ConfirmationStatus, Mode, State}; + +/// UI drawing +pub fn ui_draw(state: &mut State, f: &mut Frame<'_>) { + let main_split = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .split(f.area()); + + header_draw(state, f, main_split[0]); + main_draw(state, f, main_split[1]); + footer_draw(state, f, main_split[2]); +} + +fn header_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { + let split = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(9), + Constraint::Fill(1), + Constraint::Length(6), + ]) + .split(area); + let app_name = Span::styled( + " huellas ", + Style::new().bg(Color::Gray).fg(Color::Black).bold(), + ); + let page = + Line::from_iter(["Page: ".to_span(), state.page.to_span(), " ".to_span()]).right_aligned(); + let app_mode = match state.mode { + Mode::List => Span::styled(" LIST ", Style::new().black().on_light_green().bold()), + Mode::Edit => Span::styled(" EDIT ", Style::new().black().on_light_blue().bold()), + }; + f.render_widget(app_name, split[0]); + f.render_widget(page, split[1]); + f.render_widget(app_mode, split[2]); +} + +fn main_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { + match state.mode { + Mode::List => list_draw(state, f, area), + Mode::Edit => edit_draw(state, f, area), + } + confirmation_dialog_draw(state, f, area); +} + +fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { + let Some(confirmation) = &state.confirmation else { + return; + }; + + let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60)); + let (action, id) = match confirmation { + ConfirmationStatus::Deletion(id) => ("delete", id), + ConfirmationStatus::Save(id) => ("save", id), + }; + let confirmation_dialog = Paragraph::new(Text::from_iter([ + Line::from_iter([ + "Do you want to ".to_span(), + action.to_span(), + " place with id: ".to_span(), + id.to_span(), + ]), + Line::from(""), + Line::from("Y/N".to_span().bold()), + ])) + .centered() + .block(Block::bordered().padding(Padding::uniform(1))); + + f.render_widget(Clear, dialog_area); + f.render_widget(confirmation_dialog, dialog_area); +} + +fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { + let places = state.places.iter().map(|p| { + Row::new([ + p.id.to_string(), + p.name.clone(), + p.latitude.to_string(), + p.longitude.to_string(), + p.icon.clone(), + p.open_hours.clone(), + p.description.clone(), + p.url.clone().unwrap_or_default(), + ]) + }); + + let widths = [ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(7), + Constraint::Length(7), + Constraint::Length(8), + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Fill(1), + ]; + + let places_table = Table::new(places, widths) + .header( + Row::new([ + "Id", + "Name", + "Lat", + "Long", + "Icon", + "Open Hours", + "Description", + "URL", + ]) + .style(Style::new().white().on_dark_gray().bold()), + ) + .row_highlight_style(Style::new().reversed()); + + f.render_stateful_widget(places_table, area, &mut state.selected_place); +} + +fn edit_draw(_state: &mut State, f: &mut Frame<'_>, area: Rect) { + let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60)); + let confirmation_dialog = + Paragraph::new(Text::from_iter([ + Line::from("Not implemented yet :(").italic() + ])) + .centered() + .block(Block::bordered().padding(Padding::uniform(1))); + + f.render_widget(Clear, dialog_area); + f.render_widget(confirmation_dialog, dialog_area); +} + +#[expect(unstable_name_collisions)] +fn footer_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { + let separator = Span::styled(" ", Style::new().black().on_black()); + let keybindings = match state.mode { + Mode::List => { + let keybindings = [ + ("j", "Next"), + ("k", "Previous"), + ("Home", "First"), + ("End", "Last"), + ("PgUp", "Prev Page"), + ("PgDown", "Next Page"), + ("e", "Edit"), + ("d", "Delete"), + ] + .map(|(key, action)| keybinding(key, action).to_vec()) + .into_iter() + .intersperse(vec![separator]) + .flatten(); + Paragraph::new(Line::from_iter(keybindings)) + } + Mode::Edit => { + let keybindings = [("Esc", "Close w/o saving")] + .map(|(key, action)| keybinding(key, action).to_vec()) + .into_iter() + .intersperse(vec![separator]) + .flatten(); + Paragraph::new(Line::from_iter(keybindings)) + } + }; + f.render_widget(keybindings, area); +} + +fn keybinding(key: &'static str, action: &'static str) -> [Span<'static>; 5] { + let black_bold = Style::new().black().on_gray().bold(); + let red_bold = Style::new().red().on_gray().bold(); + let black = Style::new().black().on_gray(); + [ + Span::styled(" <", black_bold), + Span::styled(key, red_bold), + Span::styled("> ", black_bold), + Span::styled(action, black), + Span::styled(" ", black), + ] +} + +fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { + let [area] = Layout::horizontal([horizontal]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); + area +}