commit ff2a0367d55010bba1182d776abbfece449d5f16 Author: David F Date: Mon Feb 9 04:53:44 2026 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ca6bb7c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2021 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "argh" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f384d96bfd3c0b3c41f24dae69ee9602c091d64fc432225cf5295b5abbe0036" +dependencies = [ + "argh_derive", + "argh_shared", +] + +[[package]] +name = "argh_derive" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938e5f66269c1f168035e29ed3fb437b084e476465e9314a0328f4005d7be599" +dependencies = [ + "argh_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "argh_shared" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5127f8a5bc1cfb0faf1f6248491452b8a5b6901068d8da2d47cbb285986ae683" +dependencies = [ + "serde", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[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 = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +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 2.0.114", +] + +[[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 2.0.114", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[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 = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[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 = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[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.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "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-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown", + "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 2.10.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rserial" +version = "0.1.0" +dependencies = [ + "argh", + "chrono", + "color-eyre", + "crossterm", + "ratatui", + "serialport", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +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 2.0.114", +] + +[[package]] +name = "serialport" +version = "4.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f60a586160667241d7702c420fc223939fb3c0bb8d3fac84f78768e8970dee" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix 0.26.4", + "quote", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.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 = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[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 2.0.114", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[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 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[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 = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[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 = "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 = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1753603 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "rserial" +version = "0.1.0" +edition = "2024" + +[dependencies] +argh = "0.1.14" +chrono = "0.4.43" +color-eyre = "0.6.5" +crossterm = "0.29.0" +ratatui = "0.30.0" +serialport = "4.8.1" diff --git a/src/app/export.rs b/src/app/export.rs new file mode 100644 index 0000000..2289ea9 --- /dev/null +++ b/src/app/export.rs @@ -0,0 +1,115 @@ +use std::fmt::Write as FmtWrite; +use std::io; + +use chrono::Local; + +use super::App; +use super::state::MessageView; +use crate::serial::SerialMessage; + +impl App { + /// Export the currently viewed buffer to a log file. + pub(super) fn export_current_view(&mut self) { + match self.write_export(false) { + Ok(filename) => { + self.view.set_status(format!("Exported to {filename}")); + } + Err(e) => { + self.view.set_status(format!("Export failed: {e}")); + } + } + } + + /// Export all buffers to a single log file. + pub(super) fn export_all(&mut self) { + match self.write_export(true) { + Ok(filename) => { + self.view.set_status(format!("Exported to {filename}")); + } + Err(e) => { + self.view.set_status(format!("Export failed: {e}")); + } + } + } + + fn write_export(&self, all: bool) -> io::Result { + let mut output = String::new(); + self.format_header(&mut output); + + if all { + Self::format_messages(&mut output, "Received Messages", &self.messages.received); + Self::format_messages(&mut output, "Sent Messages", &self.messages.sent); + self.format_graphs(&mut output); + } else { + match self.view.current_view { + MessageView::Received => { + Self::format_messages( + &mut output, + "Received Messages", + &self.messages.received, + ); + } + MessageView::Sent => { + Self::format_messages(&mut output, "Sent Messages", &self.messages.sent); + } + MessageView::Graphs => { + self.format_graphs(&mut output); + } + } + } + + let filename = Local::now() + .format("rserial_log_%Y%m%d_%H%M%S.log") + .to_string(); + std::fs::write(&filename, &output)?; + Ok(filename) + } + + fn format_header(&self, out: &mut String) { + let now = Local::now().format("%Y-%m-%d %H:%M:%S"); + let _ = writeln!(out, "=== rserial log export ==="); + let _ = writeln!(out, "Port: {}", self.port); + let _ = writeln!(out, "Baud rate: {}", self.baud); + let _ = writeln!(out, "Export time: {now}"); + let _ = writeln!(out, "====================================="); + let _ = writeln!(out); + } + + fn format_messages(out: &mut String, title: &str, messages: &[SerialMessage]) { + let _ = writeln!(out, "--- {title} ({} entries) ---", messages.len()); + for msg in messages { + let ts = msg.timestamp.format("%H:%M:%S"); + let _ = writeln!(out, "[{ts}] {}", msg.message); + } + let _ = writeln!(out); + } + + fn format_graphs(&self, out: &mut String) { + let _ = writeln!(out, "--- Graph Data ---"); + let _ = writeln!( + out, + "Float values ({} entries):", + self.messages.graph_float.len() + ); + let floats: Vec = self + .messages + .graph_float + .iter() + .map(|v| v.to_string()) + .collect(); + let _ = writeln!(out, "{}", floats.join(", ")); + let _ = writeln!( + out, + "Integer values ({} entries):", + self.messages.graph_int.len() + ); + let ints: Vec = self + .messages + .graph_int + .iter() + .map(|v| v.to_string()) + .collect(); + let _ = writeln!(out, "{}", ints.join(", ")); + let _ = writeln!(out); + } +} diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..80a5d13 --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,45 @@ +use super::state::{Direction, InputBuffer}; + +impl InputBuffer { + pub(super) fn move_cursor(&mut self, dir: Direction) { + if dir == Direction::Left { + let cursor_moved_left = self.cursor.saturating_sub(1); + self.cursor = self.clamp_cursor(cursor_moved_left); + } else if dir == Direction::Right { + let cursor_moved_right = self.cursor.saturating_add(1); + self.cursor = self.clamp_cursor(cursor_moved_right); + } + } + + pub(super) fn enter_char(&mut self, new_char: char) { + let index = self.byte_index(); + self.text.insert(index, new_char); + self.move_cursor(Direction::Right); + } + + fn byte_index(&self) -> usize { + self.text + .char_indices() + .map(|(i, _)| i) + .nth(self.cursor) + .unwrap_or(self.text.len()) + } + + pub(super) fn delete_char(&mut self) { + let is_not_cursor_leftmost = self.cursor != 0; + if is_not_cursor_leftmost { + let current_index = self.cursor; + let from_left_to_current_index = current_index - 1; + + let before_char_to_delete = self.text.chars().take(from_left_to_current_index); + let after_char_to_delete = self.text.chars().skip(current_index); + + self.text = before_char_to_delete.chain(after_char_to_delete).collect(); + self.move_cursor(Direction::Left); + } + } + + fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { + new_cursor_pos.clamp(0, self.text.chars().count()) + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..298766c --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,168 @@ +mod export; +mod input; +mod render; +mod serial_handler; +mod state; + +pub(crate) use state::InputMode; +pub use state::{App, AppExit}; + +use std::time::Duration; + +use color_eyre::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::DefaultTerminal; + +use crate::serial::SerialMessage; +use crate::widgets::CommandPalette; +use state::{Direction, MessageView}; + +const STATUS_MESSAGE_DURATION: Duration = Duration::from_secs(3); + +impl App { + pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result { + loop { + self.view.expire_status(STATUS_MESSAGE_DURATION); + + terminal.draw(|frame| self.draw(frame))?; + + self.sync.tick(&self.serial_connection); + + if let Some(reason) = self.receive_serial() { + return Ok(AppExit::Disconnected(reason)); + } + + if event::poll(Duration::from_millis(5))? + && let Event::Key(key) = event::read()? + { + match self.view.input_mode { + InputMode::Normal => match (key.code, key.modifiers) { + (KeyCode::Char('e'), _) => { + self.view.input_mode = InputMode::Editing; + } + (KeyCode::Char('q'), _) => { + return Ok(AppExit::Quit); + } + (KeyCode::Char('h'), _) => { + self.view.input_mode = InputMode::Help; + } + (KeyCode::Char('f'), _) => { + self.view.receiving = !self.view.receiving; + } + (KeyCode::Char('1'), _) => { + self.view.current_view = MessageView::Received; + } + (KeyCode::Char('2'), _) => { + self.view.current_view = MessageView::Sent; + } + (KeyCode::Char('3'), _) => { + self.view.current_view = MessageView::Graphs; + } + (KeyCode::Char('t'), _) => { + self.view.show_timestamps = !self.view.show_timestamps; + } + (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { + self.messages.graph_float.clear(); + self.messages.graph_int.clear(); + } + (KeyCode::Char('r'), _) => { + self.messages.received.clear(); + self.view.list_state.select(None); + } + (KeyCode::Char('R'), _) => { + self.messages.sent.clear(); + self.view.list_state.select(None); + } + (KeyCode::Char('c'), _) => { + if !self.sync.device_commands.is_empty() { + self.cmd_palette.filter.clear(); + self.cmd_palette.list_state.select(Some(0)); + self.view.input_mode = InputMode::CommandPalette; + } + } + (KeyCode::Char('s'), _) => { + self.sync.start_resync(); + } + (KeyCode::Char('l'), _) => { + self.export_current_view(); + } + (KeyCode::Char('L'), _) => { + self.export_all(); + } + (KeyCode::Char('x'), _) => { + return Ok(AppExit::ManualDisconnect); + } + _ => {} + }, + InputMode::Editing if key.kind == KeyEventKind::Press => match key.code { + KeyCode::Enter => { + let text = self.input.take_input(); + if !text.is_empty() { + self.messages.push_sent(SerialMessage::new(&text)); + self.serial_connection.writeln(&text); + } + } + KeyCode::Char(to_insert) => self.input.enter_char(to_insert), + KeyCode::Backspace => self.input.delete_char(), + KeyCode::Left => self.input.move_cursor(Direction::Left), + KeyCode::Esc => self.view.input_mode = InputMode::Normal, + _ => {} + }, + InputMode::Editing => {} + InputMode::Help => match key.code { + KeyCode::Esc | KeyCode::Char('h') => { + self.view.input_mode = InputMode::Normal; + } + _ => {} + }, + InputMode::CommandPalette if key.kind == KeyEventKind::Press => { + match key.code { + KeyCode::Esc => { + self.view.input_mode = InputMode::Normal; + } + KeyCode::Up => { + let i = self + .cmd_palette + .list_state + .selected() + .unwrap_or(0) + .saturating_sub(1); + self.cmd_palette.list_state.select(Some(i)); + } + KeyCode::Down => { + let i = self + .cmd_palette + .list_state + .selected() + .map(|i| i + 1) + .unwrap_or(0); + self.cmd_palette.list_state.select(Some(i)); + } + KeyCode::Enter => { + if let Some(cmd) = CommandPalette::selected_command( + &self.sync.device_commands, + &self.cmd_palette.filter, + &self.cmd_palette.list_state, + ) { + self.input.text = format!("{} ", cmd.name); + self.input.cursor = self.input.text.chars().count(); + self.view.input_mode = InputMode::Editing; + } + } + KeyCode::Char(c) => { + self.cmd_palette.filter.push(c); + self.cmd_palette.list_state.select(Some(0)); + } + KeyCode::Backspace => { + self.cmd_palette.filter.pop(); + self.cmd_palette.list_state.select(Some(0)); + } + _ => {} + } + } + InputMode::CommandPalette => {} + } + } + } + } +} diff --git a/src/app/render.rs b/src/app/render.rs new file mode 100644 index 0000000..8c86c1e --- /dev/null +++ b/src/app/render.rs @@ -0,0 +1,122 @@ +use ratatui::{ + Frame, + layout::{Constraint, Layout, Position}, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +use super::App; +use super::state::{InputMode, MessageView}; +use crate::constants::{DEFAULT_THEME, MIN_COLS, MIN_ROWS}; +use crate::widgets::{ + CommandPalette, GraphView, HelpBar, HelpPopup, InputField, MessageList, StatusBar, +}; + +impl App { + pub(super) fn draw(&self, frame: &mut Frame) { + let area = frame.area(); + if area.width < MIN_COLS || area.height < MIN_ROWS { + let msg = format!("Terminal too small (need {}x{})", MIN_COLS, MIN_ROWS); + let paragraph = ratatui::widgets::Paragraph::new(msg).centered(); + let centered = Layout::vertical([Constraint::Length(1)]) + .flex(ratatui::layout::Flex::Center) + .split(area); + frame.render_widget(paragraph, centered[0]); + return; + } + + let vertical = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), + ]); + + let [help_area, input_area, message_area, status_area] = vertical.areas(frame.area()); + + frame.render_widget( + HelpBar::new(self.view.input_mode, help_area.width), + help_area, + ); + frame.render_widget( + InputField::new(&self.input.text, self.view.input_mode), + input_area, + ); + + if self.view.input_mode == InputMode::Editing { + frame.set_cursor_position(Position::new( + input_area.x + self.input.cursor as u16 + 1, + input_area.y + 1, + )); + } + + match self.view.current_view { + MessageView::Graphs => { + let float_data: Vec = self.messages.graph_float.iter().copied().collect(); + let int_data: Vec = self.messages.graph_int.iter().copied().collect(); + frame.render_widget(GraphView::new(&float_data, &int_data), message_area); + } + _ => { + let (messages, title, border_color) = match self.view.current_view { + MessageView::Received => ( + self.messages.received.as_slice(), + "Received", + DEFAULT_THEME.received_border, + ), + MessageView::Sent => ( + self.messages.sent.as_slice(), + "Sent", + DEFAULT_THEME.sent_border, + ), + MessageView::Graphs => unreachable!(), + }; + frame.render_stateful_widget( + MessageList::new(messages, title, self.view.show_timestamps, border_color), + message_area, + &mut self.view.list_state.clone(), + ); + } + } + + // Status bar — show transient status message if active, else normal + if let Some((ref msg, _)) = self.view.status_message { + let spans = Line::from(vec![Span::styled( + format!(" {msg}"), + Style::default().fg(Color::Green).bold(), + )]); + frame.render_widget( + Paragraph::new(spans).style(Style::default().bg(DEFAULT_THEME.status_bar_bg)), + status_area, + ); + } else { + self.render_normal_status(frame, status_area); + } + + if self.view.input_mode == InputMode::Help { + HelpPopup::render_overlay(frame); + } + + if self.view.input_mode == InputMode::CommandPalette { + CommandPalette::render_overlay( + frame, + &self.sync.device_commands, + &self.cmd_palette.filter, + &mut self.cmd_palette.list_state.clone(), + ); + } + } + + fn render_normal_status(&self, frame: &mut Frame, status_area: ratatui::layout::Rect) { + frame.render_widget( + StatusBar::new( + &self.port, + self.baud, + self.view.input_mode, + self.view.receiving, + self.sync.display(), + ), + status_area, + ); + } +} diff --git a/src/app/serial_handler.rs b/src/app/serial_handler.rs new file mode 100644 index 0000000..80a7746 --- /dev/null +++ b/src/app/serial_handler.rs @@ -0,0 +1,153 @@ +use std::time::Instant; + +use super::App; +use super::state::{MessageStore, SyncManager}; +use crate::constants::{ + SYNC_ACK, SYNC_BEGIN, SYNC_COMMAND, SYNC_END, SYNC_INITIAL_DELAY, SYNC_MAX_RETRIES, + SYNC_TIMEOUT, +}; +use crate::serial::{SerialEvent, SerialHandle, SerialMessage}; +use crate::sync::{SyncState, parse_command_line}; +use crate::widgets::SyncDisplay; + +/// Lines that are part of the sync protocol and should never appear in user-visible messages. +const SYNC_PROTOCOL_MARKERS: &[&str] = &[SYNC_BEGIN, SYNC_END, SYNC_ACK]; + +impl SyncManager { + /// Drive the sync state machine forward. Called each loop iteration. + pub(super) fn tick(&mut self, serial: &SerialHandle) { + match &self.sync_state { + SyncState::Idle => { + if self.started_at.elapsed() >= SYNC_INITIAL_DELAY { + serial.writeln(SYNC_COMMAND); + self.sync_state = SyncState::AwaitingBegin { + sent_at: Instant::now(), + attempts: 1, + }; + } + } + SyncState::AwaitingBegin { sent_at, attempts } => { + if sent_at.elapsed() >= SYNC_TIMEOUT { + if *attempts >= SYNC_MAX_RETRIES { + self.sync_state = SyncState::Failed; + } else { + let next_attempt = *attempts + 1; + serial.writeln(SYNC_COMMAND); + self.sync_state = SyncState::AwaitingBegin { + sent_at: Instant::now(), + attempts: next_attempt, + }; + } + } + } + SyncState::Receiving { .. } | SyncState::Synced | SyncState::Failed => {} + } + } + + /// Process a line through the sync protocol. Returns `true` if consumed. + pub(super) fn handle_line(&mut self, line: &str, serial: &SerialHandle) -> bool { + // Always swallow lines containing sync protocol markers (e.g. the + // device echoing back "unknown: #acknowledge-sync") regardless of + // current state. + if !matches!(self.sync_state, SyncState::AwaitingBegin { .. } | SyncState::Receiving { .. }) + && SYNC_PROTOCOL_MARKERS.iter().any(|m| line.contains(m)) + { + return true; + } + + match &self.sync_state { + SyncState::AwaitingBegin { .. } => { + if line == SYNC_BEGIN { + self.sync_state = SyncState::Receiving { + commands: Vec::new(), + }; + return true; + } + } + SyncState::Receiving { .. } => { + if line == SYNC_END { + if let SyncState::Receiving { commands } = + std::mem::replace(&mut self.sync_state, SyncState::Synced) + { + self.device_commands = commands; + } + serial.writeln(SYNC_ACK); + return true; + } + if let SyncState::Receiving { commands } = &mut self.sync_state { + commands.push(parse_command_line(line)); + } + return true; + } + _ => {} + } + false + } + + /// Get the current sync display state for the status bar. + pub(super) fn display(&self) -> SyncDisplay { + match &self.sync_state { + SyncState::Idle | SyncState::AwaitingBegin { .. } => SyncDisplay::Pending, + SyncState::Receiving { .. } => SyncDisplay::Receiving, + SyncState::Synced => SyncDisplay::Synced(self.device_commands.len()), + SyncState::Failed => SyncDisplay::Failed, + } + } + + /// Reset sync state for a manual re-sync. + pub(super) fn start_resync(&mut self) { + self.device_commands.clear(); + self.started_at = Instant::now(); + self.sync_state = SyncState::Idle; + } +} + +impl MessageStore { + /// Try to parse a graph data line. Returns `true` if consumed. + pub(super) fn handle_graph_line(&mut self, line: &str) -> bool { + if let Some(val_str) = line.strip_prefix("#graphf ") + && let Ok(val) = val_str.trim().parse::() + { + self.push_graph_float(val); + return true; + } else if let Some(val_str) = line.strip_prefix("#graphi ") + && let Ok(val) = val_str.trim().parse::() + { + self.push_graph_int(val); + return true; + } + false + } +} + +impl App { + /// Poll for serial events. Returns `Some(reason)` if the connection was lost. + pub(super) fn receive_serial(&mut self) -> Option { + if !self.view.receiving { + return None; + } + + while let Some(event) = self.serial_connection.try_recv() { + match event { + SerialEvent::LineReceived(msg) => { + if self.sync.handle_line(&msg.message, &self.serial_connection) { + // consumed by sync protocol + } else if self.messages.handle_graph_line(&msg.message) { + // consumed by graph parser + } else { + let idx = self.messages.push_received(msg); + self.view.auto_scroll(idx); + } + } + SerialEvent::Error(e) => { + let idx = self.messages.push_received(SerialMessage::new(e)); + self.view.auto_scroll(idx); + } + SerialEvent::Disconnected(reason) => { + return Some(reason); + } + } + } + None + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..d8ec2ea --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,234 @@ +use std::collections::VecDeque; +use std::time::Instant; + +use ratatui::widgets::ListState; + +use crate::constants::GRAPH_BUFFER_SIZE; +use crate::serial::{SerialHandle, SerialMessage}; +use crate::sync::{DeviceCommand, SyncState}; + +/// How the app exited. +pub enum AppExit { + /// User chose to quit. + Quit, + /// Serial connection was lost. + Disconnected(String), + /// User manually disconnected (return to setup screen). + ManualDisconnect, +} + +/// Mode enum +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InputMode { + #[default] + Normal, + Editing, + Help, + CommandPalette, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) enum MessageView { + #[default] + Received, + Sent, + Graphs, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Direction { + Left, + Right, +} + +// --------------------------------------------------------------------------- +// Sub-structs +// --------------------------------------------------------------------------- + +/// Text editing buffer and cursor. +#[derive(Debug)] +pub(super) struct InputBuffer { + pub(super) text: String, + pub(super) cursor: usize, +} + +impl InputBuffer { + fn new() -> Self { + Self { + text: String::new(), + cursor: 0, + } + } + + /// Drain the buffer and return its contents, resetting the cursor. + pub(super) fn take_input(&mut self) -> String { + let text = std::mem::take(&mut self.text); + self.cursor = 0; + text + } +} + +/// Received/sent messages and graph data. +#[derive(Debug)] +pub(super) struct MessageStore { + pub(super) received: Vec, + pub(super) sent: Vec, + pub(super) graph_float: VecDeque, + pub(super) graph_int: VecDeque, +} + +impl MessageStore { + fn new() -> Self { + Self { + received: Vec::new(), + sent: Vec::new(), + graph_float: VecDeque::new(), + graph_int: VecDeque::new(), + } + } + + /// Push a received message. Returns the index of the new last element. + pub(super) fn push_received(&mut self, msg: SerialMessage) -> usize { + self.received.push(msg); + self.received.len().saturating_sub(1) + } + + pub(super) fn push_sent(&mut self, msg: SerialMessage) { + self.sent.push(msg); + } + + pub(super) fn push_graph_float(&mut self, val: f64) { + self.graph_float.push_back(val); + if self.graph_float.len() > GRAPH_BUFFER_SIZE { + self.graph_float.pop_front(); + } + } + + pub(super) fn push_graph_int(&mut self, val: i64) { + self.graph_int.push_back(val); + if self.graph_int.len() > GRAPH_BUFFER_SIZE { + self.graph_int.pop_front(); + } + } +} + +/// Display mode, toggle flags, list selection, and transient status. +#[derive(Debug)] +pub(super) struct ViewState { + pub(super) input_mode: InputMode, + pub(super) current_view: MessageView, + pub(super) show_timestamps: bool, + pub(super) receiving: bool, + pub(super) list_state: ListState, + pub(super) status_message: Option<(String, Instant)>, +} + +impl ViewState { + fn new() -> Self { + Self { + input_mode: InputMode::Normal, + current_view: MessageView::default(), + show_timestamps: false, + receiving: true, + list_state: ListState::default(), + status_message: None, + } + } + + pub(super) fn set_status(&mut self, msg: String) { + self.status_message = Some((msg, Instant::now())); + } + + /// Expire the status message if its duration has elapsed. + pub(super) fn expire_status(&mut self, max_age: std::time::Duration) { + if let Some((_, created_at)) = &self.status_message + && created_at.elapsed() >= max_age + { + self.status_message = None; + } + } + + /// Auto-scroll the list to the given index. + pub(super) fn auto_scroll(&mut self, idx: usize) { + self.list_state.select(Some(idx)); + } +} + +/// Sync protocol state and discovered device commands. +#[derive(Debug)] +pub(super) struct SyncManager { + pub(super) device_commands: Vec, + pub(super) sync_state: SyncState, + pub(super) started_at: Instant, +} + +impl SyncManager { + fn new() -> Self { + Self { + device_commands: Vec::new(), + sync_state: SyncState::Idle, + started_at: Instant::now(), + } + } +} + +/// Command palette filter and selection state. +#[derive(Debug)] +pub(super) struct CommandPaletteState { + pub(super) filter: String, + pub(super) list_state: ListState, +} + +impl CommandPaletteState { + fn new() -> Self { + Self { + filter: String::new(), + list_state: ListState::default(), + } + } +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +/// State struct +#[derive(Debug)] +pub struct App { + pub(super) input: InputBuffer, + pub(super) messages: MessageStore, + pub(super) view: ViewState, + pub(super) sync: SyncManager, + pub(super) cmd_palette: CommandPaletteState, + pub(super) serial_connection: SerialHandle, + pub(super) port: String, + pub(super) baud: u32, +} + +impl App { + pub fn new(port: &str, baud: u32) -> Self { + Self { + input: InputBuffer::new(), + messages: MessageStore::new(), + view: ViewState::new(), + sync: SyncManager::new(), + cmd_palette: CommandPaletteState::new(), + serial_connection: SerialHandle::new(port, baud), + port: port.to_string(), + baud, + } + } + + pub fn new_mock(baud: u32) -> Self { + Self { + input: InputBuffer::new(), + messages: MessageStore::new(), + view: ViewState::new(), + sync: SyncManager::new(), + cmd_palette: CommandPaletteState::new(), + serial_connection: SerialHandle::mock(), + port: "mock".to_string(), + baud, + } + } +} diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..60d037b --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,68 @@ +#![allow( + unused, + reason = "These constants may or may not be useful in every case." +)] + +use std::time::Duration; + +use ratatui::style::Color; + +// Some useful constants, may be specific to my use case + +pub const SEEED_VID: u16 = 0x303a; // Vendor ID (vid) for the ESP32-C3 board I am using +pub const SEEED_ESP32_C3: u16 = 0x1001; // Product ID (pid) for the ESP32-C3 board I have +pub const ESP32_MANUFACTURER: &str = "Espressif"; // Manufacturer ID for ESP32 + +pub const SERIAL_PORT: &str = "/dev/ttyACM1"; // The port I'm using on my desktop + +pub const MIN_COLS: u16 = 80; +pub const MIN_ROWS: u16 = 24; + +pub const COMMON_BAUD_RATES: &[u32] = &[9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]; + +// --- Sync Protocol --- + +pub const SYNC_COMMAND: &str = "/sync"; +pub const SYNC_BEGIN: &str = "#sync-begin"; +pub const SYNC_END: &str = "#sync-end"; +pub const SYNC_ACK: &str = "#acknowledge-sync"; +pub const SYNC_TIMEOUT: Duration = Duration::from_millis(2000); +pub const SYNC_MAX_RETRIES: u32 = 3; +pub const SYNC_INITIAL_DELAY: Duration = Duration::from_millis(500); +pub const SYNC_STATUS_COLOR: Color = Color::Cyan; + +// --- Graph View --- + +pub const GRAPH_BUFFER_SIZE: usize = 30; + +// --- Theming --- + +pub struct Theme { + pub input_active: Color, + pub received_border: Color, + pub sent_border: Color, + pub status_bar_bg: Color, + pub status_live: Color, + pub status_frozen: Color, + pub help_border: Color, + pub keybind: Color, + pub enum_text: Color, + pub help_label: Color, + pub graph_float_border: Color, + pub graph_int_border: Color, +} + +pub const DEFAULT_THEME: Theme = Theme { + input_active: Color::Yellow, + received_border: Color::Green, + sent_border: Color::Red, + status_bar_bg: Color::DarkGray, + status_live: Color::Green, + status_frozen: Color::Red, + help_border: Color::White, + keybind: Color::White, + enum_text: Color::Yellow, + help_label: Color::Gray, + graph_float_border: Color::Cyan, + graph_int_border: Color::Magenta, +}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..044c438 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,64 @@ +use argh::FromArgs; + +use crate::app::{App, AppExit}; +use crate::setup::SetupScreen; + +use color_eyre::Result; + +mod app; +mod constants; +mod serial; +mod setup; +mod sync; +mod widgets; + +/// RSerial CLI flags +#[derive(Debug, FromArgs)] +struct Cli { + /// baud rate of the serial connection. Defaults to 115200 + #[argh(option, default = "115200")] + baud_rate: u32, + + /// use a mock serial connection (no physical device needed) + #[argh(switch, short = 'm')] + mock: bool, +} + +fn main() -> Result<()> { + color_eyre::install()?; + let mut cli: Cli = argh::from_env(); + + let mut terminal = ratatui::init(); + let mut alert: Option = None; + + loop { + let app = if cli.mock { + cli.mock = false; // only auto-enter mock on first iteration + App::new_mock(cli.baud_rate) + } else { + let (port, baud) = match (&alert, serial::find_esp32_port()) { + // Auto-connect only when there's no alert (i.e. not returning from a disconnect) + (None, Some(port)) => (port, cli.baud_rate), + _ => { + let setup = SetupScreen::new(alert.take()); + match setup.run(&mut terminal)? { + Some(result) => (result.port, result.baud), + None => break, + } + } + }; + App::new(port.as_str(), baud) + }; + + match app.run(&mut terminal)? { + AppExit::Quit => break, + AppExit::Disconnected(reason) => { + alert = Some(reason); + } + AppExit::ManualDisconnect => {} + } + } + + ratatui::restore(); + Ok(()) +} diff --git a/src/serial.rs b/src/serial.rs new file mode 100644 index 0000000..fe1d3fd --- /dev/null +++ b/src/serial.rs @@ -0,0 +1,284 @@ +use std::{sync::mpsc, thread, time::Duration}; + +use chrono::{DateTime, Local}; +use serialport::{SerialPortType, available_ports}; + +use crate::constants::{ESP32_MANUFACTURER, SEEED_ESP32_C3, SEEED_VID}; + +#[derive(Debug)] +pub enum SerialCommand { + Write(String), + Shutdown, +} + +#[derive(Debug)] +pub enum SerialEvent { + LineReceived(SerialMessage), + Error(String), + /// Fatal connection loss — the serial thread has exited. + Disconnected(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SerialMessage { + pub message: String, + pub timestamp: DateTime, +} +impl SerialMessage { + pub fn new(message: impl Into) -> Self { + let message = message.into(); + let timestamp = Local::now(); + SerialMessage { message, timestamp } + } +} + +#[derive(Debug)] +pub struct SerialHandle { + cmd_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + thread_handle: Option>, +} + +impl SerialHandle { + pub fn new(port: &str, baud: u32) -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); + + let thread_handle = Some(spawn_serial_thread(port, baud, cmd_rx, event_tx)); + SerialHandle { + cmd_tx, + event_rx, + thread_handle, + } + } + + #[allow(dead_code)] + pub fn write(&self, data: impl Into) { + let _ = self.cmd_tx.send(SerialCommand::Write(data.into())); + } + + pub fn writeln(&self, data: impl Into) { + let mut string = data.into(); + if !string.ends_with('\n') { + string.push('\n'); + } + let _ = self.cmd_tx.send(SerialCommand::Write(string)); + } + + pub fn try_recv(&self) -> Option { + self.event_rx.try_recv().ok() + } + + pub fn shutdown(&self) { + let _ = self.cmd_tx.send(SerialCommand::Shutdown); + } + + pub fn mock() -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); + + let thread_handle = Some(thread::spawn(move || { + let mut counter: u64 = 0; + let mut last_tick = std::time::Instant::now(); + + loop { + // Handle commands (non-blocking) + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + SerialCommand::Write(data) => { + let line = data.trim_end().to_string(); + event_tx + .send(SerialEvent::LineReceived(SerialMessage::new(format!( + "echo: {line}" + )))) + .ok(); + } + SerialCommand::Shutdown => return, + } + } + + // Send periodic mock data every ~1 second + if last_tick.elapsed() >= Duration::from_secs(1) { + last_tick = std::time::Instant::now(); + counter += 1; + + event_tx + .send(SerialEvent::LineReceived(SerialMessage::new(format!( + "[mock] tick {counter}" + )))) + .ok(); + + // Send sample graph data every few ticks + if counter.is_multiple_of(3) { + let val = (counter as f64 * 0.5).sin() * 100.0; + event_tx + .send(SerialEvent::LineReceived(SerialMessage::new(format!( + "#graphf {val:.2}" + )))) + .ok(); + } + if counter.is_multiple_of(5) { + let val = (counter % 200) as i64 - 100; + event_tx + .send(SerialEvent::LineReceived(SerialMessage::new(format!( + "#graphi {val}" + )))) + .ok(); + } + } + + thread::sleep(Duration::from_millis(10)); + } + })); + + SerialHandle { + cmd_tx, + event_rx, + thread_handle, + } + } +} + +impl Drop for SerialHandle { + fn drop(&mut self) { + self.shutdown(); + if let Some(handle) = self.thread_handle.take() { + let _ = handle.join(); + } + } +} + +pub fn find_esp32_port() -> Option { + let ports = available_ports().ok().unwrap_or_default(); + + for port in ports { + if let SerialPortType::UsbPort(info) = port.port_type + && info.vid == SEEED_VID + && info.pid == SEEED_ESP32_C3 + { + return Some(port.port_name); + } + } + None +} + +/// A detected ESP32-compatible serial port with display info. +#[derive(Debug, Clone)] +pub struct DetectedPort { + pub port_name: String, + pub description: String, +} + +/// Scan for all USB serial ports matching any ESP32 constant (VID, PID, or manufacturer). +pub fn find_esp32_ports() -> Vec { + let ports = available_ports().ok().unwrap_or_default(); + + ports + .into_iter() + .filter_map(|port| { + if let SerialPortType::UsbPort(info) = &port.port_type { + let vid_match = info.vid == SEEED_VID; + let pid_match = info.pid == SEEED_ESP32_C3; + let mfr_match = info + .manufacturer + .as_deref() + .is_some_and(|m| m.contains(ESP32_MANUFACTURER)); + + if vid_match || pid_match || mfr_match { + let desc = format!( + "{} [VID:{:04x} PID:{:04x}{}]", + port.port_name, + info.vid, + info.pid, + info.manufacturer + .as_deref() + .map(|m| format!(" {m}")) + .unwrap_or_default(), + ); + return Some(DetectedPort { + port_name: port.port_name, + description: desc, + }); + } + } + None + }) + .collect() +} + +fn spawn_serial_thread( + port_name: &str, + baud_rate: u32, + cmd_rx: mpsc::Receiver, + event_tx: mpsc::Sender, +) -> thread::JoinHandle<()> { + let port_name = port_name.to_string(); + + thread::spawn(move || { + let mut port = match serialport::new(port_name, baud_rate) + .timeout(Duration::from_millis(10)) + .open() + { + Ok(p) => p, + Err(e) => { + event_tx + .send(SerialEvent::Error(format!("Open error: {e}"))) + .ok(); + return; + } + }; + + let mut buf = [0u8; 256]; + let mut line_buffer = String::new(); + + loop { + // Handle outgoing commands (non-blocking) + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + SerialCommand::Write(data) => { + if let Err(e) = port.write_all(data.as_bytes()) { + event_tx + .send(SerialEvent::Error(format!("Write error: {e}"))) + .ok(); + } + } + SerialCommand::Shutdown => { + return; + } + } + } + + // Read incoming data (blocking w/ timeout) + match port.read(&mut buf) { + Ok(n) if n > 0 => { + let chunk = String::from_utf8_lossy(&buf[..n]); + for c in chunk.chars() { + if c == '\n' { + if line_buffer.ends_with('\r') { + line_buffer.pop(); + } + event_tx + .send(SerialEvent::LineReceived(SerialMessage::new( + line_buffer.clone(), + ))) + .ok(); + line_buffer.clear(); + } else { + line_buffer.push(c); + } + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => { + // expected; ignore + } + Err(e) => { + event_tx + .send(SerialEvent::Disconnected(format!("Read error: {e}"))) + .ok(); + return; + } + _ => {} //Ok(n==0) + } + } + }) +} diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..63be28c --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,309 @@ +use std::time::{Duration, Instant}; + +use color_eyre::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::{ + DefaultTerminal, Frame, + layout::{Constraint, Flex, Layout, Rect}, + style::{Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap}, +}; + +use crate::constants::{COMMON_BAUD_RATES, DEFAULT_THEME, MIN_COLS, MIN_ROWS}; +use crate::serial::{DetectedPort, find_esp32_ports}; + +/// Result of the setup screen: selected port and baud rate. +pub struct SetupResult { + pub port: String, + pub baud: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Ports, + BaudRates, +} + +pub struct SetupScreen { + ports: Vec, + port_state: ListState, + baud_state: ListState, + focus: Focus, + last_scan: Instant, + /// Optional alert shown as a popup (e.g. "Connection lost"). + alert: Option, +} + +impl SetupScreen { + pub fn new(alert: Option) -> Self { + let ports = find_esp32_ports(); + let mut port_state = ListState::default(); + if !ports.is_empty() { + port_state.select(Some(0)); + } + let mut baud_state = ListState::default(); + let default_baud_idx = COMMON_BAUD_RATES + .iter() + .position(|&b| b == 115200) + .unwrap_or(0); + baud_state.select(Some(default_baud_idx)); + + Self { + ports, + port_state, + baud_state, + focus: Focus::Ports, + last_scan: Instant::now(), + alert, + } + } + + /// Run the setup screen. Returns `None` if the user quits. + pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result> { + loop { + terminal.draw(|frame| self.draw(frame))?; + + // Auto-scan every second + if self.last_scan.elapsed() >= Duration::from_secs(1) { + self.rescan(); + } + + if event::poll(Duration::from_millis(50))? + && let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + // Any keypress dismisses the alert + if self.alert.is_some() { + self.alert = None; + continue; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(None), + KeyCode::Tab | KeyCode::BackTab => { + self.focus = match self.focus { + Focus::Ports => Focus::BaudRates, + Focus::BaudRates => Focus::Ports, + }; + } + KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), + KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), + KeyCode::Enter => { + if let Some(result) = self.try_confirm() { + return Ok(Some(result)); + } + } + _ => {} + } + } + } + } + + fn rescan(&mut self) { + self.last_scan = Instant::now(); + let new_ports = find_esp32_ports(); + + // Preserve selection if the same port is still present + let selected_name = self + .port_state + .selected() + .and_then(|i| self.ports.get(i)) + .map(|p| p.port_name.clone()); + + self.ports = new_ports; + + if self.ports.is_empty() { + self.port_state.select(None); + } else if let Some(name) = selected_name { + let idx = self + .ports + .iter() + .position(|p| p.port_name == name) + .unwrap_or(0); + self.port_state.select(Some(idx)); + } else { + self.port_state.select(Some(0)); + } + } + + fn move_selection(&mut self, delta: i32) { + let (state, len) = match self.focus { + Focus::Ports => (&mut self.port_state, self.ports.len()), + Focus::BaudRates => (&mut self.baud_state, COMMON_BAUD_RATES.len()), + }; + if len == 0 { + return; + } + let current = state.selected().unwrap_or(0) as i32; + let next = (current + delta).rem_euclid(len as i32) as usize; + state.select(Some(next)); + } + + fn try_confirm(&self) -> Option { + let port_idx = self.port_state.selected()?; + let baud_idx = self.baud_state.selected()?; + let port = self.ports.get(port_idx)?; + let &baud = COMMON_BAUD_RATES.get(baud_idx)?; + Some(SetupResult { + port: port.port_name.clone(), + baud, + }) + } + + fn draw(&mut self, frame: &mut Frame) { + let full = frame.area(); + if full.width < MIN_COLS || full.height < MIN_ROWS { + let msg = format!("Terminal too small (need {}x{})", MIN_COLS, MIN_ROWS); + let paragraph = Paragraph::new(msg).centered(); + let centered = Layout::vertical([Constraint::Length(1)]) + .flex(Flex::Center) + .split(full); + frame.render_widget(paragraph, centered[0]); + return; + } + + frame.render_widget(Clear, full); + + let area = clamped_centered_rect(60, 22, full); + + let outer = Block::bordered() + .title(" Serial Configuration ".bold()) + .border_style(Style::default().fg(DEFAULT_THEME.help_border)); + let inner = outer.inner(area); + frame.render_widget(outer, area); + + let layout = Layout::vertical([ + Constraint::Length(2), // header text + Constraint::Min(5), // port list + Constraint::Min(5), // baud list + Constraint::Length(2), // footer / keybinds + ]) + .split(inner); + + // Header + let header = Paragraph::new(Line::from(vec![Span::styled( + " No ESP32 device auto-detected. Select a port and baud rate:", + Style::default().fg(DEFAULT_THEME.help_label), + )])); + frame.render_widget(header, layout[0]); + + // Port list + let port_items: Vec = self + .ports + .iter() + .map(|p| ListItem::new(format!(" {}", p.description))) + .collect(); + + let port_block_style = if self.focus == Focus::Ports { + Style::default().fg(DEFAULT_THEME.input_active) + } else { + Style::default().fg(DEFAULT_THEME.help_label) + }; + + let port_list = if port_items.is_empty() { + List::new(vec![ListItem::new( + " (no ESP32 devices detected — scanning...)", + )]) + .block( + Block::bordered() + .title(" Ports ") + .border_style(port_block_style), + ) + } else { + List::new(port_items) + .block( + Block::bordered() + .title(" Ports ") + .border_style(port_block_style), + ) + .highlight_style(Style::default().fg(DEFAULT_THEME.enum_text).bold()) + .highlight_symbol("▸ ") + .highlight_spacing(HighlightSpacing::Always) + }; + frame.render_stateful_widget(port_list, layout[1], &mut self.port_state); + + // Baud rate list + let baud_items: Vec = COMMON_BAUD_RATES + .iter() + .map(|b| ListItem::new(format!(" {b}"))) + .collect(); + + let baud_block_style = if self.focus == Focus::BaudRates { + Style::default().fg(DEFAULT_THEME.input_active) + } else { + Style::default().fg(DEFAULT_THEME.help_label) + }; + + let baud_list = List::new(baud_items) + .block( + Block::bordered() + .title(" Baud Rate ") + .border_style(baud_block_style), + ) + .highlight_style(Style::default().fg(DEFAULT_THEME.enum_text).bold()) + .highlight_symbol("▸ ") + .highlight_spacing(HighlightSpacing::Always); + frame.render_stateful_widget(baud_list, layout[2], &mut self.baud_state); + + // Footer keybinds + let key = Style::default().fg(DEFAULT_THEME.keybind).bold(); + let label = Style::default().fg(DEFAULT_THEME.help_label); + let footer = Paragraph::new(Line::from(vec![ + Span::styled(" ↑↓", key), + Span::styled(" navigate ", label), + Span::styled("Tab", key), + Span::styled(" switch list ", label), + Span::styled("Enter", key), + Span::styled(" connect ", label), + Span::styled("q", key), + Span::styled(" quit", label), + ])); + frame.render_widget(footer, layout[3]); + + // Alert popup overlay + if let Some(ref msg) = self.alert { + self.draw_alert(frame, msg); + } + } + + fn draw_alert(&self, frame: &mut Frame, message: &str) { + let alert_area = clamped_centered_rect(50, 8, frame.area()); + frame.render_widget(Clear, alert_area); + + let block = Block::bordered() + .title(" Connection Lost ".bold()) + .border_style(Style::default().fg(DEFAULT_THEME.status_frozen)); + + let text = vec![ + Line::from(""), + Line::from(Span::styled( + message, + Style::default().fg(DEFAULT_THEME.status_frozen), + )), + Line::from(""), + Line::from(Span::styled( + "Press any key to dismiss", + Style::default().fg(DEFAULT_THEME.help_label), + )), + ]; + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .centered(); + + frame.render_widget(paragraph, alert_area); + } +} + +fn clamped_centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + let vertical = Layout::vertical([Constraint::Length(h)]) + .flex(Flex::Center) + .split(area); + Layout::horizontal([Constraint::Length(w)]) + .flex(Flex::Center) + .split(vertical[0])[0] +} diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..d64905e --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,34 @@ +use std::time::Instant; + +#[derive(Debug, Clone)] +pub struct DeviceCommand { + pub name: String, + pub usage: Option, +} + +#[derive(Debug)] +pub enum SyncState { + /// Waiting for initial delay before first /sync + Idle, + /// /sync sent, waiting for #sync-begin + AwaitingBegin { sent_at: Instant, attempts: u32 }, + /// Accumulating command lines between #sync-begin and #sync-end + Receiving { commands: Vec }, + /// Sync completed successfully + Synced, + /// Max retries exhausted + Failed, +} + +pub fn parse_command_line(line: &str) -> DeviceCommand { + match line.split_once(' ') { + Some((name, usage)) => DeviceCommand { + name: name.to_string(), + usage: Some(usage.to_string()), + }, + None => DeviceCommand { + name: line.to_string(), + usage: None, + }, + } +} diff --git a/src/widgets/command_palette.rs b/src/widgets/command_palette.rs new file mode 100644 index 0000000..b6f325c --- /dev/null +++ b/src/widgets/command_palette.rs @@ -0,0 +1,145 @@ +use ratatui::{ + Frame, + layout::{Constraint, Flex, Layout, Rect}, + style::{Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Clear, List, ListItem, ListState, Paragraph}, +}; + +use crate::constants::DEFAULT_THEME; +use crate::sync::DeviceCommand; + +pub struct CommandPalette; + +impl CommandPalette { + pub fn render_overlay( + frame: &mut Frame, + commands: &[DeviceCommand], + filter: &str, + list_state: &mut ListState, + ) { + let filtered: Vec<&DeviceCommand> = commands + .iter() + .filter(|cmd| { + if filter.is_empty() { + true + } else { + cmd.name.to_lowercase().contains(&filter.to_lowercase()) + } + }) + .collect(); + + let popup_block = Block::bordered() + .title("Commands".bold()) + .border_style(Style::default().fg(DEFAULT_THEME.help_border)); + + // Filter input line + let filter_line = if filter.is_empty() { + Line::from(Span::styled( + " type to filter...", + Style::default().fg(DEFAULT_THEME.help_label).italic(), + )) + } else { + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(filter, Style::default().fg(DEFAULT_THEME.keybind)), + ]) + }; + + // Build list items + let items: Vec = filtered + .iter() + .map(|cmd| { + let mut spans = vec![Span::styled( + &cmd.name, + Style::default().fg(DEFAULT_THEME.enum_text).bold(), + )]; + if let Some(usage) = &cmd.usage { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + usage, + Style::default().fg(DEFAULT_THEME.help_label), + )); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let item_count = items.len(); + let list_height = (item_count as u16).max(1) + 4; // +2 border, +1 filter, +1 separator + let popup_height = list_height.clamp(6, 20); + + let area = clamped_centered_rect(50, popup_height, frame.area()); + frame.render_widget(Clear, area); + + // Split popup area: border top (1) + filter (1) + separator (1) + list + border bottom (1) + let inner = popup_block.inner(area); + frame.render_widget(popup_block, area); + + if inner.height < 2 { + return; + } + + let inner_layout = Layout::vertical([ + Constraint::Length(1), // filter + Constraint::Length(1), // separator + Constraint::Min(1), // list + ]) + .split(inner); + + frame.render_widget(Paragraph::new(filter_line), inner_layout[0]); + frame.render_widget( + Paragraph::new(" ─────────────────────────────────────────────") + .style(Style::default().fg(DEFAULT_THEME.help_label)), + inner_layout[1], + ); + + // Clamp selection to filtered range + if item_count == 0 { + list_state.select(None); + } else if let Some(sel) = list_state.selected() { + if sel >= item_count { + list_state.select(Some(item_count - 1)); + } + } else { + list_state.select(Some(0)); + } + + let list = List::new(items) + .highlight_symbol("▸ ") + .highlight_style(Style::default().bold().reversed()); + + frame.render_stateful_widget(list, inner_layout[2], list_state); + } + + /// Returns the name of the currently selected command (after filtering). + pub fn selected_command<'a>( + commands: &'a [DeviceCommand], + filter: &str, + list_state: &ListState, + ) -> Option<&'a DeviceCommand> { + let filtered: Vec<&DeviceCommand> = commands + .iter() + .filter(|cmd| { + if filter.is_empty() { + true + } else { + cmd.name.to_lowercase().contains(&filter.to_lowercase()) + } + }) + .collect(); + + list_state.selected().and_then(|i| filtered.get(i).copied()) + } +} + +fn clamped_centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + let vertical = Layout::vertical([Constraint::Length(h)]) + .flex(Flex::Center) + .split(area); + Layout::horizontal([Constraint::Length(w)]) + .flex(Flex::Center) + .split(vertical[0])[0] +} diff --git a/src/widgets/graph_view.rs b/src/widgets/graph_view.rs new file mode 100644 index 0000000..bad3839 --- /dev/null +++ b/src/widgets/graph_view.rs @@ -0,0 +1,115 @@ +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Layout, Rect}, + style::Style, + widgets::{Block, Borders, Sparkline, Widget}, +}; + +use crate::constants::DEFAULT_THEME; + +pub struct GraphView<'a> { + float_data: &'a [f64], + int_data: &'a [i64], +} + +impl<'a> GraphView<'a> { + pub fn new(float_data: &'a [f64], int_data: &'a [i64]) -> Self { + Self { + float_data, + int_data, + } + } + + fn scale_floats(data: &[f64]) -> Vec { + if data.is_empty() { + return Vec::new(); + } + + let min = data.iter().copied().fold(f64::INFINITY, f64::min); + let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let range = max - min; + + if range == 0.0 { + return vec![500; data.len()]; + } + + data.iter() + .map(|&v| ((v - min) / range * 1000.0) as u64) + .collect() + } + + fn scale_ints(data: &[i64]) -> Vec { + if data.is_empty() { + return Vec::new(); + } + + let min = data.iter().copied().min().unwrap(); + let max = data.iter().copied().max().unwrap(); + let range = max - min; + + if range == 0 { + return vec![500; data.len()]; + } + + data.iter() + .map(|&v| ((v - min) as f64 / range as f64 * 1000.0) as u64) + .collect() + } +} + +impl Widget for GraphView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let [top, bottom] = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area); + + // Float graph + let float_title = if self.float_data.is_empty() { + " Float (no data) ".to_string() + } else { + let last = self.float_data.last().unwrap(); + let min = self + .float_data + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + let max = self + .float_data + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + format!(" Float [last: {last:.2} | min: {min:.2} max: {max:.2}] ") + }; + let float_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(DEFAULT_THEME.graph_float_border)) + .title(float_title); + let scaled_floats = Self::scale_floats(self.float_data); + Sparkline::default() + .block(float_block) + .data(&scaled_floats) + .max(1000) + .style(Style::default().fg(DEFAULT_THEME.graph_float_border)) + .render(top, buf); + + // Integer graph + let int_title = if self.int_data.is_empty() { + " Integer (no data) ".to_string() + } else { + let last = self.int_data.last().unwrap(); + let min = self.int_data.iter().copied().min().unwrap(); + let max = self.int_data.iter().copied().max().unwrap(); + format!(" Integer [last: {last} | min: {min} max: {max}] ") + }; + let int_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(DEFAULT_THEME.graph_int_border)) + .title(int_title); + let scaled_ints = Self::scale_ints(self.int_data); + Sparkline::default() + .block(int_block) + .data(&scaled_ints) + .max(1000) + .style(Style::default().fg(DEFAULT_THEME.graph_int_border)) + .render(bottom, buf); + } +} diff --git a/src/widgets/help_bar.rs b/src/widgets/help_bar.rs new file mode 100644 index 0000000..207a61d --- /dev/null +++ b/src/widgets/help_bar.rs @@ -0,0 +1,99 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + text::{Line, Span, Text}, + widgets::{Paragraph, Widget}, +}; + +use crate::app::InputMode; +use crate::constants::DEFAULT_THEME; + +pub struct HelpBar { + mode: InputMode, + width: u16, +} + +impl HelpBar { + pub fn new(mode: InputMode, width: u16) -> Self { + Self { mode, width } + } +} + +impl Widget for HelpBar { + fn render(self, area: Rect, buf: &mut Buffer) { + let key = Style::default().fg(DEFAULT_THEME.keybind).bold(); + let label = Style::default().fg(DEFAULT_THEME.help_label); + + let spans: Vec = match self.mode { + InputMode::Normal if self.width < 80 => vec![ + Span::styled("q", key), + Span::styled(" quit ", label), + Span::styled("e", key), + Span::styled(" edit ", label), + Span::styled("h", key), + Span::styled(" help ", label), + Span::styled("f", key), + Span::styled(" freeze ", label), + Span::styled("1/2/3", key), + Span::styled(" view ", label), + Span::styled("t", key), + Span::styled(" time ", label), + Span::styled("c", key), + Span::styled(" cmds ", label), + Span::styled("s", key), + Span::styled(" sync ", label), + Span::styled("l/L", key), + Span::styled(" export", label), + ], + InputMode::Normal => vec![ + Span::styled("q", key), + Span::styled(" quit · ", label), + Span::styled("e", key), + Span::styled(" edit · ", label), + Span::styled("h", key), + Span::styled(" help · ", label), + Span::styled("f", key), + Span::styled(" freeze · ", label), + Span::styled("1/2/3", key), + Span::styled(" view · ", label), + Span::styled("t", key), + Span::styled(" time · ", label), + Span::styled("r/R", key), + Span::styled(" clear · ", label), + Span::styled("C-r", key), + Span::styled(" clr graphs · ", label), + Span::styled("c", key), + Span::styled(" cmds · ", label), + Span::styled("s", key), + Span::styled(" sync · ", label), + Span::styled("l/L", key), + Span::styled(" export", label), + ], + InputMode::Editing => vec![ + Span::styled("Press ", label), + Span::styled("Esc", key), + Span::styled(" to stop editing, ", label), + Span::styled("Enter", key), + Span::styled(" to send the message", label), + ], + InputMode::Help => vec![ + Span::styled("Press ", label), + Span::styled("Esc", key), + Span::styled(" to close", label), + ], + InputMode::CommandPalette => vec![ + Span::styled("↑↓", key), + Span::styled(" navigate · ", label), + Span::styled("Enter", key), + Span::styled(" select · ", label), + Span::styled("Esc", key), + Span::styled(" close · ", label), + Span::styled("type to filter", label), + ], + }; + + let text = Text::from(Line::from(spans)); + Paragraph::new(text).render(area, buf); + } +} diff --git a/src/widgets/help_popup.rs b/src/widgets/help_popup.rs new file mode 100644 index 0000000..dadcc47 --- /dev/null +++ b/src/widgets/help_popup.rs @@ -0,0 +1,127 @@ +use ratatui::{ + Frame, + layout::{Constraint, Flex, Layout, Rect}, + style::{Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Clear, Paragraph, Wrap}, +}; + +use crate::constants::DEFAULT_THEME; + +pub struct HelpPopup; + +impl HelpPopup { + pub fn render_overlay(frame: &mut Frame) { + let key = Style::default().fg(DEFAULT_THEME.keybind).bold(); + let label = Style::default().fg(DEFAULT_THEME.help_label); + + let help_text = vec![ + Line::from("Normal Mode".bold()), + Line::from(vec![ + Span::styled(" q", key), + Span::styled(" Quit", label), + ]), + Line::from(vec![ + Span::styled(" e", key), + Span::styled(" Edit mode", label), + ]), + Line::from(vec![ + Span::styled(" h", key), + Span::styled(" Toggle help", label), + ]), + Line::from(vec![ + Span::styled(" f", key), + Span::styled(" Freeze/unfreeze", label), + ]), + Line::from(vec![ + Span::styled(" 1", key), + Span::styled(" Received messages", label), + ]), + Line::from(vec![ + Span::styled(" 2", key), + Span::styled(" Sent messages", label), + ]), + Line::from(vec![ + Span::styled(" 3", key), + Span::styled(" Graphs view", label), + ]), + Line::from(vec![ + Span::styled(" t", key), + Span::styled(" Toggle timestamps", label), + ]), + Line::from(vec![ + Span::styled(" r", key), + Span::styled(" Clear received messages", label), + ]), + Line::from(vec![ + Span::styled(" R", key), + Span::styled(" Clear sent messages", label), + ]), + Line::from(vec![ + Span::styled(" Ctrl+r", key), + Span::styled(" Clear graphs", label), + ]), + Line::from(vec![ + Span::styled(" c", key), + Span::styled(" Command palette", label), + ]), + Line::from(vec![ + Span::styled(" s", key), + Span::styled(" Re-sync commands", label), + ]), + Line::from(vec![ + Span::styled(" l", key), + Span::styled(" Export current view", label), + ]), + Line::from(vec![ + Span::styled(" L", key), + Span::styled(" Export all buffers", label), + ]), + Line::from(vec![ + Span::styled(" x", key), + Span::styled(" Disconnect", label), + ]), + Line::from(""), + Line::from("Editing Mode".bold()), + Line::from(vec![ + Span::styled(" Esc", key), + Span::styled(" Back to normal", label), + ]), + Line::from(vec![ + Span::styled(" Enter", key), + Span::styled(" Send message", label), + ]), + Line::from(vec![ + Span::styled(" Backspace", key), + Span::styled(" Delete character", label), + ]), + Line::from(vec![ + Span::styled(" ←/→", key), + Span::styled(" Move cursor", label), + ]), + ]; + + let popup_block = Block::bordered() + .title("Help".bold()) + .border_style(Style::default().fg(DEFAULT_THEME.help_border)); + + let popup = Paragraph::new(help_text) + .block(popup_block) + .wrap(Wrap { trim: false }); + + let area = clamped_centered_rect(40, 25, frame.area()); + frame.render_widget(Clear, area); + frame.render_widget(popup, area); + } +} + +fn clamped_centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + let vertical = Layout::vertical([Constraint::Length(h)]) + .flex(Flex::Center) + .split(area); + Layout::horizontal([Constraint::Length(w)]) + .flex(Flex::Center) + .split(vertical[0])[0] +} diff --git a/src/widgets/input_field.rs b/src/widgets/input_field.rs new file mode 100644 index 0000000..f2a325f --- /dev/null +++ b/src/widgets/input_field.rs @@ -0,0 +1,41 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Style, Stylize}, + widgets::{Block, Paragraph, Widget}, +}; + +use crate::app::InputMode; +use crate::constants::DEFAULT_THEME; + +pub struct InputField<'a> { + input: &'a str, + mode: InputMode, +} + +impl<'a> InputField<'a> { + pub fn new(input: &'a str, mode: InputMode) -> Self { + Self { input, mode } + } +} + +impl Widget for InputField<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = match self.mode { + InputMode::Editing => Block::bordered() + .title("Input".bold()) + .border_style(Style::default().fg(DEFAULT_THEME.input_active)), + _ => Block::bordered().title("Input".bold()), + }; + + let style = match self.mode { + InputMode::Editing => Style::default().fg(DEFAULT_THEME.input_active), + _ => Style::default(), + }; + + Paragraph::new(self.input) + .style(style) + .block(block) + .render(area, buf); + } +} diff --git a/src/widgets/message_list.rs b/src/widgets/message_list.rs new file mode 100644 index 0000000..e67a816 --- /dev/null +++ b/src/widgets/message_list.rs @@ -0,0 +1,69 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, List, ListItem, ListState, StatefulWidget}, +}; + +use crate::constants::DEFAULT_THEME; +use crate::serial::SerialMessage; + +pub struct MessageList<'a> { + messages: &'a [SerialMessage], + title: &'a str, + show_timestamps: bool, + border_color: Color, +} + +impl<'a> MessageList<'a> { + pub fn new( + messages: &'a [SerialMessage], + title: &'a str, + show_timestamps: bool, + border_color: Color, + ) -> Self { + Self { + messages, + title, + show_timestamps, + border_color, + } + } +} + +impl StatefulWidget for MessageList<'_> { + type State = ListState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let enum_style = Style::default().fg(DEFAULT_THEME.enum_text); + + let items: Vec = self + .messages + .iter() + .enumerate() + .map(|(i, m)| { + if self.show_timestamps { + let ts = m.timestamp.format("%H:%M:%S"); + ListItem::new(Line::from(vec![ + Span::styled(format!("{i}: "), enum_style), + Span::raw(format!("[{ts}] {}", m.message)), + ])) + } else { + ListItem::new(Line::from(vec![ + Span::styled(format!("{i}: "), enum_style), + Span::raw(&m.message), + ])) + } + }) + .collect(); + + let list = List::new(items).block( + Block::bordered() + .title(self.title.bold()) + .border_style(Style::default().fg(self.border_color)), + ); + + StatefulWidget::render(list, area, buf, state); + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..2d75337 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,15 @@ +mod command_palette; +mod graph_view; +mod help_bar; +mod help_popup; +mod input_field; +mod message_list; +mod status_bar; + +pub use command_palette::CommandPalette; +pub use graph_view::GraphView; +pub use help_bar::HelpBar; +pub use help_popup::HelpPopup; +pub use input_field::InputField; +pub use message_list::MessageList; +pub use status_bar::{StatusBar, SyncDisplay}; diff --git a/src/widgets/status_bar.rs b/src/widgets/status_bar.rs new file mode 100644 index 0000000..442e454 --- /dev/null +++ b/src/widgets/status_bar.rs @@ -0,0 +1,97 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Paragraph, Widget}, +}; + +use crate::app::InputMode; +use crate::constants::DEFAULT_THEME; + +#[derive(Debug, Clone, Copy)] +pub enum SyncDisplay { + /// Initial delay or awaiting begin + Pending, + /// Receiving command list + Receiving, + /// Sync completed with N commands + Synced(usize), + /// Sync failed after retries + Failed, +} + +pub struct StatusBar<'a> { + port: &'a str, + baud: u32, + mode: InputMode, + receiving: bool, + sync: SyncDisplay, +} + +impl<'a> StatusBar<'a> { + pub fn new( + port: &'a str, + baud: u32, + mode: InputMode, + receiving: bool, + sync: SyncDisplay, + ) -> Self { + Self { + port, + baud, + mode, + receiving, + sync, + } + } +} + +impl Widget for StatusBar<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let mode_str = match self.mode { + InputMode::Normal => "NORMAL", + InputMode::Editing => "EDITING", + InputMode::Help => "HELP", + InputMode::CommandPalette => "COMMANDS", + }; + let (status_str, status_color) = if self.receiving { + ("LIVE", DEFAULT_THEME.status_live) + } else { + ("FROZEN", DEFAULT_THEME.status_frozen) + }; + + let (sync_str, sync_color) = match self.sync { + SyncDisplay::Pending => ("SYNC...".to_string(), Color::Gray), + SyncDisplay::Receiving => ("SYNCING".to_string(), Color::Yellow), + SyncDisplay::Synced(n) => (format!("SYNCED({n})"), crate::constants::SYNC_STATUS_COLOR), + SyncDisplay::Failed => ("SYNC FAILED".to_string(), Color::Red), + }; + + let left = Span::raw(format!(" {} @ {} baud", self.port, self.baud)); + let right_spans = vec![ + Span::styled(&sync_str, Style::default().fg(sync_color).bold()), + Span::raw(" | "), + Span::styled(mode_str, Style::default().bold()), + Span::raw(" | "), + Span::styled(status_str, Style::default().fg(status_color).bold()), + Span::raw(" "), + ]; + + // Fill background + Paragraph::new("") + .style(Style::default().bg(DEFAULT_THEME.status_bar_bg)) + .render(area, buf); + + // Left-aligned text + Paragraph::new(left) + .style(Style::default().bg(DEFAULT_THEME.status_bar_bg)) + .render(area, buf); + + // Right-aligned text + Paragraph::new(Line::from(right_spans)) + .alignment(Alignment::Right) + .style(Style::default().bg(DEFAULT_THEME.status_bar_bg)) + .render(area, buf); + } +}