diff --git a/Cargo.lock b/Cargo.lock index bfdf61b..1808307 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,805 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hud" version = "0.1.0" +dependencies = [ + "crossterm", + "ratatui", + "serde", + "toml", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[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.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" diff --git a/Cargo.toml b/Cargo.toml index 1609922..372a8c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +crossterm = "0.29.0" +ratatui = { version = "0.30.0", default-features = false, features = ["crossterm"] } +serde = { version = "1.0.228", features = ["derive"] } +toml = "1.1.2" diff --git a/README.md b/README.md index 72ac9ce..c2ceec8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,149 @@ A programmable terminal cockpit for getting oriented, finding the next thing tha ## Status -Pre-implementation scaffold. The accepted product PRD lives in `tnezdev/proposals` at `proposals/hud.md`. +Early V1 implementation. The accepted product PRD lives in `tnezdev/proposals` at `proposals/hud.md`. + +V1 proves the cockpit loop: + +```text +open -> orient -> move focus -> refresh -> act +``` + +## Install From Source + +Prerequisites: + +- Rust toolchain with Cargo. +- A terminal that supports alternate-screen TUIs. +- Optional local tools used by your panel commands, such as `tmux`, `gh`, or `task`. + +Install the current GitHub source: + +```sh +cargo install --git git@github.com:tnezdev/hud.git +``` + +Update to the latest source: + +```sh +cargo install --git git@github.com:tnezdev/hud.git --force +``` + +Verify: + +```sh +hud --version +``` + +## Configuration + +By default, `hud` reads: + +```text +$XDG_CONFIG_HOME/.hud/config.toml +``` + +If `XDG_CONFIG_HOME` is unset, the fallback is: + +```text +$HOME/.config/.hud/config.toml +``` + +Use a specific file with: + +```sh +hud --config ./examples/dogfood.toml +``` + +Validate config without opening the TUI: + +```sh +hud --config ./examples/dogfood.toml --check-config +``` + +Minimal config: + +```toml +title = "Work cockpit" +default_timeout_secs = 120 + +[[panels]] +id = "tasks" +title = "Tasks" +command = "task mine" +timeout_secs = 10 + +[[panels.actions]] +key = "t" +label = "Open tasks" +command = "taskwarrior-tui" +``` + +Config shape: + +- `title`: dashboard title. +- `default_timeout_secs`: optional command timeout, default `120`. +- `[[panels]]`: one command-backed panel. +- `panels.id`: unique panel id. +- `panels.title`: panel title. +- `panels.command`: shell command used to refresh the panel. +- `panels.timeout_secs`: optional panel timeout override. +- `[[panels.actions]]`: optional fire-and-forget action for the focused panel. +- `panels.actions.key`: single-character keyboard shortcut. +- `panels.actions.label`: footer label. +- `panels.actions.command`: shell command launched without waiting for completion. + +Plain text stdout is the V1 panel content protocol. Non-zero exits, timeouts, and launch failures render as panel error states instead of crashing the dashboard. + +## Usage + +Run with your default config: + +```sh +hud +``` + +Try the built-in demo: + +```sh +hud --demo +``` + +Try the repository dogfood config: + +```sh +cargo run -- --config examples/dogfood.toml +``` + +Keybindings: + +- `q`, `Esc`, or `Ctrl-C`: quit. +- `h`/`j`/`k`/`l`, arrow keys, or `Tab`: move focus. +- `r`: refresh focused panel. +- `R`: refresh all panels. +- Focused-panel action keys are shown in the footer. + +## tmux Popup + +One-off popup: + +```sh +tmux display-popup -E -w 90% -h 80% 'hud' +``` + +With a local config during development: + +```sh +tmux display-popup -E -w 90% -h 80% 'cd /path/to/hud && cargo run -- --config examples/dogfood.toml' +``` + +Example keybinding: + +```tmux +bind-key H display-popup -E -w 90% -h 80% 'hud' +``` + +The dashboard is most comfortable at roughly 100 columns by 30 rows or larger. Quit returns control cleanly to tmux because `hud` restores the terminal alternate screen on exit. ## Development diff --git a/docs/engineering-spec.md b/docs/engineering-spec.md index 9589776..15cc8f6 100644 --- a/docs/engineering-spec.md +++ b/docs/engineering-spec.md @@ -43,6 +43,8 @@ src/ui.rs // ratatui rendering from typed state These boundaries are provisional. They should appear only when code needs them, not as empty architecture scaffolding. +The current implementation has crossed the first useful threshold for these modules: config parsing, command execution, dashboard state, app coordination, action resolution, and ratatui rendering now exist because V1 needs them. + ## Testing Strategy - Unit tests cover config validation, output parsing, focus movement, and action resolution. @@ -60,20 +62,60 @@ These boundaries are provisional. They should appear only when code needs them, - Avoid an async runtime until interval refresh, command execution, or input handling proves it is needed. - Treat long-running plugins, generated UI, approvals, and agent supervision as later design directions. +## Config Shape + +V1 uses static TOML discovered at `$XDG_CONFIG_HOME/.hud/config.toml`, falling back to `$HOME/.config/.hud/config.toml` when `XDG_CONFIG_HOME` is unset. + +```toml +title = "Work cockpit" +default_timeout_secs = 120 + +[[panels]] +id = "tasks" +title = "Tasks" +command = "task mine" +timeout_secs = 10 + +[[panels.actions]] +key = "t" +label = "Open tasks" +command = "taskwarrior-tui" +``` + +Config parsing happens once at the boundary into typed Rust values. Readers consume typed config, not TOML values. + +## Command Boundary + +Panel refreshes run through an injectable command runner. The real runner is a shell-command adapter at the app edge. Unit tests use fakes rather than real shell commands. + +Command results distinguish: + +- stdout +- stderr +- exit status +- timeout +- launch failure + +The V1 default command timeout is 120 seconds. Panels can override it with `timeout_secs`. + +Action commands use the same injectable boundary but are fire-and-forget: `hud` verifies that the action launches, then returns to the dashboard without waiting for completion. + +## Output Protocol + +Plain text stdout is valid V1 panel content. Structured output is intentionally reserved for a later semantic component pass; it should be parsed once at the panel output boundary when introduced. + ## Refresh Model V1 starts with manual refresh only. - Manual refresh reruns either the focused panel or all panels, depending on the keybinding. - There is no background polling in the first implementation slice. -- A slow panel should not block input handling or crash the dashboard. +- Manually triggered panel commands run outside the terminal input loop so a slow panel does not block navigation or quitting. - Long-running panel processes are out of scope for v1; each refresh is a bounded command invocation. - Per-panel interval refresh remains a likely later extension. When added, time must enter through an injectable clock or tick source so tests can advance time deterministically. ## Open Decisions -1. What is the smallest useful config shape? -2. Should structured panel output be newline-delimited JSON in v1, or a single JSON document per refresh? -3. When should per-panel interval refresh be introduced? -4. What is the minimum semantic component set for the first useful dashboard? -5. What does a deploy mean initially: local binary, GitHub release artifact, or package manager path? +1. When should per-panel interval refresh be introduced? +2. What is the minimum semantic component set after plain text? +3. Should structured panel output be newline-delimited JSON, a single JSON document per refresh, or both behind explicit protocol markers? diff --git a/docs/v1-scope.md b/docs/v1-scope.md index 6bbc310..400e026 100644 --- a/docs/v1-scope.md +++ b/docs/v1-scope.md @@ -166,11 +166,10 @@ V1 should not require all of these to be polished before the cockpit loop works. - V1 panel sources are command-backed. - V1 actions are fire-and-forget local commands. - V1 deploy means a source-install path from GitHub. +- The default panel command timeout is 120 seconds, with per-panel `timeout_secs` overrides. +- Plain text stdout is the V1 output protocol; structured output remains a reserved boundary for later semantic components. ## Open Scope Questions -1. What is the exact TOML config shape? -2. Does V1 implement structured output, or only reserve and document the boundary? -3. Is the default command timeout 2 minutes, 5 minutes, or something panel-type-specific? -4. Which semantic component comes immediately after plain text? -5. Should initial panel commands run automatically on app open, or should the first screen wait for manual refresh? +1. Which semantic component comes immediately after plain text? +2. Should structured panel output be newline-delimited JSON, a single JSON document per refresh, or both behind explicit protocol markers? diff --git a/examples/dogfood.toml b/examples/dogfood.toml new file mode 100644 index 0000000..334d830 --- /dev/null +++ b/examples/dogfood.toml @@ -0,0 +1,47 @@ +title = "Work cockpit" +default_timeout_secs = 15 + +[[panels]] +id = "tmux" +title = "tmux sessions" +command = "tmux list-sessions 2>/dev/null || printf 'no tmux server\n'" +timeout_secs = 5 + +[[panels.actions]] +key = "t" +label = "switch to work" +command = "tmux has-session -t work 2>/dev/null || tmux new-session -d -s work; tmux switch-client -t work" + +[[panels]] +id = "repos" +title = "dirty repos" +command = "for dir in \"$HOME\"/Code/*; do [ -d \"$dir/.git\" ] || continue; status=$(git -C \"$dir\" status --short 2>/dev/null); [ -n \"$status\" ] && printf '%s\n%s\n\n' \"${dir##*/}\" \"$status\"; done" +timeout_secs = 20 + +[[panels.actions]] +key = "r" +label = "code window" +command = "tmux new-window -n code -c \"$HOME/Code\"" + +[[panels]] +id = "github" +title = "hud issues" +command = "gh issue list --repo tnezdev/hud --state open --limit 8 2>/dev/null || printf 'gh unavailable or not authenticated\n'" +timeout_secs = 10 + +[[panels.actions]] +key = "g" +label = "open GitHub" +command = "gh repo view tnezdev/hud --web" + +[[panels]] +id = "tasks" +title = "tasks" +command = "task mine limit:8 2>/dev/null || printf 'taskwarrior unavailable\n'" +timeout_secs = 10 + +[[panels]] +id = "agents" +title = "agents" +command = "ps -axo pid,comm,args | grep -E '[c]odex|[a]gent' | head -12" +timeout_secs = 5 diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..8470a62 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,59 @@ +use crate::panel::{Action, DashboardState}; + +pub fn resolve_action(state: &DashboardState, key: char) -> Option { + state + .focused_panel() + .and_then(|panel| panel.actions.iter().find(|action| action.key == key)) + .cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::HudConfig; + + #[test] + fn resolves_action_for_focused_panel_only() { + let config = HudConfig::from_toml( + r#" + title = "Test" + + [[panels]] + id = "one" + title = "One" + command = "one" + + [[panels.actions]] + key = "o" + label = "Open one" + command = "open-one" + + [[panels]] + id = "two" + title = "Two" + command = "two" + + [[panels.actions]] + key = "t" + label = "Open two" + command = "open-two" + "#, + ) + .expect("valid config"); + let mut state = DashboardState::from_config(&config); + + assert_eq!( + resolve_action(&state, 'o').map(|action| action.command), + Some("open-one".into()) + ); + assert_eq!(resolve_action(&state, 't'), None); + + state.focus_next(); + + assert_eq!(resolve_action(&state, 'o'), None); + assert_eq!( + resolve_action(&state, 't').map(|action| action.command), + Some("open-two".into()) + ); + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..eb03ced --- /dev/null +++ b/src/app.rs @@ -0,0 +1,251 @@ +use crate::{ + action::resolve_action, + command::{ActionRequest, CommandRequest, CommandResult, CommandRunner}, + config::HudConfig, + panel::{DashboardState, FocusMovement}, + ui, +}; +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::{ + io, + sync::mpsc::{self, Receiver, Sender}, + thread, + time::Duration, +}; + +pub struct HudApp { + pub state: DashboardState, + runner: R, + refresh_tx: Sender, + refresh_rx: Receiver, +} + +struct RefreshMessage { + panel_index: usize, + result: CommandResult, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Control { + Continue, + Quit, +} + +impl HudApp { + pub fn new(config: HudConfig, runner: R) -> Self { + let (refresh_tx, refresh_rx) = mpsc::channel(); + + Self { + state: DashboardState::from_config(&config), + runner, + refresh_tx, + refresh_rx, + } + } + + pub fn refresh_all(&mut self) { + for index in 0..self.state.panels.len() { + self.spawn_refresh(index); + } + } + + pub fn refresh_focused(&mut self) { + self.spawn_refresh(self.state.focused); + } + + pub fn drain_refreshes(&mut self) { + while let Ok(message) = self.refresh_rx.try_recv() { + self.state.apply_result(message.panel_index, message.result); + } + } + + fn spawn_refresh(&mut self, panel_index: usize) { + let Some(panel) = self.state.panels.get(panel_index).cloned() else { + return; + }; + + self.state.mark_loading(panel_index); + + let runner = self.runner.clone(); + let tx = self.refresh_tx.clone(); + thread::spawn(move || { + let result = runner.run(CommandRequest { + command: panel.command, + timeout: panel.timeout, + }); + + let _ = tx.send(RefreshMessage { + panel_index, + result, + }); + }); + } + + fn handle_key(&mut self, key: KeyEvent) -> Control { + if key.kind != KeyEventKind::Press { + return Control::Continue; + } + + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Control::Quit, + KeyCode::Esc | KeyCode::Char('q') => Control::Quit, + KeyCode::Tab + | KeyCode::Right + | KeyCode::Down + | KeyCode::Char('j') + | KeyCode::Char('l') => { + self.state.move_focus(FocusMovement::Next); + Control::Continue + } + KeyCode::BackTab + | KeyCode::Left + | KeyCode::Up + | KeyCode::Char('h') + | KeyCode::Char('k') => { + self.state.move_focus(FocusMovement::Previous); + Control::Continue + } + KeyCode::Char('r') => { + self.refresh_focused(); + Control::Continue + } + KeyCode::Char('R') => { + self.refresh_all(); + Control::Continue + } + KeyCode::Char(key) => { + self.launch_action(key); + Control::Continue + } + _ => Control::Continue, + } + } + + fn launch_action(&mut self, key: char) { + let Some(action) = resolve_action(&self.state, key) else { + return; + }; + + match self.runner.launch(ActionRequest { + command: action.command.clone(), + }) { + Ok(()) => self.state.set_notice(format!( + "launched action '{}': {}", + action.key, action.label + )), + Err(error) => self.state.set_notice(error.message), + } + } +} + +pub fn run_terminal_app(mut app: HudApp) -> io::Result<()> { + let _session = TerminalSession::enter()?; + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + + app.refresh_all(); + + loop { + app.drain_refreshes(); + terminal.draw(|frame| ui::draw(frame, &app.state))?; + + if event::poll(Duration::from_millis(50))? { + let Event::Key(key) = event::read()? else { + continue; + }; + + if app.handle_key(key) == Control::Quit { + break; + } + } + } + + Ok(()) +} + +struct TerminalSession; + +impl TerminalSession { + fn enter() -> io::Result { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + Ok(Self) + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::{CommandLaunchError, CommandStatus}; + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + struct FakeRunner { + launched: Arc>>, + } + + impl CommandRunner for FakeRunner { + fn run(&self, _request: CommandRequest) -> CommandResult { + CommandResult { + stdout: "ok".into(), + stderr: String::new(), + status: CommandStatus::Exited(0), + } + } + + fn launch(&self, request: ActionRequest) -> Result<(), CommandLaunchError> { + self.launched + .lock() + .expect("lock launched") + .push(request.command); + Ok(()) + } + } + + #[test] + fn action_launch_uses_injectable_runner() { + let config = HudConfig::from_toml( + r#" + title = "Test" + + [[panels]] + id = "one" + title = "One" + command = "one" + + [[panels.actions]] + key = "o" + label = "Open" + command = "open" + "#, + ) + .expect("valid config"); + let launched = Arc::new(Mutex::new(Vec::new())); + let runner = FakeRunner { + launched: launched.clone(), + }; + let mut app = HudApp::new(config, runner); + + app.launch_action('o'); + + assert_eq!(launched.lock().expect("lock launched").as_slice(), ["open"]); + assert!( + app.state + .notice + .as_deref() + .expect("notice") + .contains("launched action") + ); + } +} diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..155016e --- /dev/null +++ b/src/command.rs @@ -0,0 +1,234 @@ +use std::{ + env, fs, + fs::File, + path::PathBuf, + process::{Command, Stdio}, + sync::atomic::{AtomicU64, Ordering}, + thread, + time::{Duration, Instant}, +}; + +static CAPTURE_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandRequest { + pub command: String, + pub timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionRequest { + pub command: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandResult { + pub stdout: String, + pub stderr: String, + pub status: CommandStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandStatus { + Exited(i32), + TimedOut, + LaunchFailed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandLaunchError { + pub message: String, +} + +pub trait CommandRunner: Clone + Send + Sync + 'static { + fn run(&self, request: CommandRequest) -> CommandResult; + fn launch(&self, request: ActionRequest) -> Result<(), CommandLaunchError>; +} + +#[derive(Debug, Clone, Default)] +pub struct ShellCommandRunner; + +impl CommandRunner for ShellCommandRunner { + fn run(&self, request: CommandRequest) -> CommandResult { + let capture = match CaptureFiles::new() { + Ok(capture) => capture, + Err(error) => { + return CommandResult { + stdout: String::new(), + stderr: String::new(), + status: CommandStatus::LaunchFailed(format!( + "failed to create capture files: {error}" + )), + }; + } + }; + + let stdout = match File::create(&capture.stdout_path) { + Ok(file) => file, + Err(error) => { + return CommandResult { + stdout: String::new(), + stderr: String::new(), + status: CommandStatus::LaunchFailed(format!( + "failed to capture stdout: {error}" + )), + }; + } + }; + let stderr = match File::create(&capture.stderr_path) { + Ok(file) => file, + Err(error) => { + return CommandResult { + stdout: String::new(), + stderr: String::new(), + status: CommandStatus::LaunchFailed(format!( + "failed to capture stderr: {error}" + )), + }; + } + }; + + let mut child = match shell_command(&request.command) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + { + Ok(child) => child, + Err(error) => { + return CommandResult { + stdout: String::new(), + stderr: String::new(), + status: CommandStatus::LaunchFailed(format!( + "failed to launch command: {error}" + )), + }; + } + }; + + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break CommandStatus::Exited(status.code().unwrap_or(-1)), + Ok(None) if started.elapsed() >= request.timeout => { + let _ = child.kill(); + let _ = child.wait(); + break CommandStatus::TimedOut; + } + Ok(None) => thread::sleep(Duration::from_millis(20)), + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + break CommandStatus::LaunchFailed(format!( + "failed to monitor command: {error}" + )); + } + } + }; + + CommandResult { + stdout: capture.read_stdout(), + stderr: capture.read_stderr(), + status, + } + } + + fn launch(&self, request: ActionRequest) -> Result<(), CommandLaunchError> { + let mut child = shell_command(&request.command) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| CommandLaunchError { + message: format!("failed to launch action: {error}"), + })?; + + thread::spawn(move || { + let _ = child.wait(); + }); + + Ok(()) + } +} + +fn shell_command(command: &str) -> Command { + let shell = env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into()); + let mut process = Command::new(shell); + process.arg("-lc").arg(command); + process +} + +#[derive(Debug)] +struct CaptureFiles { + stdout_path: PathBuf, + stderr_path: PathBuf, +} + +impl CaptureFiles { + fn new() -> std::io::Result { + let id = CAPTURE_COUNTER.fetch_add(1, Ordering::Relaxed); + let base = env::temp_dir().join(format!("hud-{}-{id}", std::process::id())); + Ok(Self { + stdout_path: base.with_extension("stdout"), + stderr_path: base.with_extension("stderr"), + }) + } + + fn read_stdout(&self) -> String { + read_capture_file(&self.stdout_path) + } + + fn read_stderr(&self) -> String { + read_capture_file(&self.stderr_path) + } +} + +impl Drop for CaptureFiles { + fn drop(&mut self) { + let _ = fs::remove_file(&self.stdout_path); + let _ = fs::remove_file(&self.stderr_path); + } +} + +fn read_capture_file(path: &PathBuf) -> String { + fs::read_to_string(path).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone)] + struct FakeRunner { + result: CommandResult, + } + + impl CommandRunner for FakeRunner { + fn run(&self, _request: CommandRequest) -> CommandResult { + self.result.clone() + } + + fn launch(&self, _request: ActionRequest) -> Result<(), CommandLaunchError> { + Ok(()) + } + } + + #[test] + fn fake_runner_can_stand_in_for_command_execution() { + let runner = FakeRunner { + result: CommandResult { + stdout: "ok".into(), + stderr: String::new(), + status: CommandStatus::Exited(0), + }, + }; + + let result = runner.run(CommandRequest { + command: "ignored".into(), + timeout: Duration::from_secs(1), + }); + + assert_eq!(result.stdout, "ok"); + assert_eq!(result.status, CommandStatus::Exited(0)); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..411cda1 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,398 @@ +use serde::Deserialize; +use std::{ + collections::HashSet, + env, fmt, fs, + path::{Path, PathBuf}, + time::Duration, +}; + +pub const DEFAULT_TIMEOUT_SECS: u64 = 120; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HudConfig { + pub title: String, + pub default_timeout: Duration, + pub panels: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PanelConfig { + pub id: String, + pub title: String, + pub command: String, + pub timeout: Duration, + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionConfig { + pub key: char, + pub label: String, + pub command: String, +} + +#[derive(Debug)] +pub enum ConfigError { + MissingPathContext, + Missing { + path: PathBuf, + }, + Read { + path: PathBuf, + message: String, + }, + Parse { + path: Option, + message: String, + }, + Validation { + errors: Vec, + }, +} + +impl HudConfig { + pub fn load_default() -> Result { + Self::load_from_path(default_config_path()?) + } + + pub fn load_from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Err(ConfigError::Missing { + path: path.to_path_buf(), + }); + } + + let contents = fs::read_to_string(path).map_err(|error| ConfigError::Read { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + Self::from_toml_with_path(&contents, Some(path.to_path_buf())) + } + + pub fn from_toml(input: &str) -> Result { + Self::from_toml_with_path(input, None) + } + + fn from_toml_with_path(input: &str, path: Option) -> Result { + let raw: RawHudConfig = toml::from_str(input).map_err(|error| ConfigError::Parse { + path, + message: error.to_string(), + })?; + + raw.validate() + } +} + +impl fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigError::MissingPathContext => { + write!( + formatter, + "could not determine config path because neither XDG_CONFIG_HOME nor HOME is set" + ) + } + ConfigError::Missing { path } => write!( + formatter, + "missing hud config at {}. Create it or pass --config .", + path.display() + ), + ConfigError::Read { path, message } => { + write!( + formatter, + "could not read config at {}: {message}", + path.display() + ) + } + ConfigError::Parse { path, message } => { + if let Some(path) = path { + write!(formatter, "invalid TOML in {}: {message}", path.display()) + } else { + write!(formatter, "invalid TOML: {message}") + } + } + ConfigError::Validation { errors } => { + write!(formatter, "invalid hud config: {}", errors.join("; ")) + } + } + } +} + +impl std::error::Error for ConfigError {} + +pub fn default_config_path() -> Result { + if let Some(config_home) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(config_home).join(".hud").join("config.toml")); + } + + if let Some(home) = env::var_os("HOME").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(home) + .join(".config") + .join(".hud") + .join("config.toml")); + } + + Err(ConfigError::MissingPathContext) +} + +pub fn demo_config() -> HudConfig { + HudConfig::from_toml( + r#" + title = "hud demo" + default_timeout_secs = 10 + + [[panels]] + id = "orientation" + title = "Orientation" + command = "printf 'hud demo\n\nr refresh panel\nR refresh all\nq quit\n'" + + [[panels.actions]] + key = "e" + label = "Print environment" + command = "env | sort | head -20" + + [[panels]] + id = "calendar" + title = "Now" + command = "date" + "#, + ) + .expect("built-in demo config is valid") +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawHudConfig { + title: Option, + default_timeout_secs: Option, + #[serde(default)] + panels: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPanelConfig { + id: Option, + title: Option, + command: Option, + timeout_secs: Option, + #[serde(default)] + actions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawActionConfig { + key: Option, + label: Option, + command: Option, +} + +impl RawHudConfig { + fn validate(self) -> Result { + let mut errors = Vec::new(); + + let title = required_text("title", self.title, &mut errors); + let default_timeout_secs = self.default_timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS); + validate_timeout("default_timeout_secs", default_timeout_secs, &mut errors); + + if self.panels.is_empty() { + errors.push("at least one [[panels]] entry is required".into()); + } + + let mut panel_ids = HashSet::new(); + let mut panels = Vec::new(); + for (index, panel) in self.panels.into_iter().enumerate() { + let label = format!("panels[{index}]"); + let id = required_text(&format!("{label}.id"), panel.id, &mut errors); + let panel_title = required_text(&format!("{label}.title"), panel.title, &mut errors); + let command = required_text(&format!("{label}.command"), panel.command, &mut errors); + + if let Some(id) = &id + && !panel_ids.insert(id.clone()) + { + errors.push(format!("duplicate panel id '{id}'")); + } + + let timeout_secs = panel.timeout_secs.unwrap_or(default_timeout_secs); + validate_timeout(&format!("{label}.timeout_secs"), timeout_secs, &mut errors); + + let actions = validate_actions(&label, panel.actions, &mut errors); + + if let (Some(id), Some(title), Some(command)) = (id, panel_title, command) { + panels.push(PanelConfig { + id, + title, + command, + timeout: Duration::from_secs(timeout_secs), + actions, + }); + } + } + + if !errors.is_empty() { + return Err(ConfigError::Validation { errors }); + } + + Ok(HudConfig { + title: title.expect("title is present after validation"), + default_timeout: Duration::from_secs(default_timeout_secs), + panels, + }) + } +} + +fn validate_actions( + panel_label: &str, + actions: Vec, + errors: &mut Vec, +) -> Vec { + let mut action_keys = HashSet::new(); + let mut valid_actions = Vec::new(); + + for (index, action) in actions.into_iter().enumerate() { + let label = format!("{panel_label}.actions[{index}]"); + let key = action.key.and_then(|key| { + let mut chars = key.chars(); + let first = chars.next(); + if first.is_none() || chars.next().is_some() { + errors.push(format!("{label}.key must be exactly one character")); + None + } else { + first + } + }); + let action_label = required_text(&format!("{label}.label"), action.label, errors); + let command = required_text(&format!("{label}.command"), action.command, errors); + + if let Some(key) = key + && !action_keys.insert(key) + { + errors.push(format!("duplicate action key '{key}' in {panel_label}")); + } + + if let (Some(key), Some(label), Some(command)) = (key, action_label, command) { + valid_actions.push(ActionConfig { + key, + label, + command, + }); + } + } + + valid_actions +} + +fn required_text(field: &str, value: Option, errors: &mut Vec) -> Option { + match value.map(|value| value.trim().to_string()) { + Some(value) if !value.is_empty() => Some(value), + _ => { + errors.push(format!("{field} is required")); + None + } + } +} + +fn validate_timeout(field: &str, value: u64, errors: &mut Vec) { + if value == 0 { + errors.push(format!("{field} must be greater than zero")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_valid_config() { + let config = HudConfig::from_toml( + r#" + title = "Workspace" + default_timeout_secs = 30 + + [[panels]] + id = "tasks" + title = "Tasks" + command = "task mine" + timeout_secs = 5 + + [[panels.actions]] + key = "t" + label = "Open tasks" + command = "taskwarrior-tui" + "#, + ) + .expect("valid config"); + + assert_eq!(config.title, "Workspace"); + assert_eq!(config.default_timeout, Duration::from_secs(30)); + assert_eq!(config.panels[0].timeout, Duration::from_secs(5)); + assert_eq!(config.panels[0].actions[0].key, 't'); + } + + #[test] + fn reports_missing_required_fields() { + let error = HudConfig::from_toml( + r#" + title = "Broken" + + [[panels]] + id = "tasks" + title = "Tasks" + "#, + ) + .expect_err("missing command is invalid"); + + assert!(error.to_string().contains("panels[0].command is required")); + } + + #[test] + fn reports_invalid_shapes() { + let error = HudConfig::from_toml( + r#" + title = "Broken" + panels = "not a list" + "#, + ) + .expect_err("invalid TOML shape"); + + assert!(error.to_string().contains("invalid TOML")); + } + + #[test] + fn rejects_invalid_action_key() { + let error = HudConfig::from_toml( + r#" + title = "Broken" + + [[panels]] + id = "tasks" + title = "Tasks" + command = "task mine" + + [[panels.actions]] + key = "go" + label = "Go" + command = "echo go" + "#, + ) + .expect_err("multi-character action key is invalid"); + + assert!( + error + .to_string() + .contains("key must be exactly one character") + ); + } + + #[test] + fn missing_config_path_is_clear() { + let path = env::temp_dir().join("hud-test-missing-config.toml"); + let error = HudConfig::load_from_path(&path).expect_err("missing file is invalid"); + + assert!(error.to_string().contains("missing hud config")); + assert!(error.to_string().contains(path.to_string_lossy().as_ref())); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9aef291 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod action; +pub mod app; +pub mod command; +pub mod config; +pub mod panel; +pub mod ui; diff --git a/src/main.rs b/src/main.rs index aaa1a0b..012ee77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,84 @@ +use hud::{ + app::{HudApp, run_terminal_app}, + command::ShellCommandRunner, + config::{HudConfig, demo_config}, +}; +use std::{env, path::PathBuf, process}; + fn main() { - println!("hud: terminal cockpit prototype"); + if let Err(error) = run() { + eprintln!("{error}"); + process::exit(1); + } +} + +fn run() -> Result<(), String> { + let cli = Cli::parse(env::args().skip(1))?; + + if cli.help { + print_help(); + return Ok(()); + } + + if cli.version { + println!("hud {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + let config = if cli.demo { + demo_config() + } else if let Some(path) = cli.config { + HudConfig::load_from_path(path).map_err(|error| error.to_string())? + } else { + HudConfig::load_default().map_err(|error| error.to_string())? + }; + + if cli.check_config { + println!("hud config OK: {} panels", config.panels.len()); + return Ok(()); + } + + let app = HudApp::new(config, ShellCommandRunner); + run_terminal_app(app).map_err(|error| format!("terminal error: {error}")) +} + +#[derive(Debug, Default)] +struct Cli { + config: Option, + check_config: bool, + demo: bool, + help: bool, + version: bool, +} + +impl Cli { + fn parse(args: impl IntoIterator) -> Result { + let mut cli = Cli::default(); + let mut args = args.into_iter(); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--config" => { + let Some(path) = args.next() else { + return Err("--config requires a path".into()); + }; + cli.config = Some(path.into()); + } + "--check-config" => cli.check_config = true, + "--demo" => cli.demo = true, + "--help" | "-h" => cli.help = true, + "--version" | "-V" => cli.version = true, + unknown => return Err(format!("unknown argument: {unknown}")), + } + } + + Ok(cli) + } +} + +fn print_help() { + println!( + "hud {}\n\nUsage:\n hud [--config ] [--check-config]\n hud --demo\n hud --version\n\nKeys:\n q/Esc/Ctrl-C quit\n h/j/k/l or arrows move focus\n r refresh focused panel\n R refresh all panels\n panel action keys are shown in the footer", + env!("CARGO_PKG_VERSION") + ); } diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..0334759 --- /dev/null +++ b/src/panel.rs @@ -0,0 +1,261 @@ +use crate::{ + command::{CommandResult, CommandStatus}, + config::HudConfig, +}; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DashboardState { + pub title: String, + pub panels: Vec, + pub focused: usize, + pub notice: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Panel { + pub id: String, + pub title: String, + pub command: String, + pub timeout: Duration, + pub actions: Vec, + pub state: PanelState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Action { + pub key: char, + pub label: String, + pub command: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PanelState { + Idle, + Loading, + Ready { output: String }, + Error(PanelError), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PanelError { + pub message: String, + pub detail: Option, + pub kind: PanelErrorKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PanelErrorKind { + ExitStatus(i32), + TimedOut, + LaunchFailed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusMovement { + Next, + Previous, +} + +impl DashboardState { + pub fn from_config(config: &HudConfig) -> Self { + Self { + title: config.title.clone(), + panels: config + .panels + .iter() + .map(|panel| Panel { + id: panel.id.clone(), + title: panel.title.clone(), + command: panel.command.clone(), + timeout: panel.timeout, + actions: panel + .actions + .iter() + .map(|action| Action { + key: action.key, + label: action.label.clone(), + command: action.command.clone(), + }) + .collect(), + state: PanelState::Idle, + }) + .collect(), + focused: 0, + notice: None, + } + } + + pub fn focused_panel(&self) -> Option<&Panel> { + self.panels.get(self.focused) + } + + pub fn focus_next(&mut self) { + self.move_focus(FocusMovement::Next); + } + + pub fn focus_previous(&mut self) { + self.move_focus(FocusMovement::Previous); + } + + pub fn move_focus(&mut self, movement: FocusMovement) { + if self.panels.is_empty() { + self.focused = 0; + return; + } + + self.focused = match movement { + FocusMovement::Next => (self.focused + 1) % self.panels.len(), + FocusMovement::Previous if self.focused == 0 => self.panels.len() - 1, + FocusMovement::Previous => self.focused - 1, + }; + } + + pub fn mark_loading(&mut self, panel_index: usize) { + if let Some(panel) = self.panels.get_mut(panel_index) { + panel.state = PanelState::Loading; + } + } + + pub fn apply_result(&mut self, panel_index: usize, result: CommandResult) { + if let Some(panel) = self.panels.get_mut(panel_index) { + panel.state = PanelState::from_command_result(result); + } + } + + pub fn set_notice(&mut self, notice: impl Into) { + self.notice = Some(notice.into()); + } + + pub fn clear_notice(&mut self) { + self.notice = None; + } +} + +impl PanelState { + pub fn from_command_result(result: CommandResult) -> Self { + match result.status { + CommandStatus::Exited(0) => PanelState::Ready { + output: result.stdout, + }, + CommandStatus::Exited(status) => PanelState::Error(PanelError { + message: format!("command exited with status {status}"), + detail: non_empty_detail(result.stderr, result.stdout), + kind: PanelErrorKind::ExitStatus(status), + }), + CommandStatus::TimedOut => PanelState::Error(PanelError { + message: "command timed out".into(), + detail: non_empty_detail(result.stderr, result.stdout), + kind: PanelErrorKind::TimedOut, + }), + CommandStatus::LaunchFailed(message) => PanelState::Error(PanelError { + message, + detail: non_empty_detail(result.stderr, result.stdout), + kind: PanelErrorKind::LaunchFailed, + }), + } + } + + pub fn label(&self) -> &'static str { + match self { + PanelState::Idle => "idle", + PanelState::Loading => "loading", + PanelState::Ready { .. } => "ready", + PanelState::Error(_) => "error", + } + } +} + +fn non_empty_detail(primary: String, fallback: String) -> Option { + let primary = primary.trim().to_string(); + if !primary.is_empty() { + return Some(primary); + } + + let fallback = fallback.trim().to_string(); + if !fallback.is_empty() { + Some(fallback) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::HudConfig; + + fn sample_state() -> DashboardState { + let config = HudConfig::from_toml( + r#" + title = "Test" + + [[panels]] + id = "one" + title = "One" + command = "one" + + [[panels]] + id = "two" + title = "Two" + command = "two" + "#, + ) + .expect("valid config"); + + DashboardState::from_config(&config) + } + + #[test] + fn focus_movement_wraps_deterministically() { + let mut state = sample_state(); + + assert_eq!(state.focused, 0); + state.focus_next(); + assert_eq!(state.focused, 1); + state.focus_next(); + assert_eq!(state.focused, 0); + state.focus_previous(); + assert_eq!(state.focused, 1); + } + + #[test] + fn panel_state_tracks_loading_ready_and_error() { + let mut state = sample_state(); + + assert_eq!(state.panels[0].state, PanelState::Idle); + state.mark_loading(0); + assert_eq!(state.panels[0].state, PanelState::Loading); + + state.apply_result( + 0, + CommandResult { + stdout: "done".into(), + stderr: String::new(), + status: CommandStatus::Exited(0), + }, + ); + assert_eq!( + state.panels[0].state, + PanelState::Ready { + output: "done".into() + } + ); + + state.apply_result( + 0, + CommandResult { + stdout: String::new(), + stderr: "nope".into(), + status: CommandStatus::Exited(2), + }, + ); + assert!(matches!( + state.panels[0].state, + PanelState::Error(PanelError { + kind: PanelErrorKind::ExitStatus(2), + .. + }) + )); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..75b39f5 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,142 @@ +use crate::panel::{DashboardState, Panel, PanelState}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +pub fn draw(frame: &mut Frame<'_>, state: &DashboardState) { + let area = frame.area(); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(2), + ]) + .split(area); + + draw_header(frame, sections[0], state); + draw_panels(frame, sections[1], state); + draw_footer(frame, sections[2], state); +} + +fn draw_header(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) { + let header = Paragraph::new(Line::from(vec![ + Span::styled(&state.title, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled( + "open -> orient -> refresh -> act", + Style::default().fg(Color::DarkGray), + ), + ])) + .block(Block::default().borders(Borders::BOTTOM)); + + frame.render_widget(header, area); +} + +fn draw_panels(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) { + if state.panels.is_empty() { + let empty = Paragraph::new("No panels configured") + .block(Block::default().borders(Borders::ALL).title("hud")); + frame.render_widget(empty, area); + return; + } + + let rows = state.panels.len().div_ceil(2); + let row_constraints = vec![Constraint::Ratio(1, rows as u32); rows]; + let row_areas = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) + .split(area); + + for row_index in 0..rows { + let columns_in_row = if state.panels.len() - (row_index * 2) >= 2 { + 2 + } else { + 1 + }; + let column_constraints = vec![Constraint::Ratio(1, columns_in_row as u32); columns_in_row]; + let column_areas = Layout::default() + .direction(Direction::Horizontal) + .constraints(column_constraints) + .split(row_areas[row_index]); + + for column_index in 0..columns_in_row { + let panel_index = row_index * 2 + column_index; + if let Some(panel) = state.panels.get(panel_index) { + draw_panel( + frame, + column_areas[column_index], + panel, + state.focused == panel_index, + ); + } + } + } +} + +fn draw_panel(frame: &mut Frame<'_>, area: Rect, panel: &Panel, focused: bool) { + let border_style = if focused { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + let title = format!(" {} [{}] ", panel.title, panel.state.label()); + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title); + + let text = match &panel.state { + PanelState::Idle => "press r to refresh this panel or R for all panels".into(), + PanelState::Loading => "loading...".into(), + PanelState::Ready { output } if output.trim().is_empty() => "(no output)".into(), + PanelState::Ready { output } => output.clone(), + PanelState::Error(error) => { + let mut lines = vec![format!("error: {}", error.message)]; + if let Some(detail) = &error.detail { + lines.push(String::new()); + lines.push(detail.clone()); + } + lines.join("\n") + } + }; + + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +fn draw_footer(frame: &mut Frame<'_>, area: Rect, state: &DashboardState) { + let mut footer = vec![ + Span::raw("q quit"), + Span::raw(" "), + Span::raw("hjkl/arrow move"), + Span::raw(" "), + Span::raw("r refresh"), + Span::raw(" "), + Span::raw("R refresh all"), + ]; + + if let Some(panel) = state.focused_panel() { + for action in &panel.actions { + footer.push(Span::raw(" ")); + footer.push(Span::styled( + format!("{} {}", action.key, action.label), + Style::default().fg(Color::Cyan), + )); + } + } + + if let Some(notice) = &state.notice { + footer.push(Span::raw(" ")); + footer.push(Span::styled(notice, Style::default().fg(Color::Green))); + } + + let paragraph = Paragraph::new(Line::from(footer)) + .block(Block::default().borders(Borders::TOP)) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +}