From c2846dfec7c870a00618c11d8875c0151f40df60 Mon Sep 17 00:00:00 2001 From: Lea Wedmann Date: Mon, 1 Jun 2026 19:04:58 +0200 Subject: [PATCH 1/7] Add team player tui --- pixi.toml | 1 + .../bitbots_bringup/tui/Cargo.lock | 683 +++++++++ .../bitbots_bringup/tui/Cargo.toml | 16 + .../bitbots_bringup/tui/src/main.rs | 1314 +++++++++++++++++ 4 files changed, 2014 insertions(+) create mode 100644 src/bitbots_misc/bitbots_bringup/tui/Cargo.lock create mode 100644 src/bitbots_misc/bitbots_bringup/tui/Cargo.toml create mode 100644 src/bitbots_misc/bitbots_bringup/tui/src/main.rs diff --git a/pixi.toml b/pixi.toml index 24faec9bf..59e571be1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -14,6 +14,7 @@ version = "0.1.0" zenoh = "ros2 run rmw_zenoh_cpp rmw_zenohd" deploy = {cmd = "scripts/deploy_robots.py", description = "Deploys the current environment."} format = {cmd = "pre-commit run --all-files", description = "Runs code formatting and linting."} +teamplayer = { cmd = "cargo run --release --manifest-path src/bitbots_misc/bitbots_bringup/tui/Cargo.toml", description = "Launch the teamplayer TUI (Rust/ratatui)" } [tasks.build] # Skipping ZED-camera related packages as they require a local installation of the ZED SDK. diff --git a/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock b/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock new file mode 100644 index 000000000..e6ea2c6f0 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock @@ -0,0 +1,683 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "libc", + "mio 0.8.11", + "parking_lot", + "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 = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +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 = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "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 0.8.11", + "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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "teamplayer-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "futures", + "libc", + "ratatui", + "tokio", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.0", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml b/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml new file mode 100644 index 000000000..5f10c415b --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "teamplayer-tui" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "teamplayer_tui" +path = "src/main.rs" + +[dependencies] +ratatui = "0.26" +crossterm = { version = "0.27", features = ["event-stream"] } +tokio = { version = "1", features = ["full"] } +anyhow = "1" +futures = "0.3" +libc = "0.2" diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/main.rs b/src/bitbots_misc/bitbots_bringup/tui/src/main.rs new file mode 100644 index 000000000..08af36822 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/tui/src/main.rs @@ -0,0 +1,1314 @@ +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + time::Duration, +}; + +use anyhow::Result; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::StreamExt; +use ratatui::{ + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command, + sync::mpsc, +}; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +enum ProcState { + Idle, + Running, + Stopping, + Stopped, + Crashed, +} + +impl ProcState { + fn label(&self) -> &'static str { + match self { + ProcState::Idle => "IDLE", + ProcState::Running => "RUN ", + ProcState::Stopping => "STOP", + ProcState::Stopped => "DONE", + ProcState::Crashed => "FAIL", + } + } + fn style(&self) -> Style { + match self { + ProcState::Idle => Style::default().fg(Color::DarkGray), + ProcState::Running => Style::default().fg(Color::Green), + ProcState::Stopping => Style::default().fg(Color::Yellow), + ProcState::Stopped => Style::default().fg(Color::Blue), + ProcState::Crashed => Style::default().fg(Color::Red), + } + } +} + +#[derive(Clone, Debug)] +struct GlobalParams { + sim: bool, + zenoh: bool, + fieldname: String, + dsd_file: String, +} + +impl Default for GlobalParams { + fn default() -> Self { + Self { + sim: false, + zenoh: true, + fieldname: String::new(), + dsd_file: "main.dsd".to_string(), + } + } +} + +struct ComponentDef { + name: &'static str, + key: &'static str, + default_enabled: bool, + infrastructure: bool, + /// Component is only valid on real hardware — hidden/disabled when sim=true + hardware_only: bool, + cmds: fn(&GlobalParams) -> Vec>, +} + +struct ComponentState { + def_idx: usize, + enabled: bool, + state: ProcState, + logs: Arc>>, + pids: Vec, + task_handles: Vec>, +} + +impl ComponentState { + fn new(def_idx: usize, enabled: bool) -> Self { + Self { + def_idx, + enabled, + state: ProcState::Idle, + logs: Arc::new(Mutex::new(VecDeque::with_capacity(2000))), + pids: Vec::new(), + task_handles: Vec::new(), + } + } +} + +enum AppMsg { + ProcessExited { key: String, code: i32 }, +} + +// ─── Component definitions ──────────────────────────────────────────────────── + +fn make_ros2_launch(pkg: &str, file: &str, extra: &[&str]) -> Vec { + let mut cmd = vec![ + "ros2".to_string(), + "launch".to_string(), + pkg.to_string(), + file.to_string(), + ]; + for e in extra { + cmd.push(e.to_string()); + } + cmd +} + +fn make_ros2_run(pkg: &str, exec: &str, extra: &[&str]) -> Vec { + let mut cmd = vec![ + "ros2".to_string(), + "run".to_string(), + pkg.to_string(), + exec.to_string(), + ]; + for e in extra { + cmd.push(e.to_string()); + } + cmd +} + +fn sim_arg(params: &GlobalParams) -> String { + format!("sim:={}", params.sim) +} + +static COMPONENT_DEFS: &[ComponentDef] = &[ + // ── Infrastructure (always started) ── + ComponentDef { + name: "Zenoh", + key: "zenoh", + default_enabled: true, + infrastructure: true, + hardware_only: false, + cmds: |_p| vec![make_ros2_run("rmw_zenoh_cpp", "rmw_zenohd", &[])], + }, + ComponentDef { + name: "Param Blackboard", + key: "blackboard", + default_enabled: true, + infrastructure: true, + hardware_only: false, + cmds: |_p| { + vec![make_ros2_run( + "parameter_blackboard", + "parameter_blackboard", + &["--ros-args", "-p", "use_sim_time:=false"], + )] + }, + }, + ComponentDef { + name: "Robot Description", + key: "robot_description", + default_enabled: true, + infrastructure: true, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "bitbots_bringup", + "robot_description.launch", + &[&sim_arg(p)], + )] + }, + }, + ComponentDef { + name: "Diagnostics", + key: "diagnostics", + default_enabled: true, + infrastructure: true, + hardware_only: false, + cmds: |_p| vec![make_ros2_launch("bitbots_bringup", "diagnostics.launch", &[])], + }, + ComponentDef { + name: "Simulator", + key: "simulator", + default_enabled: false, + infrastructure: true, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "bitbots_bringup", + "mujoco_simulation.launch.py", + &[&sim_arg(p)], + )] + }, + }, + // ── User-toggleable ── + ComponentDef { + name: "Lowlevel", + key: "lowlevel", + default_enabled: true, + infrastructure: false, + hardware_only: true, // hardware interface — never in sim + cmds: |_p| vec![make_ros2_launch("livelybot_bringup", "lowlevel.launch", &[])], + }, + ComponentDef { + name: "Motion", + key: "motion", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "bitbots_bringup", + "motion.launch", + &[&sim_arg(p)], + )] + }, + }, + ComponentDef { + name: "Game Controller", + key: "game_controller", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "game_controller_hsl", + "game_controller.launch", + &[ + &sim_arg(p), + "use_parameter_blackboard:=true", + "parameter_blackboard_name:=parameter_blackboard", + "team_id_param_name:=team_id", + "bot_id_param_name:=bot_id", + ], + )] + }, + }, + ComponentDef { + name: "Vision", + key: "vision", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch("bitbots_bringup", "vision.launch", &[&sim_arg(p)])] + }, + }, + ComponentDef { + name: "IPM", + key: "ipm", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch("bitbots_ipm", "ipm.launch", &[&sim_arg(p)])] + }, + }, + ComponentDef { + name: "Localization", + key: "localization", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "bitbots_localization", + "localization.launch", + &[&sim_arg(p)], + )] + }, + }, + ComponentDef { + name: "Path Planning", + key: "path_planning", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "bitbots_path_planning", + "path_planning.launch", + &[&sim_arg(p)], + )] + }, + }, + ComponentDef { + name: "Behavior", + key: "behavior", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "bitbots_body_behavior", + "behavior.launch", + &[&sim_arg(p), &format!("dsd_file:={}", p.dsd_file)], + )] + }, + }, + ComponentDef { + name: "Team Comm", + key: "teamcom", + default_enabled: false, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "bitbots_team_communication", + "team_comm.launch", + &[&sim_arg(p)], + )] + }, + }, + ComponentDef { + name: "World Model", + key: "world_model", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![ + make_ros2_launch("bitbots_ball_filter", "ball_filter.launch", &[&sim_arg(p)]), + make_ros2_launch("bitbots_robot_filter", "robot_filter.launch", &[&sim_arg(p)]), + ] + }, + }, + ComponentDef { + name: "Whistle Det.", + key: "whistle_detector", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |_p| { + vec![make_ros2_launch( + "bitbots_whistle_detector", + "whistle_detector.launch", + &[], + )] + }, + }, + ComponentDef { + name: "Audio", + key: "audio", + default_enabled: true, + infrastructure: false, + hardware_only: false, + cmds: |_p| vec![make_ros2_launch("bitbots_bringup", "audio.launch", &[])], + }, + ComponentDef { + name: "TTS", + key: "tts", + default_enabled: false, + infrastructure: false, + hardware_only: false, + cmds: |_p| vec![make_ros2_launch("bitbots_bringup", "tts.launch", &[])], + }, + ComponentDef { + name: "Monitoring", + key: "monitoring", + default_enabled: false, + infrastructure: false, + hardware_only: false, + cmds: |_p| vec![make_ros2_launch("bitbots_bringup", "monitoring.launch", &[])], + }, + ComponentDef { + name: "Recording", + key: "record", + default_enabled: false, + infrastructure: false, + hardware_only: false, + cmds: |p| { + vec![make_ros2_launch( + "bitbots_bringup", + "record.launch", + &[&format!("fieldname:={}", p.fieldname)], + )] + }, + }, +]; + +// ─── Screens ────────────────────────────────────────────────────────────────── + +#[derive(PartialEq, Clone)] +enum Screen { + Config, + Runtime, + Logs(usize), // component index +} + +// Config screen focus items +#[derive(Clone, PartialEq, Debug)] +enum ConfigFocus { + Zenoh, + Sim, + Fieldname, + DsdFile, + Component(usize), // index into toggleable components list + Start, +} + +// ─── App state ──────────────────────────────────────────────────────────────── + +struct App { + screen: Screen, + params: GlobalParams, + components: Vec, + config_focus: ConfigFocus, + config_fieldname_input: String, + config_dsd_input: String, + runtime_selected: usize, + tick_count: u64, + msg_tx: mpsc::UnboundedSender, + msg_rx: mpsc::UnboundedReceiver, + log_scroll: usize, +} + +impl App { + fn new() -> Self { + let (msg_tx, msg_rx) = mpsc::unbounded_channel(); + let mut components: Vec = COMPONENT_DEFS + .iter() + .enumerate() + .map(|(i, def)| ComponentState::new(i, def.default_enabled)) + .collect(); + + // Simulator is only enabled when sim=true + let sim_idx = COMPONENT_DEFS.iter().position(|d| d.key == "simulator").unwrap(); + components[sim_idx].enabled = false; + + App { + screen: Screen::Config, + params: GlobalParams::default(), + components, + config_focus: ConfigFocus::Zenoh, + config_fieldname_input: String::new(), + config_dsd_input: "main.dsd".to_string(), + runtime_selected: 0, + tick_count: 0, + msg_tx, + msg_rx, + log_scroll: 0, + } + } + + fn toggleable_indices(&self) -> Vec { + COMPONENT_DEFS + .iter() + .enumerate() + .filter(|(_, d)| !d.infrastructure && !(d.hardware_only && self.params.sim)) + .map(|(i, _)| i) + .collect() + } + + fn config_focus_items(&self) -> Vec { + let mut items = vec![ConfigFocus::Zenoh, ConfigFocus::Sim, ConfigFocus::Fieldname, ConfigFocus::DsdFile]; + let toggleables = self.toggleable_indices(); + for i in 0..toggleables.len() { + items.push(ConfigFocus::Component(i)); + } + items.push(ConfigFocus::Start); + items + } + + fn config_focus_next(&mut self) { + let items = self.config_focus_items(); + let pos = items.iter().position(|f| f == &self.config_focus).unwrap_or(0); + self.config_focus = items[(pos + 1) % items.len()].clone(); + } + + fn config_focus_prev(&mut self) { + let items = self.config_focus_items(); + let pos = items.iter().position(|f| f == &self.config_focus).unwrap_or(0); + self.config_focus = items[(pos + items.len() - 1) % items.len()].clone(); + } + + fn runtime_visible_indices(&self) -> Vec { + COMPONENT_DEFS + .iter() + .enumerate() + .filter(|(i, d)| { + // Simulator row only appears when sim=true + if d.key == "simulator" { + return self.params.sim; + } + // Hardware-only components are never shown in sim + if d.hardware_only && self.params.sim { + return false; + } + self.components[*i].enabled + }) + .map(|(i, _)| i) + .collect() + } + + async fn start_component(&mut self, comp_idx: usize) { + let def = &COMPONENT_DEFS[comp_idx]; + let cmds = (def.cmds)(&self.params); + let key = def.key.to_string(); + + // Extract what we need before the loop so we don't hold &mut self.components + let logs = self.components[comp_idx].logs.clone(); + self.components[comp_idx].state = ProcState::Running; + self.components[comp_idx].pids.clear(); + self.components[comp_idx].task_handles.clear(); + + for cmd_args in cmds { + if cmd_args.is_empty() { + continue; + } + let tx = self.msg_tx.clone(); + let logs = logs.clone(); + let key = key.clone(); + + let mut child = match Command::new(&cmd_args[0]) + .args(&cmd_args[1..]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + // New process group so we can kill the entire ros2 launch tree + .process_group(0) + .kill_on_drop(true) + .spawn() + { + Ok(c) => c, + Err(e) => { + let mut l = logs.lock().unwrap(); + l.push_back(format!("[ERROR] spawn failed: {e}")); + drop(l); + let _ = tx.send(AppMsg::ProcessExited { key, code: -1 }); + return; + } + }; + + if let Some(pid) = child.id() { + self.components[comp_idx].pids.push(pid); + } + + let stdout = child.stdout.take().map(BufReader::new); + let stderr = child.stderr.take().map(BufReader::new); + + let handle = tokio::spawn(async move { + // Interleave stdout+stderr into log buffer + if let Some(mut reader) = stdout { + let mut line = String::new(); + while let Ok(n) = reader.read_line(&mut line).await { + if n == 0 { break; } + let trimmed = line.trim_end_matches('\n').to_string(); + let mut l = logs.lock().unwrap(); + if l.len() >= 2000 { l.pop_front(); } + l.push_back(trimmed); + line.clear(); + } + } + if let Some(mut reader) = stderr { + let mut line = String::new(); + while let Ok(n) = reader.read_line(&mut line).await { + if n == 0 { break; } + let trimmed = format!("[ERR] {}", line.trim_end_matches('\n')); + let mut l = logs.lock().unwrap(); + if l.len() >= 2000 { l.pop_front(); } + l.push_back(trimmed); + line.clear(); + } + } + let code = child.wait().await.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1); + let _ = tx.send(AppMsg::ProcessExited { key, code }); + }); + self.components[comp_idx].task_handles.push(handle); + } + } + + async fn stop_component(&mut self, comp_idx: usize) { + let comp = &mut self.components[comp_idx]; + if comp.state != ProcState::Running { + return; + } + comp.state = ProcState::Stopping; + let pids = comp.pids.clone(); + for &pid in &pids { + unsafe { + // Negative PID = send to entire process group (kills ros2 launch children too) + libc::kill(-(pid as libc::pid_t), libc::SIGTERM); + } + } + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + for pid in pids { + unsafe { + libc::kill(-(pid as libc::pid_t), libc::SIGKILL); + } + } + }); + } + + async fn stop_all(&mut self) { + let indices: Vec = (0..self.components.len()).collect(); + for i in indices { + if self.components[i].state == ProcState::Running { + self.stop_component(i).await; + } + } + } + + async fn start_all_enabled(&mut self) { + let enabled: Vec = self.components + .iter() + .enumerate() + .filter(|(_, c)| c.enabled && c.state == ProcState::Idle) + .map(|(i, _)| i) + .collect(); + for i in enabled { + self.start_component(i).await; + } + } + + fn drain_messages(&mut self) { + while let Ok(msg) = self.msg_rx.try_recv() { + match msg { + AppMsg::ProcessExited { key, code } => { + if let Some(comp) = self.components.iter_mut().find(|c| { + COMPONENT_DEFS[c.def_idx].key == key + }) { + if comp.state == ProcState::Stopping || comp.state == ProcState::Running { + comp.state = if code == 0 { + ProcState::Stopped + } else { + ProcState::Crashed + }; + comp.pids.clear(); + } + } + } + } + } + } +} + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +const BANNER: &str = r#" + ██████╗ ██╗████████╗ ██████╗ ██████╗ ████████╗███████╗ + ██╔══██╗██║╚══██╔══╝ ██╔══██╗██╔═══██╗╚══██╔══╝██╔════╝ + ██████╔╝██║ ██║ ██████╔╝██║ ██║ ██║ ███████╗ + ██╔══██╗██║ ██║ ██╔══██╗██║ ██║ ██║ ╚════██║ + ██████╔╝██║ ██║ ██████╔╝╚██████╔╝ ██║ ███████║ + ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ + Teamplayer Launcher +"#; + +fn spinner_char(tick: u64) -> char { + const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + FRAMES[(tick as usize) % FRAMES.len()] +} + +fn draw(f: &mut Frame, app: &App) { + match &app.screen { + Screen::Config => draw_config(f, app), + Screen::Runtime => draw_runtime(f, app), + Screen::Logs(idx) => draw_logs(f, app, *idx), + } +} + +fn draw_config(f: &mut Frame, app: &App) { + let area = f.size(); + + let banner_lines = BANNER.lines().count() as u16; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(banner_lines), + Constraint::Length(3), // global flags bar + Constraint::Min(0), // component grid + Constraint::Length(3), // Start/Continue button + Constraint::Length(1), // footer + ]) + .split(area); + + let banner = Paragraph::new(BANNER) + .style(Style::default().fg(Color::Cyan)) + .alignment(Alignment::Center); + f.render_widget(banner, chunks[0]); + + draw_config_flags(f, app, chunks[1]); + draw_config_components(f, app, chunks[2]); + draw_start_button(f, app, chunks[3]); + + let footer = Paragraph::new( + " Tab/↑↓: navigate Space/Enter: toggle/select q: quit", + ) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[4]); +} + +fn draw_start_button(f: &mut Frame, app: &App, area: Rect) { + let focused = app.config_focus == ConfigFocus::Start; + let has_running = app.components.iter().any(|c| c.state == ProcState::Running); + + let label = if has_running { " Continue → " } else { " Launch → " }; + + let (border_style, text_style) = if focused { + ( + Style::default().fg(Color::Yellow), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + } else { + ( + Style::default().fg(Color::Green), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ) + }; + + let button = Paragraph::new(label) + .block(Block::default().borders(Borders::ALL).border_style(border_style)) + .style(text_style) + .alignment(Alignment::Center); + f.render_widget(button, area); +} + +fn draw_config_flags(f: &mut Frame, app: &App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(18), // Zenoh + Constraint::Length(18), // Sim + Constraint::Min(20), // Fieldname + Constraint::Min(20), // DSD file + ]) + .split(area); + + let zenoh_focused = app.config_focus == ConfigFocus::Zenoh; + let sim_focused = app.config_focus == ConfigFocus::Sim; + let field_focused = app.config_focus == ConfigFocus::Fieldname; + let dsd_focused = app.config_focus == ConfigFocus::DsdFile; + + let zenoh_style = if zenoh_focused { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let zenoh_text = if app.params.zenoh { "[✓] Zenoh" } else { "[ ] Zenoh" }; + let zenoh_p = Paragraph::new(zenoh_text) + .block(Block::default().borders(Borders::ALL).border_style(if zenoh_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + })) + .style(zenoh_style); + f.render_widget(zenoh_p, chunks[0]); + + let sim_style = if sim_focused { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let sim_text = if app.params.sim { "[✓] Simulator" } else { "[ ] Simulator" }; + let sim_p = Paragraph::new(sim_text) + .block(Block::default().borders(Borders::ALL).border_style(if sim_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + })) + .style(sim_style); + f.render_widget(sim_p, chunks[1]); + + let field_p = Paragraph::new(app.config_fieldname_input.as_str()) + .block(Block::default().borders(Borders::ALL).title("Fieldname").border_style(if field_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + })); + f.render_widget(field_p, chunks[2]); + + let dsd_p = Paragraph::new(app.config_dsd_input.as_str()) + .block(Block::default().borders(Borders::ALL).title("DSD File").border_style(if dsd_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + })); + f.render_widget(dsd_p, chunks[3]); +} + +fn draw_config_components(f: &mut Frame, app: &App, area: Rect) { + let toggleables = app.toggleable_indices(); + + // Split into two columns + let col_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + let left_count = (toggleables.len() + 1) / 2; + let (left_indices, right_indices) = toggleables.split_at(left_count); + + draw_component_column(f, app, col_chunks[0], left_indices, 0); + draw_component_column(f, app, col_chunks[1], right_indices, left_indices.len()); +} + +fn draw_component_column( + f: &mut Frame, + app: &App, + area: Rect, + comp_indices: &[usize], + focus_offset: usize, +) { + let rows: Vec = comp_indices + .iter() + .map(|_| Constraint::Length(1)) + .collect(); + if rows.is_empty() { + return; + } + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(rows) + .split(area); + + for (local_i, &comp_idx) in comp_indices.iter().enumerate() { + let focus_i = focus_offset + local_i; + let focused = app.config_focus == ConfigFocus::Component(focus_i); + let comp = &app.components[comp_idx]; + let def = &COMPONENT_DEFS[comp_idx]; + + let check = if comp.enabled { "✓" } else { " " }; + let text = format!("[{}] {}", check, def.name); + + let style = if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if comp.enabled { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + + let p = Paragraph::new(text).style(style); + if local_i < chunks.len() { + f.render_widget(p, chunks[local_i]); + } + } + + // Start button at bottom of right column + // (handled separately in draw_config) +} + +fn draw_runtime(f: &mut Frame, app: &App) { + let area = f.size(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // header + Constraint::Min(0), // component list + Constraint::Length(1), // footer + ]) + .split(area); + + // Header + draw_runtime_header(f, app, chunks[0]); + + // Component list + draw_runtime_list(f, app, chunks[1]); + + // Footer + let footer = Paragraph::new( + " ↑↓: select s: start/stop r: restart l: logs a: start all x: stop all q: quit", + ) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[2]); +} + +fn draw_runtime_header(f: &mut Frame, app: &App, area: Rect) { + let running = app.components.iter().filter(|c| c.state == ProcState::Running).count(); + let total_enabled = app.components.iter().filter(|c| c.enabled).count(); + + let title = format!(" Bit-Bots Teamplayer [{running}/{total_enabled} running]"); + let sim_badge = if app.params.sim { " SIM " } else { "" }; + let text = format!("{title}{sim_badge}"); + + let p = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(p, area); +} + +fn draw_runtime_list(f: &mut Frame, app: &App, area: Rect) { + let visible = app.runtime_visible_indices(); + if visible.is_empty() { + let p = Paragraph::new("No components enabled.").alignment(Alignment::Center); + f.render_widget(p, area); + return; + } + + let row_height = 1u16; + let rows: Vec = visible.iter().map(|_| Constraint::Length(row_height)).collect(); + let row_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(rows) + .split(area); + + for (vis_i, &comp_idx) in visible.iter().enumerate() { + let selected = app.runtime_selected == vis_i; + let comp = &app.components[comp_idx]; + let def = &COMPONENT_DEFS[comp_idx]; + + if vis_i < row_chunks.len() { + draw_runtime_row(f, app, row_chunks[vis_i], comp, def, selected); + } + } +} + +fn draw_runtime_row( + f: &mut Frame, + app: &App, + area: Rect, + comp: &ComponentState, + def: &ComponentDef, + selected: bool, +) { + let row_style = if selected { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + let spinner = match comp.state { + ProcState::Running => format!("{} ", spinner_char(app.tick_count)), + ProcState::Stopping => "… ".to_string(), + _ => " ".to_string(), + }; + + let state_label = comp.state.label(); + let state_style = comp.state.style(); + + let infra_marker = if def.infrastructure { "·" } else { " " }; + + let line = Line::from(vec![ + Span::styled(format!("{infra_marker}"), Style::default().fg(Color::DarkGray)), + Span::styled(spinner, Style::default().fg(Color::Green)), + Span::styled( + format!("[{state_label}]"), + state_style.add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("{:<16}", def.name), + if selected { + Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ), + Span::raw(" "), + Span::styled("s", Style::default().fg(Color::Yellow)), + Span::raw(":start/stop "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(":restart "), + Span::styled("l", Style::default().fg(Color::Yellow)), + Span::raw(":logs"), + ]); + + let p = Paragraph::new(line).style(row_style); + f.render_widget(p, area); +} + +fn draw_logs(f: &mut Frame, app: &App, comp_idx: usize) { + let area = f.size(); + let comp = &app.components[comp_idx]; + let def = &COMPONENT_DEFS[comp_idx]; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)]) + .split(area); + + let title = format!(" Logs: {} ", def.name); + let header = Paragraph::new(title) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + let log_area = chunks[1]; + let log_height = log_area.height as usize; + + let lines: Vec = { + let logs = comp.logs.lock().unwrap(); + let total = logs.len(); + let scroll = app.log_scroll.min(if total > log_height { total - log_height } else { 0 }); + logs.iter() + .skip(scroll) + .take(log_height) + .map(|s| Line::from(s.clone())) + .collect() + }; + + let log_p = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::White)); + f.render_widget(log_p, log_area); + + let footer = Paragraph::new(" ↑↓/PgUp/PgDn: scroll Esc/q: back G: jump to end") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[2]); +} + +// ─── Input handling ─────────────────────────────────────────────────────────── + +async fn handle_config_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> bool { + match (&app.config_focus.clone(), key) { + // Navigation + (_, KeyCode::Tab) => { + if modifiers.contains(KeyModifiers::SHIFT) { + app.config_focus_prev(); + } else { + app.config_focus_next(); + } + } + (_, KeyCode::Down) => app.config_focus_next(), + (_, KeyCode::Up) => app.config_focus_prev(), + + // Zenoh toggle + (ConfigFocus::Zenoh, KeyCode::Char(' ') | KeyCode::Enter) => { + app.params.zenoh = !app.params.zenoh; + let zenoh_idx = COMPONENT_DEFS.iter().position(|d| d.key == "zenoh").unwrap(); + app.components[zenoh_idx].enabled = app.params.zenoh; + } + + // Sim toggle + (ConfigFocus::Sim, KeyCode::Char(' ') | KeyCode::Enter) => { + app.params.sim = !app.params.sim; + let sim = app.params.sim; + for (i, def) in COMPONENT_DEFS.iter().enumerate() { + if def.key == "simulator" { + app.components[i].enabled = sim; + } else if def.hardware_only { + // Auto-disable HW-only components in sim, restore default otherwise + app.components[i].enabled = !sim && def.default_enabled; + } + } + // Reset config focus if it now points at a hidden component + app.config_focus = ConfigFocus::Zenoh; + } + + // Fieldname text input + (ConfigFocus::Fieldname, KeyCode::Char(c)) => { + app.config_fieldname_input.push(c); + app.params.fieldname = app.config_fieldname_input.clone(); + } + (ConfigFocus::Fieldname, KeyCode::Backspace) => { + app.config_fieldname_input.pop(); + app.params.fieldname = app.config_fieldname_input.clone(); + } + + // DSD file text input + (ConfigFocus::DsdFile, KeyCode::Char(c)) => { + app.config_dsd_input.push(c); + app.params.dsd_file = app.config_dsd_input.clone(); + } + (ConfigFocus::DsdFile, KeyCode::Backspace) => { + app.config_dsd_input.pop(); + app.params.dsd_file = app.config_dsd_input.clone(); + } + + // Component checkbox toggle + (ConfigFocus::Component(fi), KeyCode::Char(' ') | KeyCode::Enter) => { + let fi = *fi; + let toggleables = app.toggleable_indices(); + if fi < toggleables.len() { + let comp_idx = toggleables[fi]; + app.components[comp_idx].enabled = !app.components[comp_idx].enabled; + } + } + + // Start button + (ConfigFocus::Start, KeyCode::Enter | KeyCode::Char(' ')) => { + // Transition to runtime + app.params.fieldname = app.config_fieldname_input.clone(); + app.params.dsd_file = app.config_dsd_input.clone(); + app.screen = Screen::Runtime; + app.start_all_enabled().await; + } + + // Quit + (_, KeyCode::Char('q') | KeyCode::Char('Q')) => { + return true; // signal quit + } + (_, KeyCode::Esc) => { + return true; + } + + _ => {} + } + false +} + +async fn handle_runtime_key(app: &mut App, key: KeyCode, _modifiers: KeyModifiers) -> bool { + let visible = app.runtime_visible_indices(); + let sel = app.runtime_selected.min(visible.len().saturating_sub(1)); + let comp_idx = visible.get(sel).copied(); + + match key { + KeyCode::Down | KeyCode::Char('j') => { + app.runtime_selected = (sel + 1).min(visible.len().saturating_sub(1)); + } + KeyCode::Up | KeyCode::Char('k') => { + app.runtime_selected = sel.saturating_sub(1); + } + KeyCode::Char('s') => { + if let Some(idx) = comp_idx { + match app.components[idx].state { + ProcState::Running | ProcState::Stopping => { + app.stop_component(idx).await; + } + ProcState::Idle | ProcState::Stopped | ProcState::Crashed => { + app.components[idx].state = ProcState::Idle; + app.start_component(idx).await; + } + } + } + } + KeyCode::Char('r') => { + if let Some(idx) = comp_idx { + app.stop_component(idx).await; + // Brief delay then restart — we set idle so it auto-starts + // Actually just start immediately; stop sends SIGTERM async + app.components[idx].state = ProcState::Idle; + app.start_component(idx).await; + } + } + KeyCode::Char('l') => { + if let Some(idx) = comp_idx { + app.log_scroll = usize::MAX; // jump to end + { + let logs = app.components[idx].logs.lock().unwrap(); + let len = logs.len(); + drop(logs); + app.log_scroll = len.saturating_sub(1); + } + app.screen = Screen::Logs(idx); + } + } + KeyCode::Char('a') => { + app.start_all_enabled().await; + } + KeyCode::Char('x') => { + app.stop_all().await; + } + KeyCode::Esc => { + // Back to config (ask for quit confirm instead) + app.screen = Screen::Config; + } + KeyCode::Char('q') | KeyCode::Char('Q') => { + return true; + } + _ => {} + } + false +} + +async fn handle_logs_key(app: &mut App, key: KeyCode, _modifiers: KeyModifiers, comp_idx: usize) -> bool { + let log_len = app.components[comp_idx].logs.lock().unwrap().len(); + match key { + KeyCode::Down | KeyCode::Char('j') => { + app.log_scroll = (app.log_scroll + 1).min(log_len.saturating_sub(1)); + } + KeyCode::Up | KeyCode::Char('k') => { + app.log_scroll = app.log_scroll.saturating_sub(1); + } + KeyCode::PageDown => { + app.log_scroll = (app.log_scroll + 20).min(log_len.saturating_sub(1)); + } + KeyCode::PageUp => { + app.log_scroll = app.log_scroll.saturating_sub(20); + } + KeyCode::Char('G') => { + app.log_scroll = log_len.saturating_sub(1); + } + KeyCode::Char('g') => { + app.log_scroll = 0; + } + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('l') => { + app.screen = Screen::Runtime; + } + _ => {} + } + false +} + +// ─── Terminal cleanup helpers ───────────────────────────────────────────────── + +fn setup_panic_hook() { + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + // Best-effort cleanup — ignore errors + let _ = disable_raw_mode(); + let _ = execute!( + std::io::stdout(), + LeaveAlternateScreen, + DisableMouseCapture + ); + original(info); + })); +} + +fn cleanup_terminal() { + let _ = disable_raw_mode(); + let _ = execute!( + std::io::stdout(), + LeaveAlternateScreen, + DisableMouseCapture + ); +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> Result<()> { + setup_panic_hook(); + + // Setup terminal + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(); + let mut event_stream = EventStream::new(); + let mut tick_interval = tokio::time::interval(Duration::from_millis(100)); + + let result = run_loop(&mut terminal, &mut app, &mut event_stream, &mut tick_interval).await; + + // Ensure all children are killed + app.stop_all().await; + // Give them a moment to receive SIGTERM + tokio::time::sleep(Duration::from_millis(300)).await; + // SIGKILL anything still alive (whole process group) + for comp in &app.components { + for &pid in &comp.pids { + unsafe { + libc::kill(-(pid as libc::pid_t), libc::SIGKILL); + } + } + } + + cleanup_terminal(); + result +} + +async fn run_loop( + terminal: &mut Terminal>, + app: &mut App, + event_stream: &mut EventStream, + tick_interval: &mut tokio::time::Interval, +) -> Result<()> { + loop { + terminal.draw(|f| draw(f, app))?; + + tokio::select! { + _ = tick_interval.tick() => { + app.tick_count += 1; + app.drain_messages(); + // Auto-scroll logs to bottom if on log screen + if let Screen::Logs(idx) = &app.screen { + let len = app.components[*idx].logs.lock().unwrap().len(); + // Only auto-scroll if near the bottom + if app.log_scroll + 5 >= len.saturating_sub(1) { + app.log_scroll = len.saturating_sub(1); + } + } + } + + maybe_event = event_stream.next() => { + let Some(Ok(event)) = maybe_event else { break; }; + + if let Event::Key(key_event) = event { + let quit = match app.screen.clone() { + Screen::Config => { + handle_config_key(app, key_event.code, key_event.modifiers).await + } + Screen::Runtime => { + handle_runtime_key(app, key_event.code, key_event.modifiers).await + } + Screen::Logs(idx) => { + handle_logs_key(app, key_event.code, key_event.modifiers, idx).await + } + }; + if quit { + return Ok(()); + } + } + } + + _ = tokio::signal::ctrl_c() => { + return Ok(()); + } + } + } + Ok(()) +} From 6c1ff940b2ac175d04b102ea3048ac4c2fdc85c5 Mon Sep 17 00:00:00 2001 From: Lea Wedmann Date: Mon, 1 Jun 2026 21:07:48 +0200 Subject: [PATCH 2/7] Cleaned up version --- pixi.toml | 2 +- .../launch/visualization.launch | 41 - .../bitbots_bringup/tui/Cargo.lock | 144 +- .../bitbots_bringup/tui/Cargo.toml | 7 +- .../bitbots_bringup/tui/src/app.rs | 401 +++++ .../bitbots_bringup/tui/src/components.rs | 324 ++++ .../bitbots_bringup/tui/src/input.rs | 164 ++ .../bitbots_bringup/tui/src/main.rs | 1361 ++--------------- .../bitbots_bringup/tui/src/ui.rs | 408 +++++ 9 files changed, 1581 insertions(+), 1271 deletions(-) delete mode 100644 src/bitbots_misc/bitbots_bringup/launch/visualization.launch create mode 100644 src/bitbots_misc/bitbots_bringup/tui/src/app.rs create mode 100644 src/bitbots_misc/bitbots_bringup/tui/src/components.rs create mode 100644 src/bitbots_misc/bitbots_bringup/tui/src/input.rs create mode 100644 src/bitbots_misc/bitbots_bringup/tui/src/ui.rs diff --git a/pixi.toml b/pixi.toml index 59e571be1..cb41ba1d2 100644 --- a/pixi.toml +++ b/pixi.toml @@ -14,7 +14,7 @@ version = "0.1.0" zenoh = "ros2 run rmw_zenoh_cpp rmw_zenohd" deploy = {cmd = "scripts/deploy_robots.py", description = "Deploys the current environment."} format = {cmd = "pre-commit run --all-files", description = "Runs code formatting and linting."} -teamplayer = { cmd = "cargo run --release --manifest-path src/bitbots_misc/bitbots_bringup/tui/Cargo.toml", description = "Launch the teamplayer TUI (Rust/ratatui)" } +teamplayer = { cmd = "cargo run --release --manifest-path src/bitbots_misc/bitbots_bringup/tui/Cargo.toml --", description = "Launch the main software components" } [tasks.build] # Skipping ZED-camera related packages as they require a local installation of the ZED SDK. diff --git a/src/bitbots_misc/bitbots_bringup/launch/visualization.launch b/src/bitbots_misc/bitbots_bringup/launch/visualization.launch deleted file mode 100644 index 8b4bf73d5..000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/visualization.launch +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock b/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock index e6ea2c6f0..8c6dbbc34 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock +++ b/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock @@ -8,6 +8,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -47,6 +97,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.7.1" @@ -213,12 +309,24 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.12.1" @@ -302,6 +410,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -471,6 +596,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strum" version = "0.26.3" @@ -486,7 +617,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -505,13 +636,14 @@ dependencies = [ ] [[package]] -name = "teamplayer-tui" +name = "teamplayer" version = "0.1.0" dependencies = [ "anyhow", + "clap", "crossterm", "futures", - "libc", + "nix", "ratatui", "tokio", ] @@ -573,6 +705,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml b/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml index 5f10c415b..313033333 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml +++ b/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "teamplayer-tui" +name = "teamplayer" version = "0.1.0" edition = "2021" [[bin]] -name = "teamplayer_tui" +name = "teamplayer" path = "src/main.rs" [dependencies] @@ -13,4 +13,5 @@ crossterm = { version = "0.27", features = ["event-stream"] } tokio = { version = "1", features = ["full"] } anyhow = "1" futures = "0.3" -libc = "0.2" +nix = { version = "0.27", features = ["signal", "process"] } +clap = { version = "~4.4", features = ["derive"] } diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/app.rs b/src/bitbots_misc/bitbots_bringup/tui/src/app.rs new file mode 100644 index 000000000..25943bada --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/tui/src/app.rs @@ -0,0 +1,401 @@ +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; + +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command, + sync::mpsc, +}; + +pub use crate::components::GlobalParams; +use crate::components::{COMPONENT_DEFS, FIELDNAME_DEFAULT_HW, FIELDNAME_DEFAULT_SIM}; + +// ─── State types ────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub enum ProcState { + Idle, + Running, + /// SIGTERM sent, waiting for exit + Stopping, + /// SIGTERM sent, will restart automatically when the process exits + Restarting, + Stopped, + Crashed, +} + +impl ProcState { + pub fn label(&self) -> &'static str { + match self { + ProcState::Idle => "IDLE", + ProcState::Running => "RUN ", + ProcState::Stopping => "STOP", + ProcState::Restarting => "↺ ", + ProcState::Stopped => "DONE", + ProcState::Crashed => "FAIL", + } + } +} + +pub struct ComponentState { + pub comp_idx: usize, + pub enabled: bool, + pub state: ProcState, + pub logs: Arc>>, + pub pids: Vec, + pub task_handles: Vec>, +} + +impl ComponentState { + pub fn new(comp_idx: usize, enabled: bool) -> Self { + Self { + comp_idx, + enabled, + state: ProcState::Idle, + logs: Arc::new(Mutex::new(VecDeque::with_capacity(2000))), + pids: Vec::new(), + task_handles: Vec::new(), + } + } +} + +pub enum AppMsg { + ProcessExited { key: String, code: i32 }, +} + +// ─── Screen / focus ─────────────────────────────────────────────────────────── + +#[derive(PartialEq, Clone)] +pub enum Screen { + Config, + Runtime, + Logs(usize), +} + +#[derive(Clone, PartialEq, Debug)] +pub enum ConfigFocus { + Zenoh, + Sim, + Fieldname, + DsdFile, + Component(usize), + Start, +} + +// ─── App ────────────────────────────────────────────────────────────────────── + +pub struct App { + pub screen: Screen, + pub params: GlobalParams, + pub components: Vec, + pub config_focus: ConfigFocus, + pub config_fieldname_input: String, + pub config_dsd_input: String, + pub runtime_selected: usize, + pub tick_count: u64, + pub msg_tx: mpsc::UnboundedSender, + pub msg_rx: mpsc::UnboundedReceiver, + pub log_scroll: usize, +} + +impl App { + pub fn new() -> Self { + let (msg_tx, msg_rx) = mpsc::unbounded_channel(); + let components: Vec = COMPONENT_DEFS + .iter() + .enumerate() + .map(|(i, def)| { + ComponentState::new(i, if def.sim_component { false } else { def.default_enabled }) + }) + .collect(); + + App { + screen: Screen::Config, + params: GlobalParams::default(), + components, + config_focus: ConfigFocus::Zenoh, + config_fieldname_input: FIELDNAME_DEFAULT_HW.to_string(), + config_dsd_input: "main.dsd".to_string(), + runtime_selected: 0, + tick_count: 0, + msg_tx, + msg_rx, + log_scroll: 0, + } + } + + pub fn apply_cli_presets( + &mut self, + sim: bool, + no_zenoh: bool, + fieldname: Option<&str>, + dsd_file: &str, + enable: &[String], + disable: &[String], + ) { + if sim { + self.params.sim = true; + self.apply_sim_toggle(); + } + if let Some(f) = fieldname { + self.params.fieldname = f.to_string(); + self.config_fieldname_input = f.to_string(); + } + self.params.dsd_file = dsd_file.to_string(); + self.config_dsd_input = dsd_file.to_string(); + + if no_zenoh { + if let Some(idx) = COMPONENT_DEFS.iter().position(|d| d.key == "zenoh") { + self.components[idx].enabled = false; + self.params.zenoh = false; + } + } + for key in enable { + if let Some(idx) = COMPONENT_DEFS.iter().position(|d| d.key == key.as_str()) { + self.components[idx].enabled = true; + } + } + for key in disable { + if let Some(idx) = COMPONENT_DEFS.iter().position(|d| d.key == key.as_str()) { + self.components[idx].enabled = false; + } + } + } + + pub fn toggleable_indices(&self) -> Vec { + COMPONENT_DEFS + .iter() + .enumerate() + .filter(|(_, d)| { + !d.infrastructure && !d.sim_component && !(d.hardware_only && self.params.sim) + }) + .map(|(i, _)| i) + .collect() + } + + pub fn config_focus_items(&self) -> Vec { + let mut items = vec![ + ConfigFocus::Zenoh, + ConfigFocus::Sim, + ConfigFocus::Fieldname, + ConfigFocus::DsdFile, + ]; + for i in 0..self.toggleable_indices().len() { + items.push(ConfigFocus::Component(i)); + } + items.push(ConfigFocus::Start); + items + } + + pub fn config_focus_next(&mut self) { + let items = self.config_focus_items(); + let pos = items.iter().position(|f| f == &self.config_focus).unwrap_or(0); + self.config_focus = items[(pos + 1) % items.len()].clone(); + } + + pub fn config_focus_prev(&mut self) { + let items = self.config_focus_items(); + let pos = items.iter().position(|f| f == &self.config_focus).unwrap_or(0); + self.config_focus = items[(pos + items.len() - 1) % items.len()].clone(); + } + + pub fn runtime_visible_indices(&self) -> Vec { + COMPONENT_DEFS + .iter() + .enumerate() + .filter(|(i, d)| { + if d.sim_component { + return self.params.sim; + } + if d.hardware_only && self.params.sim { + return false; + } + self.components[*i].enabled + }) + .map(|(i, _)| i) + .collect() + } + + pub fn apply_sim_toggle(&mut self) { + let sim = self.params.sim; + for (i, def) in COMPONENT_DEFS.iter().enumerate() { + if def.sim_component { + self.components[i].enabled = sim; + } else if def.hardware_only { + self.components[i].enabled = !sim && def.default_enabled; + } + } + self.config_fieldname_input = + if sim { FIELDNAME_DEFAULT_SIM } else { FIELDNAME_DEFAULT_HW }.to_string(); + self.params.fieldname = self.config_fieldname_input.clone(); + self.config_focus = ConfigFocus::Zenoh; + } + + pub async fn start_component(&mut self, comp_idx: usize) { + let def = &COMPONENT_DEFS[comp_idx]; + let cmds = (def.cmds)(&self.params); + let key = def.key.to_string(); + + let logs = self.components[comp_idx].logs.clone(); + self.components[comp_idx].state = ProcState::Running; + self.components[comp_idx].pids.clear(); + self.components[comp_idx].task_handles.clear(); + + for cmd_args in cmds { + if cmd_args.is_empty() { + continue; + } + let tx = self.msg_tx.clone(); + let logs = logs.clone(); + let key = key.clone(); + + let mut child = match Command::new(&cmd_args[0]) + .args(&cmd_args[1..]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .process_group(0) + .kill_on_drop(true) + .spawn() + { + Ok(c) => c, + Err(e) => { + let mut l = logs.lock().unwrap(); + l.push_back(format!("[ERROR] spawn failed: {e}")); + drop(l); + let _ = tx.send(AppMsg::ProcessExited { key, code: -1 }); + return; + } + }; + + if let Some(pid) = child.id() { + self.components[comp_idx].pids.push(pid); + } + + let stdout = child.stdout.take().map(BufReader::new); + let stderr = child.stderr.take().map(BufReader::new); + + let handle = tokio::spawn(async move { + async fn pump( + mut r: BufReader, + logs: Arc>>, + prefix: &str, + ) { + let mut line = String::new(); + while let Ok(n) = r.read_line(&mut line).await { + if n == 0 { + break; + } + let entry = format!("{}{}", prefix, line.trim_end_matches('\n')); + let mut l = logs.lock().unwrap(); + if l.len() >= 2000 { + l.pop_front(); + } + l.push_back(entry); + line.clear(); + } + } + if let Some(r) = stdout { + pump(r, logs.clone(), "").await; + } + if let Some(r) = stderr { + pump(r, logs.clone(), "[ERR] ").await; + } + let code = child + .wait() + .await + .map(|s| s.code().unwrap_or(-1)) + .unwrap_or(-1); + let _ = tx.send(AppMsg::ProcessExited { key, code }); + }); + self.components[comp_idx].task_handles.push(handle); + } + } + + /// Send SIGTERM to the component's direct child processes and mark as Stopping. + pub async fn stop_component(&mut self, comp_idx: usize) { + match self.components[comp_idx].state { + ProcState::Running => { + sigterm_pids(&self.components[comp_idx].pids); + self.components[comp_idx].state = ProcState::Stopping; + } + ProcState::Restarting => { + // Already sent SIGTERM — cancel the pending restart + self.components[comp_idx].state = ProcState::Stopping; + } + _ => {} + } + } + + /// Send SIGTERM and mark as Restarting — will auto-restart when the process exits. + pub fn restart_component(&mut self, comp_idx: usize) { + if self.components[comp_idx].state != ProcState::Running { + return; + } + sigterm_pids(&self.components[comp_idx].pids); + self.components[comp_idx].state = ProcState::Restarting; + } + + pub async fn stop_all(&mut self) { + for i in 0..self.components.len() { + self.stop_component(i).await; + } + } + + pub async fn start_all_enabled(&mut self) { + let enabled: Vec = self + .components + .iter() + .enumerate() + .filter(|(_, c)| c.enabled && c.state == ProcState::Idle) + .map(|(i, _)| i) + .collect(); + for i in enabled { + self.start_component(i).await; + } + } + + /// Drain the message queue and return the indices of components that need restarting. + pub fn drain_messages(&mut self) -> Vec { + let mut to_restart = Vec::new(); + while let Ok(msg) = self.msg_rx.try_recv() { + match msg { + AppMsg::ProcessExited { key, code } => { + if let Some((idx, comp)) = self + .components + .iter_mut() + .enumerate() + .find(|(_, c)| COMPONENT_DEFS[c.comp_idx].key == key) + { + match comp.state { + ProcState::Restarting => { + comp.pids.clear(); + comp.state = ProcState::Idle; + to_restart.push(idx); + } + ProcState::Stopping | ProcState::Running => { + comp.state = if code == 0 { + ProcState::Stopped + } else { + ProcState::Crashed + }; + comp.pids.clear(); + } + _ => {} + } + } + } + } + } + to_restart + } +} + +fn sigterm_pids(pids: &[u32]) { + use nix::sys::signal::{killpg, Signal}; + use nix::unistd::Pid; + for &pid in pids { + let _ = killpg(Pid::from_raw(pid as i32), Signal::SIGTERM); + } +} diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/components.rs b/src/bitbots_misc/bitbots_bringup/tui/src/components.rs new file mode 100644 index 000000000..0045e8cb0 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/tui/src/components.rs @@ -0,0 +1,324 @@ +// ─── Global params ──────────────────────────────────────────────────────────── + +pub const FIELDNAME_DEFAULT_HW: &str = "labor"; +pub const FIELDNAME_DEFAULT_SIM: &str = "hsl_kid"; + +#[derive(Clone, Debug)] +pub struct GlobalParams { + pub sim: bool, + pub zenoh: bool, + pub fieldname: String, + pub dsd_file: String, +} + +impl Default for GlobalParams { + fn default() -> Self { + Self { + sim: false, + zenoh: true, + fieldname: FIELDNAME_DEFAULT_HW.to_string(), + dsd_file: "main.dsd".to_string(), + } + } +} + +// ─── Component definitions ──────────────────────────────────────────────────── + +pub struct ComponentDef { + pub name: &'static str, + pub key: &'static str, + pub default_enabled: bool, + pub infrastructure: bool, + /// Only valid on real hardware — hidden/disabled in sim + pub hardware_only: bool, + /// Only shown/started when sim mode is active (e.g. the simulator itself) + pub sim_component: bool, + /// Returns the list of commands to spawn for this component + pub cmds: fn(&GlobalParams) -> Vec>, +} + +// ─── Command helpers ────────────────────────────────────────────────────────── + +pub fn ros2_launch(pkg: &str, file: &str, extra: &[&str]) -> Vec { + let mut v = vec![ + "ros2".into(), + "launch".into(), + pkg.into(), + file.into(), + ]; + v.extend(extra.iter().map(|s| s.to_string())); + v +} + +pub fn ros2_run(pkg: &str, exec: &str, extra: &[&str]) -> Vec { + let mut v = vec![ + "ros2".into(), + "run".into(), + pkg.into(), + exec.into(), + ]; + v.extend(extra.iter().map(|s| s.to_string())); + v +} + +fn sim(p: &GlobalParams) -> String { + format!("sim:={}", p.sim) +} + +// ─── Static component registry ──────────────────────────────────────────────── + +pub static COMPONENT_DEFS: &[ComponentDef] = &[ + // ── Infrastructure (always started) ────────────────────────────────────── + ComponentDef { + name: "Zenoh", + key: "zenoh", + default_enabled: true, + infrastructure: true, + hardware_only: false, + sim_component: false, + cmds: |_| vec![ros2_run("rmw_zenoh_cpp", "rmw_zenohd", &[])], + }, + ComponentDef { + name: "Param Blackboard", + key: "blackboard", + default_enabled: true, + infrastructure: true, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ros2_launch( + "bitbots_parameter_blackboard", + "parameter_blackboard.launch", + &[&sim(p), &format!("fieldname:={}", p.fieldname)], + )] + }, + }, + ComponentDef { + name: "Robot Description", + key: "robot_description", + default_enabled: true, + infrastructure: true, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ros2_launch( + "bitbots_robot_description", + "load_robot_description.launch", + &[&sim(p)], + )] + }, + }, + ComponentDef { + name: "Diagnostics", + key: "diagnostics", + default_enabled: true, + infrastructure: true, + hardware_only: false, + sim_component: false, + cmds: |_| vec![ros2_launch("bitbots_diagnostic", "aggregator.launch", &[])], + }, + ComponentDef { + name: "Simulator", + key: "simulator", + default_enabled: false, + infrastructure: true, + hardware_only: false, + sim_component: true, + cmds: |p| { + vec![ros2_launch( + "bitbots_bringup", + "mujoco_simulation.launch.py", + &[&sim(p)], + )] + }, + }, + // ── User-toggleable ─────────────────────────────────────────────────────── + ComponentDef { + name: "Lowlevel", + key: "lowlevel", + default_enabled: true, + infrastructure: false, + hardware_only: true, + sim_component: false, + cmds: |_| vec![ros2_launch("livelybot_bringup", "lowlevel.launch", &[])], + }, + ComponentDef { + name: "Motion", + key: "motion", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| vec![ros2_launch("bitbots_bringup", "motion.launch", &[&sim(p)])], + }, + ComponentDef { + name: "Game Controller", + key: "game_controller", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ros2_launch( + "game_controller_hsl", + "game_controller.launch", + &[ + &sim(p), + "use_parameter_blackboard:=true", + "parameter_blackboard_name:=parameter_blackboard", + "team_id_param_name:=team_id", + "bot_id_param_name:=bot_id", + ], + )] + }, + }, + ComponentDef { + name: "Vision", + key: "vision", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| vec![ros2_launch("bitbots_bringup", "vision.launch", &[&sim(p)])], + }, + ComponentDef { + name: "IPM", + key: "ipm", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| vec![ros2_launch("bitbots_ipm", "ipm.launch", &[&sim(p)])], + }, + ComponentDef { + name: "Localization", + key: "localization", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ros2_launch( + "bitbots_localization", + "localization.launch", + &[&sim(p)], + )] + }, + }, + ComponentDef { + name: "Path Planning", + key: "path_planning", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ros2_launch( + "bitbots_path_planning", + "path_planning.launch", + &[&sim(p)], + )] + }, + }, + ComponentDef { + name: "Behavior", + key: "behavior", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ros2_launch( + "bitbots_body_behavior", + "behavior.launch", + &[&sim(p), &format!("dsd_file:={}", p.dsd_file)], + )] + }, + }, + ComponentDef { + name: "Team Comm", + key: "teamcom", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ros2_launch( + "bitbots_team_communication", + "team_comm.launch", + &[&sim(p)], + )] + }, + }, + ComponentDef { + name: "World Model", + key: "world_model", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ + ros2_launch("bitbots_ball_filter", "ball_filter.launch", &[&sim(p)]), + ros2_launch("bitbots_robot_filter", "robot_filter.launch", &[&sim(p)]), + ] + }, + }, + ComponentDef { + name: "Whistle Det.", + key: "whistle_detector", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |_| { + vec![ros2_launch( + "bitbots_whistle_detector", + "whistle_detector.launch", + &[], + )] + }, + }, + ComponentDef { + name: "Audio", + key: "audio", + default_enabled: true, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |_| vec![ros2_launch("bitbots_bringup", "audio.launch", &[])], + }, + ComponentDef { + name: "TTS", + key: "tts", + default_enabled: false, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |_| vec![ros2_launch("bitbots_tts", "tts.launch", &[])], + }, + ComponentDef { + name: "Monitoring", + key: "monitoring", + default_enabled: false, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |_| vec![ros2_launch("bitbots_bringup", "monitoring.launch", &[])], + }, + ComponentDef { + name: "Recording", + key: "record", + default_enabled: false, + infrastructure: false, + hardware_only: false, + sim_component: false, + cmds: |p| { + vec![ros2_launch( + "bitbots_bringup", + "rosbag_record.launch.py", + &[&sim(p)], + )] + }, + }, +]; diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/input.rs b/src/bitbots_misc/bitbots_bringup/tui/src/input.rs new file mode 100644 index 000000000..356d019ba --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/tui/src/input.rs @@ -0,0 +1,164 @@ +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::app::{App, ConfigFocus, ProcState, Screen}; +use crate::components::COMPONENT_DEFS; + +pub async fn handle_config_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> bool { + match (&app.config_focus.clone(), key) { + // Navigation + (_, KeyCode::Tab) => { + if modifiers.contains(KeyModifiers::SHIFT) { + app.config_focus_prev(); + } else { + app.config_focus_next(); + } + } + (_, KeyCode::Down) => app.config_focus_next(), + (_, KeyCode::Up) => app.config_focus_prev(), + + // Zenoh toggle + (ConfigFocus::Zenoh, KeyCode::Char(' ') | KeyCode::Enter) => { + app.params.zenoh = !app.params.zenoh; + if let Some(idx) = COMPONENT_DEFS.iter().position(|d| d.key == "zenoh") { + app.components[idx].enabled = app.params.zenoh; + } + } + + // Sim toggle + (ConfigFocus::Sim, KeyCode::Char(' ') | KeyCode::Enter) => { + app.params.sim = !app.params.sim; + app.apply_sim_toggle(); + } + + // Fieldname text input + (ConfigFocus::Fieldname, KeyCode::Char(c)) => { + if !modifiers.contains(KeyModifiers::CONTROL) { + app.config_fieldname_input.push(c); + app.params.fieldname = app.config_fieldname_input.clone(); + } + } + (ConfigFocus::Fieldname, KeyCode::Backspace) => { + app.config_fieldname_input.pop(); + app.params.fieldname = app.config_fieldname_input.clone(); + } + + // DSD file text input + (ConfigFocus::DsdFile, KeyCode::Char(c)) => { + if !modifiers.contains(KeyModifiers::CONTROL) { + app.config_dsd_input.push(c); + app.params.dsd_file = app.config_dsd_input.clone(); + } + } + (ConfigFocus::DsdFile, KeyCode::Backspace) => { + app.config_dsd_input.pop(); + app.params.dsd_file = app.config_dsd_input.clone(); + } + + // Component checkbox toggle + (ConfigFocus::Component(fi), KeyCode::Char(' ') | KeyCode::Enter) => { + let fi = *fi; + let toggleables = app.toggleable_indices(); + if fi < toggleables.len() { + let comp_idx = toggleables[fi]; + app.components[comp_idx].enabled = !app.components[comp_idx].enabled; + } + } + + // Start / Continue button + (ConfigFocus::Start, KeyCode::Enter | KeyCode::Char(' ')) => { + app.params.fieldname = app.config_fieldname_input.clone(); + app.params.dsd_file = app.config_dsd_input.clone(); + app.screen = Screen::Runtime; + app.start_all_enabled().await; + } + + // Quit + (_, KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc) => return true, + + _ => {} + } + false +} + +pub async fn handle_runtime_key(app: &mut App, key: KeyCode, _modifiers: KeyModifiers) -> bool { + let visible = app.runtime_visible_indices(); + let sel = app.runtime_selected.min(visible.len().saturating_sub(1)); + let comp_idx = visible.get(sel).copied(); + + match key { + KeyCode::Down | KeyCode::Char('j') => { + app.runtime_selected = (sel + 1).min(visible.len().saturating_sub(1)); + } + KeyCode::Up | KeyCode::Char('k') => { + app.runtime_selected = sel.saturating_sub(1); + } + + KeyCode::Char('s') => { + if let Some(idx) = comp_idx { + match app.components[idx].state { + ProcState::Running | ProcState::Stopping | ProcState::Restarting => { + app.stop_component(idx).await; + } + _ => { + app.components[idx].state = ProcState::Idle; + app.start_component(idx).await; + } + } + } + } + + KeyCode::Char('r') => { + if let Some(idx) = comp_idx { + match app.components[idx].state { + ProcState::Running => app.restart_component(idx), + ProcState::Stopping | ProcState::Restarting => {} // already terminating + _ => app.start_component(idx).await, + } + } + } + + KeyCode::Char('l') => { + if let Some(idx) = comp_idx { + app.log_scroll = app.components[idx].logs.lock().unwrap().len().saturating_sub(1); + app.screen = Screen::Logs(idx); + } + } + + KeyCode::Char('a') => app.start_all_enabled().await, + KeyCode::Char('x') => app.stop_all().await, + KeyCode::Esc => app.screen = Screen::Config, + KeyCode::Char('q') | KeyCode::Char('Q') => return true, + _ => {} + } + false +} + +pub async fn handle_logs_key( + app: &mut App, + key: KeyCode, + _modifiers: KeyModifiers, + comp_idx: usize, +) -> bool { + let log_len = app.components[comp_idx].logs.lock().unwrap().len(); + match key { + KeyCode::Down | KeyCode::Char('j') => { + app.log_scroll = (app.log_scroll + 1).min(log_len.saturating_sub(1)); + } + KeyCode::Up | KeyCode::Char('k') => { + app.log_scroll = app.log_scroll.saturating_sub(1); + } + KeyCode::PageDown => { + app.log_scroll = (app.log_scroll + 20).min(log_len.saturating_sub(1)); + } + KeyCode::PageUp => { + app.log_scroll = app.log_scroll.saturating_sub(20); + } + KeyCode::Char('G') => app.log_scroll = log_len.saturating_sub(1), + KeyCode::Char('g') => app.log_scroll = 0, + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('l') => { + app.screen = Screen::Runtime; + } + _ => {} + } + false +} diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/main.rs b/src/bitbots_misc/bitbots_bringup/tui/src/main.rs index 08af36822..ef259cb68 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/src/main.rs +++ b/src/bitbots_misc/bitbots_bringup/tui/src/main.rs @@ -1,1211 +1,70 @@ -use std::{ - collections::VecDeque, - sync::{Arc, Mutex}, - time::Duration, -}; +mod app; +mod components; +mod input; +mod ui; use anyhow::Result; +use clap::Parser; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyModifiers}, + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use futures::StreamExt; -use ratatui::{ - backend::CrosstermBackend, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, - Frame, Terminal, -}; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::Command, - sync::mpsc, -}; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum ProcState { - Idle, - Running, - Stopping, - Stopped, - Crashed, -} - -impl ProcState { - fn label(&self) -> &'static str { - match self { - ProcState::Idle => "IDLE", - ProcState::Running => "RUN ", - ProcState::Stopping => "STOP", - ProcState::Stopped => "DONE", - ProcState::Crashed => "FAIL", - } - } - fn style(&self) -> Style { - match self { - ProcState::Idle => Style::default().fg(Color::DarkGray), - ProcState::Running => Style::default().fg(Color::Green), - ProcState::Stopping => Style::default().fg(Color::Yellow), - ProcState::Stopped => Style::default().fg(Color::Blue), - ProcState::Crashed => Style::default().fg(Color::Red), - } - } -} - -#[derive(Clone, Debug)] -struct GlobalParams { +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::time::Duration; +use tokio::time; + +use app::{App, ProcState, Screen}; +use components::COMPONENT_DEFS; + +// ─── CLI ────────────────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command( + name = "teamplayer", + about = "Bit-Bots teamplayer launcher — interactive TUI or headless mode", + long_about = None, +)] +struct Cli { + /// Run without TUI: start all enabled components and block until Ctrl-C + #[arg(long)] + headless: bool, + + /// Enable simulation mode (also enables the simulator, disables hardware-only components) + #[arg(long)] sim: bool, - zenoh: bool, - fieldname: String, - dsd_file: String, -} - -impl Default for GlobalParams { - fn default() -> Self { - Self { - sim: false, - zenoh: true, - fieldname: String::new(), - dsd_file: "main.dsd".to_string(), - } - } -} - -struct ComponentDef { - name: &'static str, - key: &'static str, - default_enabled: bool, - infrastructure: bool, - /// Component is only valid on real hardware — hidden/disabled when sim=true - hardware_only: bool, - cmds: fn(&GlobalParams) -> Vec>, -} - -struct ComponentState { - def_idx: usize, - enabled: bool, - state: ProcState, - logs: Arc>>, - pids: Vec, - task_handles: Vec>, -} - -impl ComponentState { - fn new(def_idx: usize, enabled: bool) -> Self { - Self { - def_idx, - enabled, - state: ProcState::Idle, - logs: Arc::new(Mutex::new(VecDeque::with_capacity(2000))), - pids: Vec::new(), - task_handles: Vec::new(), - } - } -} - -enum AppMsg { - ProcessExited { key: String, code: i32 }, -} - -// ─── Component definitions ──────────────────────────────────────────────────── - -fn make_ros2_launch(pkg: &str, file: &str, extra: &[&str]) -> Vec { - let mut cmd = vec![ - "ros2".to_string(), - "launch".to_string(), - pkg.to_string(), - file.to_string(), - ]; - for e in extra { - cmd.push(e.to_string()); - } - cmd -} - -fn make_ros2_run(pkg: &str, exec: &str, extra: &[&str]) -> Vec { - let mut cmd = vec![ - "ros2".to_string(), - "run".to_string(), - pkg.to_string(), - exec.to_string(), - ]; - for e in extra { - cmd.push(e.to_string()); - } - cmd -} - -fn sim_arg(params: &GlobalParams) -> String { - format!("sim:={}", params.sim) -} - -static COMPONENT_DEFS: &[ComponentDef] = &[ - // ── Infrastructure (always started) ── - ComponentDef { - name: "Zenoh", - key: "zenoh", - default_enabled: true, - infrastructure: true, - hardware_only: false, - cmds: |_p| vec![make_ros2_run("rmw_zenoh_cpp", "rmw_zenohd", &[])], - }, - ComponentDef { - name: "Param Blackboard", - key: "blackboard", - default_enabled: true, - infrastructure: true, - hardware_only: false, - cmds: |_p| { - vec![make_ros2_run( - "parameter_blackboard", - "parameter_blackboard", - &["--ros-args", "-p", "use_sim_time:=false"], - )] - }, - }, - ComponentDef { - name: "Robot Description", - key: "robot_description", - default_enabled: true, - infrastructure: true, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "bitbots_bringup", - "robot_description.launch", - &[&sim_arg(p)], - )] - }, - }, - ComponentDef { - name: "Diagnostics", - key: "diagnostics", - default_enabled: true, - infrastructure: true, - hardware_only: false, - cmds: |_p| vec![make_ros2_launch("bitbots_bringup", "diagnostics.launch", &[])], - }, - ComponentDef { - name: "Simulator", - key: "simulator", - default_enabled: false, - infrastructure: true, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "bitbots_bringup", - "mujoco_simulation.launch.py", - &[&sim_arg(p)], - )] - }, - }, - // ── User-toggleable ── - ComponentDef { - name: "Lowlevel", - key: "lowlevel", - default_enabled: true, - infrastructure: false, - hardware_only: true, // hardware interface — never in sim - cmds: |_p| vec![make_ros2_launch("livelybot_bringup", "lowlevel.launch", &[])], - }, - ComponentDef { - name: "Motion", - key: "motion", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "bitbots_bringup", - "motion.launch", - &[&sim_arg(p)], - )] - }, - }, - ComponentDef { - name: "Game Controller", - key: "game_controller", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "game_controller_hsl", - "game_controller.launch", - &[ - &sim_arg(p), - "use_parameter_blackboard:=true", - "parameter_blackboard_name:=parameter_blackboard", - "team_id_param_name:=team_id", - "bot_id_param_name:=bot_id", - ], - )] - }, - }, - ComponentDef { - name: "Vision", - key: "vision", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch("bitbots_bringup", "vision.launch", &[&sim_arg(p)])] - }, - }, - ComponentDef { - name: "IPM", - key: "ipm", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch("bitbots_ipm", "ipm.launch", &[&sim_arg(p)])] - }, - }, - ComponentDef { - name: "Localization", - key: "localization", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "bitbots_localization", - "localization.launch", - &[&sim_arg(p)], - )] - }, - }, - ComponentDef { - name: "Path Planning", - key: "path_planning", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "bitbots_path_planning", - "path_planning.launch", - &[&sim_arg(p)], - )] - }, - }, - ComponentDef { - name: "Behavior", - key: "behavior", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "bitbots_body_behavior", - "behavior.launch", - &[&sim_arg(p), &format!("dsd_file:={}", p.dsd_file)], - )] - }, - }, - ComponentDef { - name: "Team Comm", - key: "teamcom", - default_enabled: false, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "bitbots_team_communication", - "team_comm.launch", - &[&sim_arg(p)], - )] - }, - }, - ComponentDef { - name: "World Model", - key: "world_model", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![ - make_ros2_launch("bitbots_ball_filter", "ball_filter.launch", &[&sim_arg(p)]), - make_ros2_launch("bitbots_robot_filter", "robot_filter.launch", &[&sim_arg(p)]), - ] - }, - }, - ComponentDef { - name: "Whistle Det.", - key: "whistle_detector", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |_p| { - vec![make_ros2_launch( - "bitbots_whistle_detector", - "whistle_detector.launch", - &[], - )] - }, - }, - ComponentDef { - name: "Audio", - key: "audio", - default_enabled: true, - infrastructure: false, - hardware_only: false, - cmds: |_p| vec![make_ros2_launch("bitbots_bringup", "audio.launch", &[])], - }, - ComponentDef { - name: "TTS", - key: "tts", - default_enabled: false, - infrastructure: false, - hardware_only: false, - cmds: |_p| vec![make_ros2_launch("bitbots_bringup", "tts.launch", &[])], - }, - ComponentDef { - name: "Monitoring", - key: "monitoring", - default_enabled: false, - infrastructure: false, - hardware_only: false, - cmds: |_p| vec![make_ros2_launch("bitbots_bringup", "monitoring.launch", &[])], - }, - ComponentDef { - name: "Recording", - key: "record", - default_enabled: false, - infrastructure: false, - hardware_only: false, - cmds: |p| { - vec![make_ros2_launch( - "bitbots_bringup", - "record.launch", - &[&format!("fieldname:={}", p.fieldname)], - )] - }, - }, -]; - -// ─── Screens ────────────────────────────────────────────────────────────────── - -#[derive(PartialEq, Clone)] -enum Screen { - Config, - Runtime, - Logs(usize), // component index -} - -// Config screen focus items -#[derive(Clone, PartialEq, Debug)] -enum ConfigFocus { - Zenoh, - Sim, - Fieldname, - DsdFile, - Component(usize), // index into toggleable components list - Start, -} - -// ─── App state ──────────────────────────────────────────────────────────────── - -struct App { - screen: Screen, - params: GlobalParams, - components: Vec, - config_focus: ConfigFocus, - config_fieldname_input: String, - config_dsd_input: String, - runtime_selected: usize, - tick_count: u64, - msg_tx: mpsc::UnboundedSender, - msg_rx: mpsc::UnboundedReceiver, - log_scroll: usize, -} - -impl App { - fn new() -> Self { - let (msg_tx, msg_rx) = mpsc::unbounded_channel(); - let mut components: Vec = COMPONENT_DEFS - .iter() - .enumerate() - .map(|(i, def)| ComponentState::new(i, def.default_enabled)) - .collect(); - - // Simulator is only enabled when sim=true - let sim_idx = COMPONENT_DEFS.iter().position(|d| d.key == "simulator").unwrap(); - components[sim_idx].enabled = false; - - App { - screen: Screen::Config, - params: GlobalParams::default(), - components, - config_focus: ConfigFocus::Zenoh, - config_fieldname_input: String::new(), - config_dsd_input: "main.dsd".to_string(), - runtime_selected: 0, - tick_count: 0, - msg_tx, - msg_rx, - log_scroll: 0, - } - } - - fn toggleable_indices(&self) -> Vec { - COMPONENT_DEFS - .iter() - .enumerate() - .filter(|(_, d)| !d.infrastructure && !(d.hardware_only && self.params.sim)) - .map(|(i, _)| i) - .collect() - } - - fn config_focus_items(&self) -> Vec { - let mut items = vec![ConfigFocus::Zenoh, ConfigFocus::Sim, ConfigFocus::Fieldname, ConfigFocus::DsdFile]; - let toggleables = self.toggleable_indices(); - for i in 0..toggleables.len() { - items.push(ConfigFocus::Component(i)); - } - items.push(ConfigFocus::Start); - items - } - - fn config_focus_next(&mut self) { - let items = self.config_focus_items(); - let pos = items.iter().position(|f| f == &self.config_focus).unwrap_or(0); - self.config_focus = items[(pos + 1) % items.len()].clone(); - } - - fn config_focus_prev(&mut self) { - let items = self.config_focus_items(); - let pos = items.iter().position(|f| f == &self.config_focus).unwrap_or(0); - self.config_focus = items[(pos + items.len() - 1) % items.len()].clone(); - } - - fn runtime_visible_indices(&self) -> Vec { - COMPONENT_DEFS - .iter() - .enumerate() - .filter(|(i, d)| { - // Simulator row only appears when sim=true - if d.key == "simulator" { - return self.params.sim; - } - // Hardware-only components are never shown in sim - if d.hardware_only && self.params.sim { - return false; - } - self.components[*i].enabled - }) - .map(|(i, _)| i) - .collect() - } - - async fn start_component(&mut self, comp_idx: usize) { - let def = &COMPONENT_DEFS[comp_idx]; - let cmds = (def.cmds)(&self.params); - let key = def.key.to_string(); - - // Extract what we need before the loop so we don't hold &mut self.components - let logs = self.components[comp_idx].logs.clone(); - self.components[comp_idx].state = ProcState::Running; - self.components[comp_idx].pids.clear(); - self.components[comp_idx].task_handles.clear(); - - for cmd_args in cmds { - if cmd_args.is_empty() { - continue; - } - let tx = self.msg_tx.clone(); - let logs = logs.clone(); - let key = key.clone(); - - let mut child = match Command::new(&cmd_args[0]) - .args(&cmd_args[1..]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - // New process group so we can kill the entire ros2 launch tree - .process_group(0) - .kill_on_drop(true) - .spawn() - { - Ok(c) => c, - Err(e) => { - let mut l = logs.lock().unwrap(); - l.push_back(format!("[ERROR] spawn failed: {e}")); - drop(l); - let _ = tx.send(AppMsg::ProcessExited { key, code: -1 }); - return; - } - }; - - if let Some(pid) = child.id() { - self.components[comp_idx].pids.push(pid); - } - - let stdout = child.stdout.take().map(BufReader::new); - let stderr = child.stderr.take().map(BufReader::new); - - let handle = tokio::spawn(async move { - // Interleave stdout+stderr into log buffer - if let Some(mut reader) = stdout { - let mut line = String::new(); - while let Ok(n) = reader.read_line(&mut line).await { - if n == 0 { break; } - let trimmed = line.trim_end_matches('\n').to_string(); - let mut l = logs.lock().unwrap(); - if l.len() >= 2000 { l.pop_front(); } - l.push_back(trimmed); - line.clear(); - } - } - if let Some(mut reader) = stderr { - let mut line = String::new(); - while let Ok(n) = reader.read_line(&mut line).await { - if n == 0 { break; } - let trimmed = format!("[ERR] {}", line.trim_end_matches('\n')); - let mut l = logs.lock().unwrap(); - if l.len() >= 2000 { l.pop_front(); } - l.push_back(trimmed); - line.clear(); - } - } - let code = child.wait().await.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1); - let _ = tx.send(AppMsg::ProcessExited { key, code }); - }); - self.components[comp_idx].task_handles.push(handle); - } - } - - async fn stop_component(&mut self, comp_idx: usize) { - let comp = &mut self.components[comp_idx]; - if comp.state != ProcState::Running { - return; - } - comp.state = ProcState::Stopping; - let pids = comp.pids.clone(); - for &pid in &pids { - unsafe { - // Negative PID = send to entire process group (kills ros2 launch children too) - libc::kill(-(pid as libc::pid_t), libc::SIGTERM); - } - } - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(5)).await; - for pid in pids { - unsafe { - libc::kill(-(pid as libc::pid_t), libc::SIGKILL); - } - } - }); - } - - async fn stop_all(&mut self) { - let indices: Vec = (0..self.components.len()).collect(); - for i in indices { - if self.components[i].state == ProcState::Running { - self.stop_component(i).await; - } - } - } - - async fn start_all_enabled(&mut self) { - let enabled: Vec = self.components - .iter() - .enumerate() - .filter(|(_, c)| c.enabled && c.state == ProcState::Idle) - .map(|(i, _)| i) - .collect(); - for i in enabled { - self.start_component(i).await; - } - } - - fn drain_messages(&mut self) { - while let Ok(msg) = self.msg_rx.try_recv() { - match msg { - AppMsg::ProcessExited { key, code } => { - if let Some(comp) = self.components.iter_mut().find(|c| { - COMPONENT_DEFS[c.def_idx].key == key - }) { - if comp.state == ProcState::Stopping || comp.state == ProcState::Running { - comp.state = if code == 0 { - ProcState::Stopped - } else { - ProcState::Crashed - }; - comp.pids.clear(); - } - } - } - } - } - } -} - -// ─── Rendering ──────────────────────────────────────────────────────────────── - -const BANNER: &str = r#" - ██████╗ ██╗████████╗ ██████╗ ██████╗ ████████╗███████╗ - ██╔══██╗██║╚══██╔══╝ ██╔══██╗██╔═══██╗╚══██╔══╝██╔════╝ - ██████╔╝██║ ██║ ██████╔╝██║ ██║ ██║ ███████╗ - ██╔══██╗██║ ██║ ██╔══██╗██║ ██║ ██║ ╚════██║ - ██████╔╝██║ ██║ ██████╔╝╚██████╔╝ ██║ ███████║ - ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ - Teamplayer Launcher -"#; - -fn spinner_char(tick: u64) -> char { - const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - FRAMES[(tick as usize) % FRAMES.len()] -} - -fn draw(f: &mut Frame, app: &App) { - match &app.screen { - Screen::Config => draw_config(f, app), - Screen::Runtime => draw_runtime(f, app), - Screen::Logs(idx) => draw_logs(f, app, *idx), - } -} - -fn draw_config(f: &mut Frame, app: &App) { - let area = f.size(); - - let banner_lines = BANNER.lines().count() as u16; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(banner_lines), - Constraint::Length(3), // global flags bar - Constraint::Min(0), // component grid - Constraint::Length(3), // Start/Continue button - Constraint::Length(1), // footer - ]) - .split(area); - - let banner = Paragraph::new(BANNER) - .style(Style::default().fg(Color::Cyan)) - .alignment(Alignment::Center); - f.render_widget(banner, chunks[0]); - - draw_config_flags(f, app, chunks[1]); - draw_config_components(f, app, chunks[2]); - draw_start_button(f, app, chunks[3]); - - let footer = Paragraph::new( - " Tab/↑↓: navigate Space/Enter: toggle/select q: quit", - ) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(footer, chunks[4]); -} - -fn draw_start_button(f: &mut Frame, app: &App, area: Rect) { - let focused = app.config_focus == ConfigFocus::Start; - let has_running = app.components.iter().any(|c| c.state == ProcState::Running); - - let label = if has_running { " Continue → " } else { " Launch → " }; - - let (border_style, text_style) = if focused { - ( - Style::default().fg(Color::Yellow), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - } else { - ( - Style::default().fg(Color::Green), - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), - ) - }; - - let button = Paragraph::new(label) - .block(Block::default().borders(Borders::ALL).border_style(border_style)) - .style(text_style) - .alignment(Alignment::Center); - f.render_widget(button, area); -} - -fn draw_config_flags(f: &mut Frame, app: &App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(18), // Zenoh - Constraint::Length(18), // Sim - Constraint::Min(20), // Fieldname - Constraint::Min(20), // DSD file - ]) - .split(area); - - let zenoh_focused = app.config_focus == ConfigFocus::Zenoh; - let sim_focused = app.config_focus == ConfigFocus::Sim; - let field_focused = app.config_focus == ConfigFocus::Fieldname; - let dsd_focused = app.config_focus == ConfigFocus::DsdFile; - - let zenoh_style = if zenoh_focused { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - let zenoh_text = if app.params.zenoh { "[✓] Zenoh" } else { "[ ] Zenoh" }; - let zenoh_p = Paragraph::new(zenoh_text) - .block(Block::default().borders(Borders::ALL).border_style(if zenoh_focused { - Style::default().fg(Color::Yellow) - } else { - Style::default() - })) - .style(zenoh_style); - f.render_widget(zenoh_p, chunks[0]); - - let sim_style = if sim_focused { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - let sim_text = if app.params.sim { "[✓] Simulator" } else { "[ ] Simulator" }; - let sim_p = Paragraph::new(sim_text) - .block(Block::default().borders(Borders::ALL).border_style(if sim_focused { - Style::default().fg(Color::Yellow) - } else { - Style::default() - })) - .style(sim_style); - f.render_widget(sim_p, chunks[1]); - - let field_p = Paragraph::new(app.config_fieldname_input.as_str()) - .block(Block::default().borders(Borders::ALL).title("Fieldname").border_style(if field_focused { - Style::default().fg(Color::Yellow) - } else { - Style::default() - })); - f.render_widget(field_p, chunks[2]); - let dsd_p = Paragraph::new(app.config_dsd_input.as_str()) - .block(Block::default().borders(Borders::ALL).title("DSD File").border_style(if dsd_focused { - Style::default().fg(Color::Yellow) - } else { - Style::default() - })); - f.render_widget(dsd_p, chunks[3]); -} - -fn draw_config_components(f: &mut Frame, app: &App, area: Rect) { - let toggleables = app.toggleable_indices(); - - // Split into two columns - let col_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); - - let left_count = (toggleables.len() + 1) / 2; - let (left_indices, right_indices) = toggleables.split_at(left_count); - - draw_component_column(f, app, col_chunks[0], left_indices, 0); - draw_component_column(f, app, col_chunks[1], right_indices, left_indices.len()); -} - -fn draw_component_column( - f: &mut Frame, - app: &App, - area: Rect, - comp_indices: &[usize], - focus_offset: usize, -) { - let rows: Vec = comp_indices - .iter() - .map(|_| Constraint::Length(1)) - .collect(); - if rows.is_empty() { - return; - } - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(rows) - .split(area); - - for (local_i, &comp_idx) in comp_indices.iter().enumerate() { - let focus_i = focus_offset + local_i; - let focused = app.config_focus == ConfigFocus::Component(focus_i); - let comp = &app.components[comp_idx]; - let def = &COMPONENT_DEFS[comp_idx]; - - let check = if comp.enabled { "✓" } else { " " }; - let text = format!("[{}] {}", check, def.name); - - let style = if focused { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else if comp.enabled { - Style::default().fg(Color::White) - } else { - Style::default().fg(Color::DarkGray) - }; - - let p = Paragraph::new(text).style(style); - if local_i < chunks.len() { - f.render_widget(p, chunks[local_i]); - } - } - - // Start button at bottom of right column - // (handled separately in draw_config) -} - -fn draw_runtime(f: &mut Frame, app: &App) { - let area = f.size(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // header - Constraint::Min(0), // component list - Constraint::Length(1), // footer - ]) - .split(area); - - // Header - draw_runtime_header(f, app, chunks[0]); - - // Component list - draw_runtime_list(f, app, chunks[1]); - - // Footer - let footer = Paragraph::new( - " ↑↓: select s: start/stop r: restart l: logs a: start all x: stop all q: quit", - ) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(footer, chunks[2]); -} - -fn draw_runtime_header(f: &mut Frame, app: &App, area: Rect) { - let running = app.components.iter().filter(|c| c.state == ProcState::Running).count(); - let total_enabled = app.components.iter().filter(|c| c.enabled).count(); - - let title = format!(" Bit-Bots Teamplayer [{running}/{total_enabled} running]"); - let sim_badge = if app.params.sim { " SIM " } else { "" }; - let text = format!("{title}{sim_badge}"); - - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); - f.render_widget(p, area); -} - -fn draw_runtime_list(f: &mut Frame, app: &App, area: Rect) { - let visible = app.runtime_visible_indices(); - if visible.is_empty() { - let p = Paragraph::new("No components enabled.").alignment(Alignment::Center); - f.render_widget(p, area); - return; - } - - let row_height = 1u16; - let rows: Vec = visible.iter().map(|_| Constraint::Length(row_height)).collect(); - let row_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(rows) - .split(area); - - for (vis_i, &comp_idx) in visible.iter().enumerate() { - let selected = app.runtime_selected == vis_i; - let comp = &app.components[comp_idx]; - let def = &COMPONENT_DEFS[comp_idx]; - - if vis_i < row_chunks.len() { - draw_runtime_row(f, app, row_chunks[vis_i], comp, def, selected); - } - } -} + /// Override the field name [default: labor, or hsl_kid in sim] + #[arg(long, value_name = "NAME")] + fieldname: Option, -fn draw_runtime_row( - f: &mut Frame, - app: &App, - area: Rect, - comp: &ComponentState, - def: &ComponentDef, - selected: bool, -) { - let row_style = if selected { - Style::default().bg(Color::DarkGray) - } else { - Style::default() - }; - - let spinner = match comp.state { - ProcState::Running => format!("{} ", spinner_char(app.tick_count)), - ProcState::Stopping => "… ".to_string(), - _ => " ".to_string(), - }; - - let state_label = comp.state.label(); - let state_style = comp.state.style(); - - let infra_marker = if def.infrastructure { "·" } else { " " }; - - let line = Line::from(vec![ - Span::styled(format!("{infra_marker}"), Style::default().fg(Color::DarkGray)), - Span::styled(spinner, Style::default().fg(Color::Green)), - Span::styled( - format!("[{state_label}]"), - state_style.add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - format!("{:<16}", def.name), - if selected { - Style::default().fg(Color::White).add_modifier(Modifier::BOLD) - } else { - Style::default() - }, - ), - Span::raw(" "), - Span::styled("s", Style::default().fg(Color::Yellow)), - Span::raw(":start/stop "), - Span::styled("r", Style::default().fg(Color::Yellow)), - Span::raw(":restart "), - Span::styled("l", Style::default().fg(Color::Yellow)), - Span::raw(":logs"), - ]); - - let p = Paragraph::new(line).style(row_style); - f.render_widget(p, area); -} - -fn draw_logs(f: &mut Frame, app: &App, comp_idx: usize) { - let area = f.size(); - let comp = &app.components[comp_idx]; - let def = &COMPONENT_DEFS[comp_idx]; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)]) - .split(area); - - let title = format!(" Logs: {} ", def.name); - let header = Paragraph::new(title) - .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); - f.render_widget(header, chunks[0]); - - let log_area = chunks[1]; - let log_height = log_area.height as usize; - - let lines: Vec = { - let logs = comp.logs.lock().unwrap(); - let total = logs.len(); - let scroll = app.log_scroll.min(if total > log_height { total - log_height } else { 0 }); - logs.iter() - .skip(scroll) - .take(log_height) - .map(|s| Line::from(s.clone())) - .collect() - }; - - let log_p = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::White)); - f.render_widget(log_p, log_area); - - let footer = Paragraph::new(" ↑↓/PgUp/PgDn: scroll Esc/q: back G: jump to end") - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(footer, chunks[2]); -} - -// ─── Input handling ─────────────────────────────────────────────────────────── - -async fn handle_config_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> bool { - match (&app.config_focus.clone(), key) { - // Navigation - (_, KeyCode::Tab) => { - if modifiers.contains(KeyModifiers::SHIFT) { - app.config_focus_prev(); - } else { - app.config_focus_next(); - } - } - (_, KeyCode::Down) => app.config_focus_next(), - (_, KeyCode::Up) => app.config_focus_prev(), - - // Zenoh toggle - (ConfigFocus::Zenoh, KeyCode::Char(' ') | KeyCode::Enter) => { - app.params.zenoh = !app.params.zenoh; - let zenoh_idx = COMPONENT_DEFS.iter().position(|d| d.key == "zenoh").unwrap(); - app.components[zenoh_idx].enabled = app.params.zenoh; - } - - // Sim toggle - (ConfigFocus::Sim, KeyCode::Char(' ') | KeyCode::Enter) => { - app.params.sim = !app.params.sim; - let sim = app.params.sim; - for (i, def) in COMPONENT_DEFS.iter().enumerate() { - if def.key == "simulator" { - app.components[i].enabled = sim; - } else if def.hardware_only { - // Auto-disable HW-only components in sim, restore default otherwise - app.components[i].enabled = !sim && def.default_enabled; - } - } - // Reset config focus if it now points at a hidden component - app.config_focus = ConfigFocus::Zenoh; - } - - // Fieldname text input - (ConfigFocus::Fieldname, KeyCode::Char(c)) => { - app.config_fieldname_input.push(c); - app.params.fieldname = app.config_fieldname_input.clone(); - } - (ConfigFocus::Fieldname, KeyCode::Backspace) => { - app.config_fieldname_input.pop(); - app.params.fieldname = app.config_fieldname_input.clone(); - } - - // DSD file text input - (ConfigFocus::DsdFile, KeyCode::Char(c)) => { - app.config_dsd_input.push(c); - app.params.dsd_file = app.config_dsd_input.clone(); - } - (ConfigFocus::DsdFile, KeyCode::Backspace) => { - app.config_dsd_input.pop(); - app.params.dsd_file = app.config_dsd_input.clone(); - } - - // Component checkbox toggle - (ConfigFocus::Component(fi), KeyCode::Char(' ') | KeyCode::Enter) => { - let fi = *fi; - let toggleables = app.toggleable_indices(); - if fi < toggleables.len() { - let comp_idx = toggleables[fi]; - app.components[comp_idx].enabled = !app.components[comp_idx].enabled; - } - } - - // Start button - (ConfigFocus::Start, KeyCode::Enter | KeyCode::Char(' ')) => { - // Transition to runtime - app.params.fieldname = app.config_fieldname_input.clone(); - app.params.dsd_file = app.config_dsd_input.clone(); - app.screen = Screen::Runtime; - app.start_all_enabled().await; - } - - // Quit - (_, KeyCode::Char('q') | KeyCode::Char('Q')) => { - return true; // signal quit - } - (_, KeyCode::Esc) => { - return true; - } + /// Override the behavior DSD file [default: main.dsd] + #[arg(long, value_name = "FILE", default_value = "main.dsd")] + dsd_file: String, - _ => {} - } - false -} + /// Disable the Zenoh daemon (useful when zenoh is already running externally) + #[arg(long)] + no_zenoh: bool, -async fn handle_runtime_key(app: &mut App, key: KeyCode, _modifiers: KeyModifiers) -> bool { - let visible = app.runtime_visible_indices(); - let sel = app.runtime_selected.min(visible.len().saturating_sub(1)); - let comp_idx = visible.get(sel).copied(); + /// Enable a component by key; may be specified multiple times. + /// Available keys: zenoh, blackboard, robot_description, diagnostics, + /// simulator, lowlevel, motion, game_controller, vision, ipm, localization, + /// path_planning, behavior, teamcom, world_model, whistle_detector, audio, + /// tts, monitoring, record + #[arg(long = "enable", value_name = "KEY")] + enable: Vec, - match key { - KeyCode::Down | KeyCode::Char('j') => { - app.runtime_selected = (sel + 1).min(visible.len().saturating_sub(1)); - } - KeyCode::Up | KeyCode::Char('k') => { - app.runtime_selected = sel.saturating_sub(1); - } - KeyCode::Char('s') => { - if let Some(idx) = comp_idx { - match app.components[idx].state { - ProcState::Running | ProcState::Stopping => { - app.stop_component(idx).await; - } - ProcState::Idle | ProcState::Stopped | ProcState::Crashed => { - app.components[idx].state = ProcState::Idle; - app.start_component(idx).await; - } - } - } - } - KeyCode::Char('r') => { - if let Some(idx) = comp_idx { - app.stop_component(idx).await; - // Brief delay then restart — we set idle so it auto-starts - // Actually just start immediately; stop sends SIGTERM async - app.components[idx].state = ProcState::Idle; - app.start_component(idx).await; - } - } - KeyCode::Char('l') => { - if let Some(idx) = comp_idx { - app.log_scroll = usize::MAX; // jump to end - { - let logs = app.components[idx].logs.lock().unwrap(); - let len = logs.len(); - drop(logs); - app.log_scroll = len.saturating_sub(1); - } - app.screen = Screen::Logs(idx); - } - } - KeyCode::Char('a') => { - app.start_all_enabled().await; - } - KeyCode::Char('x') => { - app.stop_all().await; - } - KeyCode::Esc => { - // Back to config (ask for quit confirm instead) - app.screen = Screen::Config; - } - KeyCode::Char('q') | KeyCode::Char('Q') => { - return true; - } - _ => {} - } - false -} - -async fn handle_logs_key(app: &mut App, key: KeyCode, _modifiers: KeyModifiers, comp_idx: usize) -> bool { - let log_len = app.components[comp_idx].logs.lock().unwrap().len(); - match key { - KeyCode::Down | KeyCode::Char('j') => { - app.log_scroll = (app.log_scroll + 1).min(log_len.saturating_sub(1)); - } - KeyCode::Up | KeyCode::Char('k') => { - app.log_scroll = app.log_scroll.saturating_sub(1); - } - KeyCode::PageDown => { - app.log_scroll = (app.log_scroll + 20).min(log_len.saturating_sub(1)); - } - KeyCode::PageUp => { - app.log_scroll = app.log_scroll.saturating_sub(20); - } - KeyCode::Char('G') => { - app.log_scroll = log_len.saturating_sub(1); - } - KeyCode::Char('g') => { - app.log_scroll = 0; - } - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('l') => { - app.screen = Screen::Runtime; - } - _ => {} - } - false + /// Disable a component by key; may be specified multiple times + #[arg(long = "disable", value_name = "KEY")] + disable: Vec, } -// ─── Terminal cleanup helpers ───────────────────────────────────────────────── +// ─── Terminal helpers ───────────────────────────────────────────────────────── fn setup_panic_hook() { let original = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - // Best-effort cleanup — ignore errors let _ = disable_raw_mode(); let _ = execute!( std::io::stdout(), @@ -1225,59 +84,116 @@ fn cleanup_terminal() { ); } -// ─── Main ───────────────────────────────────────────────────────────────────── +// ─── Entry point ────────────────────────────────────────────────────────────── #[tokio::main] async fn main() -> Result<()> { - setup_panic_hook(); - - // Setup terminal - enable_raw_mode()?; - let mut stdout = std::io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; + let cli = Cli::parse(); let mut app = App::new(); - let mut event_stream = EventStream::new(); - let mut tick_interval = tokio::time::interval(Duration::from_millis(100)); + app.apply_cli_presets( + cli.sim, + cli.no_zenoh, + cli.fieldname.as_deref(), + &cli.dsd_file, + &cli.enable, + &cli.disable, + ); - let result = run_loop(&mut terminal, &mut app, &mut event_stream, &mut tick_interval).await; + let result = if cli.headless { + run_headless(&mut app).await + } else { + setup_panic_hook(); + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut event_stream = EventStream::new(); + let mut tick_interval = time::interval(Duration::from_millis(100)); + let r = run_tui(&mut terminal, &mut app, &mut event_stream, &mut tick_interval).await; + cleanup_terminal(); + r + }; - // Ensure all children are killed - app.stop_all().await; - // Give them a moment to receive SIGTERM - tokio::time::sleep(Duration::from_millis(300)).await; - // SIGKILL anything still alive (whole process group) - for comp in &app.components { - for &pid in &comp.pids { - unsafe { - libc::kill(-(pid as libc::pid_t), libc::SIGKILL); + // SIGTERM all remaining process groups on exit + { + use nix::sys::signal::{killpg, Signal}; + use nix::unistd::Pid; + for comp in &app.components { + for &pid in &comp.pids { + let _ = killpg(Pid::from_raw(pid as i32), Signal::SIGTERM); } } } - cleanup_terminal(); result } -async fn run_loop( +// ─── Headless mode ──────────────────────────────────────────────────────────── + +async fn run_headless(app: &mut App) -> Result<()> { + eprintln!("teamplayer [headless] — Ctrl-C to stop"); + eprintln!(); + + // Print what will be started + for comp in &app.components { + let def = &COMPONENT_DEFS[comp.comp_idx]; + if comp.enabled { + eprintln!(" [+] {}", def.name); + } + } + eprintln!(); + + app.start_all_enabled().await; + + let mut tick = time::interval(Duration::from_millis(500)); + loop { + tokio::select! { + _ = tick.tick() => { + let to_restart = app.drain_messages(); + for idx in to_restart { + app.start_component(idx).await; + } + let any_running = app.components.iter().any(|c| { + matches!(c.state, ProcState::Running | ProcState::Stopping) + }); + if !any_running { + eprintln!("All components have stopped."); + break; + } + } + _ = tokio::signal::ctrl_c() => { + eprintln!("\nInterrupted — stopping all components..."); + break; + } + } + } + Ok(()) +} + +// ─── TUI event loop ─────────────────────────────────────────────────────────── + +async fn run_tui( terminal: &mut Terminal>, app: &mut App, event_stream: &mut EventStream, - tick_interval: &mut tokio::time::Interval, + tick_interval: &mut time::Interval, ) -> Result<()> { loop { - terminal.draw(|f| draw(f, app))?; + terminal.draw(|f| ui::draw(f, app))?; tokio::select! { _ = tick_interval.tick() => { app.tick_count += 1; - app.drain_messages(); - // Auto-scroll logs to bottom if on log screen + let to_restart = app.drain_messages(); + for idx in to_restart { + app.start_component(idx).await; + } if let Screen::Logs(idx) = &app.screen { - let len = app.components[*idx].logs.lock().unwrap().len(); - // Only auto-scroll if near the bottom + let idx = *idx; + let len = app.components[idx].logs.lock().unwrap().len(); if app.log_scroll + 5 >= len.saturating_sub(1) { app.log_scroll = len.saturating_sub(1); } @@ -1285,18 +201,17 @@ async fn run_loop( } maybe_event = event_stream.next() => { - let Some(Ok(event)) = maybe_event else { break; }; - + let Some(Ok(event)) = maybe_event else { break }; if let Event::Key(key_event) = event { let quit = match app.screen.clone() { Screen::Config => { - handle_config_key(app, key_event.code, key_event.modifiers).await + input::handle_config_key(app, key_event.code, key_event.modifiers).await } Screen::Runtime => { - handle_runtime_key(app, key_event.code, key_event.modifiers).await + input::handle_runtime_key(app, key_event.code, key_event.modifiers).await } Screen::Logs(idx) => { - handle_logs_key(app, key_event.code, key_event.modifiers, idx).await + input::handle_logs_key(app, key_event.code, key_event.modifiers, idx).await } }; if quit { diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs b/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs new file mode 100644 index 000000000..9fae56445 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs @@ -0,0 +1,408 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::app::{App, ConfigFocus, ProcState, Screen}; +use crate::components::COMPONENT_DEFS; + +// ─── Banner ─────────────────────────────────────────────────────────────────── + +const BANNER: &str = r#" + ██████╗ ██╗████████╗ ██████╗ ██████╗ ████████╗███████╗ + ██╔══██╗██║╚══██╔══╝ ██╔══██╗██╔═══██╗╚══██╔══╝██╔════╝ + ██████╔╝██║ ██║ ██████╔╝██║ ██║ ██║ ███████╗ + ██╔══██╗██║ ██║ ██╔══██╗██║ ██║ ██║ ╚════██║ + ██████╔╝██║ ██║ ██████╔╝╚██████╔╝ ██║ ███████║ + ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ + Teamplayer Launcher +"#; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +pub fn proc_state_style(state: &ProcState) -> Style { + match state { + ProcState::Idle => Style::default().fg(Color::DarkGray), + ProcState::Running => Style::default().fg(Color::Green), + ProcState::Stopping => Style::default().fg(Color::Yellow), + ProcState::Restarting => Style::default().fg(Color::Cyan), + ProcState::Stopped => Style::default().fg(Color::Blue), + ProcState::Crashed => Style::default().fg(Color::Red), + } +} + +fn spinner_char(tick: u64) -> char { + const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + FRAMES[(tick as usize) % FRAMES.len()] +} + +// ─── Top-level dispatch ─────────────────────────────────────────────────────── + +pub fn draw(f: &mut Frame, app: &App) { + match &app.screen { + Screen::Config => draw_config(f, app), + Screen::Runtime => draw_runtime(f, app), + Screen::Logs(idx) => draw_logs(f, app, *idx), + } +} + +// ─── Config screen ──────────────────────────────────────────────────────────── + +pub fn draw_config(f: &mut Frame, app: &App) { + let area = f.size(); + let banner_lines = BANNER.lines().count() as u16; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(banner_lines), + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + f.render_widget( + Paragraph::new(BANNER) + .style(Style::default().fg(Color::Cyan)) + .alignment(Alignment::Center), + chunks[0], + ); + + draw_config_flags(f, app, chunks[1]); + draw_config_components(f, app, chunks[2]); + draw_start_button(f, app, chunks[3]); + + f.render_widget( + Paragraph::new(" Tab/↑↓: navigate Space/Enter: toggle/activate q: quit") + .style(Style::default().fg(Color::DarkGray)), + chunks[4], + ); +} + +fn draw_config_flags(f: &mut Frame, app: &App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(18), + Constraint::Length(18), + Constraint::Min(20), + Constraint::Min(20), + ]) + .split(area); + + let focus_border = |focused: bool| { + if focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + } + }; + let focus_text = |focused: bool| { + if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + } + }; + + let zenoh_focused = app.config_focus == ConfigFocus::Zenoh; + let sim_focused = app.config_focus == ConfigFocus::Sim; + let field_focused = app.config_focus == ConfigFocus::Fieldname; + let dsd_focused = app.config_focus == ConfigFocus::DsdFile; + + f.render_widget( + Paragraph::new(if app.params.zenoh { "[✓] Zenoh" } else { "[ ] Zenoh" }) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(focus_border(zenoh_focused)), + ) + .style(focus_text(zenoh_focused)), + chunks[0], + ); + + f.render_widget( + Paragraph::new(if app.params.sim { + "[✓] Simulator" + } else { + "[ ] Simulator" + }) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(focus_border(sim_focused)), + ) + .style(focus_text(sim_focused)), + chunks[1], + ); + + f.render_widget( + Paragraph::new(app.config_fieldname_input.as_str()).block( + Block::default() + .borders(Borders::ALL) + .title("Fieldname") + .border_style(focus_border(field_focused)), + ), + chunks[2], + ); + + f.render_widget( + Paragraph::new(app.config_dsd_input.as_str()).block( + Block::default() + .borders(Borders::ALL) + .title("DSD File") + .border_style(focus_border(dsd_focused)), + ), + chunks[3], + ); +} + +fn draw_config_components(f: &mut Frame, app: &App, area: Rect) { + let toggleables = app.toggleable_indices(); + let col_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + let left_count = (toggleables.len() + 1) / 2; + let (left, right) = toggleables.split_at(left_count); + draw_component_column(f, app, col_chunks[0], left, 0); + draw_component_column(f, app, col_chunks[1], right, left.len()); +} + +fn draw_component_column( + f: &mut Frame, + app: &App, + area: Rect, + comp_indices: &[usize], + focus_offset: usize, +) { + if comp_indices.is_empty() { + return; + } + let rows: Vec = comp_indices.iter().map(|_| Constraint::Length(1)).collect(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(rows) + .split(area); + + for (local_i, &comp_idx) in comp_indices.iter().enumerate() { + let focused = app.config_focus == ConfigFocus::Component(focus_offset + local_i); + let comp = &app.components[comp_idx]; + let def = &COMPONENT_DEFS[comp_idx]; + + let check = if comp.enabled { "✓" } else { " " }; + let style = if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if comp.enabled { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + + if local_i < chunks.len() { + f.render_widget( + Paragraph::new(format!("[{check}] {}", def.name)).style(style), + chunks[local_i], + ); + } + } +} + +fn draw_start_button(f: &mut Frame, app: &App, area: Rect) { + let focused = app.config_focus == ConfigFocus::Start; + let has_running = app.components.iter().any(|c| c.state == ProcState::Running); + let label = if has_running { + " Continue → " + } else { + " Launch → " + }; + let (border_style, text_style) = if focused { + ( + Style::default().fg(Color::Yellow), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + } else { + ( + Style::default().fg(Color::Green), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + }; + f.render_widget( + Paragraph::new(label) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style), + ) + .style(text_style) + .alignment(Alignment::Center), + area, + ); +} + +// ─── Runtime screen ─────────────────────────────────────────────────────────── + +pub fn draw_runtime(f: &mut Frame, app: &App) { + let area = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)]) + .split(area); + + draw_runtime_header(f, app, chunks[0]); + draw_runtime_list(f, app, chunks[1]); + f.render_widget( + Paragraph::new( + " ↑↓/jk: select s: start/stop r: restart l: logs a: start all x: stop all Esc: config q: quit", + ) + .style(Style::default().fg(Color::DarkGray)), + chunks[2], + ); +} + +fn draw_runtime_header(f: &mut Frame, app: &App, area: Rect) { + let running = app + .components + .iter() + .filter(|c| c.state == ProcState::Running) + .count(); + let total_enabled = app.components.iter().filter(|c| c.enabled).count(); + let sim_badge = if app.params.sim { " [SIM]" } else { "" }; + f.render_widget( + Paragraph::new(format!( + " Bit-Bots Teamplayer [{running}/{total_enabled} running]{sim_badge}" + )) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + area, + ); +} + +fn draw_runtime_list(f: &mut Frame, app: &App, area: Rect) { + let visible = app.runtime_visible_indices(); + if visible.is_empty() { + f.render_widget( + Paragraph::new("No components enabled.").alignment(Alignment::Center), + area, + ); + return; + } + + let rows: Vec = visible.iter().map(|_| Constraint::Length(1)).collect(); + let row_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(rows) + .split(area); + + for (vis_i, &comp_idx) in visible.iter().enumerate() { + if vis_i >= row_chunks.len() { + break; + } + let selected = app.runtime_selected == vis_i; + let comp = &app.components[comp_idx]; + let def = &COMPONENT_DEFS[comp_idx]; + + let row_style = if selected { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + let spinner = match comp.state { + ProcState::Running => format!("{} ", spinner_char(app.tick_count)), + ProcState::Stopping => "… ".to_string(), + ProcState::Restarting => "↺ ".to_string(), + _ => " ".to_string(), + }; + + let line = Line::from(vec![ + Span::styled( + if def.infrastructure { "·" } else { " " }, + Style::default().fg(Color::DarkGray), + ), + Span::styled(spinner, Style::default().fg(Color::Green)), + Span::styled( + format!("[{}]", comp.state.label()), + proc_state_style(&comp.state).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("{:<18}", def.name), + if selected { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ), + Span::raw(" "), + Span::styled("s", Style::default().fg(Color::Yellow)), + Span::raw(":start/stop "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(":restart "), + Span::styled("l", Style::default().fg(Color::Yellow)), + Span::raw(":logs"), + ]); + f.render_widget(Paragraph::new(line).style(row_style), row_chunks[vis_i]); + } +} + +// ─── Logs screen ────────────────────────────────────────────────────────────── + +pub fn draw_logs(f: &mut Frame, app: &App, comp_idx: usize) { + let area = f.size(); + let comp = &app.components[comp_idx]; + let name = COMPONENT_DEFS[comp_idx].name; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)]) + .split(area); + + f.render_widget( + Paragraph::new(format!(" Logs: {name} ")) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + chunks[0], + ); + + let log_height = chunks[1].height as usize; + let lines: Vec = { + let logs = comp.logs.lock().unwrap(); + let total = logs.len(); + let max_scroll = if total > log_height { total - log_height } else { 0 }; + let scroll = app.log_scroll.min(max_scroll); + logs.iter() + .skip(scroll) + .take(log_height) + .map(|s| Line::from(s.clone())) + .collect() + }; + + f.render_widget( + Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::White)), + chunks[1], + ); + + f.render_widget( + Paragraph::new(" ↑↓/jk/PgUp/PgDn: scroll g: top G: bottom Esc/q/l: back") + .style(Style::default().fg(Color::DarkGray)), + chunks[2], + ); +} From c11b58e10686f705b2c657b371258643b770cd44 Mon Sep 17 00:00:00 2001 From: Lea Wedmann Date: Mon, 1 Jun 2026 21:40:35 +0200 Subject: [PATCH 3/7] Fix sim launch --- src/bitbots_misc/bitbots_bringup/tui/src/components.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/components.rs b/src/bitbots_misc/bitbots_bringup/tui/src/components.rs index 0045e8cb0..86221896e 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/src/components.rs +++ b/src/bitbots_misc/bitbots_bringup/tui/src/components.rs @@ -126,8 +126,8 @@ pub static COMPONENT_DEFS: &[ComponentDef] = &[ sim_component: true, cmds: |p| { vec![ros2_launch( - "bitbots_bringup", - "mujoco_simulation.launch.py", + "bitbots_mujoco_sim", + "simulator.launch", &[&sim(p)], )] }, From 89514149caa88b9304d10c2d357ae1ac8afedee0 Mon Sep 17 00:00:00 2001 From: Lea Wedmann Date: Mon, 1 Jun 2026 22:13:10 +0200 Subject: [PATCH 4/7] Better threading model for the team com --- .../bitbots_team_communication.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication.py b/src/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication.py index d52c6e7a1..41c9d069c 100755 --- a/src/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication.py +++ b/src/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication.py @@ -3,6 +3,7 @@ import socket import struct import threading +import time from typing import Optional import rclpy @@ -63,24 +64,16 @@ def __init__(self): self.tf_buffer = Buffer(node=self.node) - self.run_spin_in_thread() self.try_to_establish_connection() + self.logger.info("Successfully established connection.") + self.node.create_timer(1 / self.rate, self.send_message, callback_group=MutuallyExclusiveCallbackGroup()) - self.receive_forever() - def spin(self): - executor = EventsExecutor() - executor.add_node(self.node) - try: - executor.spin() - except KeyboardInterrupt: - pass + receive_thread = threading.Thread(target=self.receive_forever, daemon=True) + receive_thread.start() - def run_spin_in_thread(self): - # Necessary in ROS2, else we are forever stuck receiving messages - thread = threading.Thread(target=self.spin, daemon=True) - thread.start() + self.logger.info("Initialization complete.") def set_state_defaults(self): self.gamestate: Optional[GameState] = None @@ -100,8 +93,9 @@ def set_state_defaults(self): def try_to_establish_connection(self): # we will try multiple times till we manage to get a connection while rclpy.ok() and not self.socket_communication.is_setup(): + self.logger.info("Trying to establish connection...") self.socket_communication.establish_connection() - self.node.get_clock().sleep_for(Duration(seconds=1)) + time.sleep(1) def create_publishers(self): self.team_data_publisher = self.node.create_publisher(TeamData, self.topics["team_data_topic"], qos_profile=1) @@ -317,8 +311,14 @@ def convert_to_euler(self, quaternion: Quaternion): def main(): rclpy.init(args=None) - TeamCommunication() - + tc = TeamCommunication() + + executor = EventsExecutor() + executor.add_node(tc.node) + try: + executor.spin() + except KeyboardInterrupt: + pass if __name__ == "__main__": main() From 871ffba336cb2020e76dcf5d43f222e8ba876945 Mon Sep 17 00:00:00 2001 From: Lea Wedmann Date: Mon, 1 Jun 2026 22:30:39 +0200 Subject: [PATCH 5/7] Add joined log view --- .../bitbots_bringup/tui/src/app.rs | 54 +++++++++++---- .../bitbots_bringup/tui/src/input.rs | 1 + .../bitbots_bringup/tui/src/main.rs | 10 ++- .../bitbots_bringup/tui/src/ui.rs | 67 +++++++++++++++++-- 4 files changed, 111 insertions(+), 21 deletions(-) diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/app.rs b/src/bitbots_misc/bitbots_bringup/tui/src/app.rs index 25943bada..675e959d9 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/src/app.rs +++ b/src/bitbots_misc/bitbots_bringup/tui/src/app.rs @@ -98,6 +98,8 @@ pub struct App { pub msg_tx: mpsc::UnboundedSender, pub msg_rx: mpsc::UnboundedReceiver, pub log_scroll: usize, + pub all_logs: Arc>>, + pub show_log_panel: bool, } impl App { @@ -123,6 +125,8 @@ impl App { msg_tx, msg_rx, log_scroll: 0, + all_logs: Arc::new(Mutex::new(VecDeque::with_capacity(5000))), + show_log_panel: true, } } @@ -237,8 +241,10 @@ impl App { let def = &COMPONENT_DEFS[comp_idx]; let cmds = (def.cmds)(&self.params); let key = def.key.to_string(); + let comp_name = def.name; // &'static str let logs = self.components[comp_idx].logs.clone(); + let all_logs = self.all_logs.clone(); self.components[comp_idx].state = ProcState::Running; self.components[comp_idx].pids.clear(); self.components[comp_idx].task_handles.clear(); @@ -249,6 +255,7 @@ impl App { } let tx = self.msg_tx.clone(); let logs = logs.clone(); + let all_logs = all_logs.clone(); let key = key.clone(); let mut child = match Command::new(&cmd_args[0]) @@ -261,9 +268,9 @@ impl App { { Ok(c) => c, Err(e) => { - let mut l = logs.lock().unwrap(); - l.push_back(format!("[ERROR] spawn failed: {e}")); - drop(l); + let entry = format!("[ERROR] spawn failed: {e}"); + logs.lock().unwrap().push_back(entry.clone()); + all_logs.lock().unwrap().push_back(format!("[{comp_name}] {entry}")); let _ = tx.send(AppMsg::ProcessExited { key, code: -1 }); return; } @@ -280,7 +287,9 @@ impl App { async fn pump( mut r: BufReader, logs: Arc>>, - prefix: &str, + all_logs: Arc>>, + prefix: &'static str, + comp_name: &'static str, ) { let mut line = String::new(); while let Ok(n) = r.read_line(&mut line).await { @@ -288,20 +297,37 @@ impl App { break; } let entry = format!("{}{}", prefix, line.trim_end_matches('\n')); - let mut l = logs.lock().unwrap(); - if l.len() >= 2000 { - l.pop_front(); + { + let mut l = logs.lock().unwrap(); + if l.len() >= 2000 { + l.pop_front(); + } + l.push_back(entry.clone()); + } + { + let mut l = all_logs.lock().unwrap(); + if l.len() >= 5000 { + l.pop_front(); + } + l.push_back(format!("[{comp_name}] {entry}")); } - l.push_back(entry); line.clear(); } } - if let Some(r) = stdout { - pump(r, logs.clone(), "").await; - } - if let Some(r) = stderr { - pump(r, logs.clone(), "[ERR] ").await; - } + // Read stdout and stderr concurrently so neither blocks the other. + let (logs2, all2) = (logs.clone(), all_logs.clone()); + tokio::join!( + async move { + if let Some(r) = stdout { + pump(r, logs2, all2, "", comp_name).await; + } + }, + async move { + if let Some(r) = stderr { + pump(r, logs, all_logs, "[ERR] ", comp_name).await; + } + } + ); let code = child .wait() .await diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/input.rs b/src/bitbots_misc/bitbots_bringup/tui/src/input.rs index 356d019ba..2ee9e8ab9 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/src/input.rs +++ b/src/bitbots_misc/bitbots_bringup/tui/src/input.rs @@ -124,6 +124,7 @@ pub async fn handle_runtime_key(app: &mut App, key: KeyCode, _modifiers: KeyModi } } + KeyCode::Char('v') => app.show_log_panel = !app.show_log_panel, KeyCode::Char('a') => app.start_all_enabled().await, KeyCode::Char('x') => app.stop_all().await, KeyCode::Esc => app.screen = Screen::Config, diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/main.rs b/src/bitbots_misc/bitbots_bringup/tui/src/main.rs index ef259cb68..fc4ed7760 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/src/main.rs +++ b/src/bitbots_misc/bitbots_bringup/tui/src/main.rs @@ -148,7 +148,7 @@ async fn run_headless(app: &mut App) -> Result<()> { app.start_all_enabled().await; - let mut tick = time::interval(Duration::from_millis(500)); + let mut tick = time::interval(Duration::from_millis(50)); loop { tokio::select! { _ = tick.tick() => { @@ -156,8 +156,14 @@ async fn run_headless(app: &mut App) -> Result<()> { for idx in to_restart { app.start_component(idx).await; } + { + let mut logs = app.all_logs.lock().unwrap(); + while let Some(entry) = logs.pop_front() { + println!("{entry}"); + } + } let any_running = app.components.iter().any(|c| { - matches!(c.state, ProcState::Running | ProcState::Stopping) + matches!(c.state, ProcState::Running | ProcState::Stopping | ProcState::Restarting) }); if !any_running { eprintln!("All components have stopped."); diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs b/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs index 9fae56445..38eebf389 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs +++ b/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs @@ -264,12 +264,25 @@ pub fn draw_runtime(f: &mut Frame, app: &App) { .split(area); draw_runtime_header(f, app, chunks[0]); - draw_runtime_list(f, app, chunks[1]); + + if app.show_log_panel { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) + .split(chunks[1]); + draw_runtime_list(f, app, cols[0]); + draw_joint_logs(f, app, cols[1]); + } else { + draw_runtime_list(f, app, chunks[1]); + } + + let hint = if app.show_log_panel { + " ↑↓/jk: select s: stop/start r: restart l: logs a: start all x: stop all v: hide logs Esc: config q: quit" + } else { + " ↑↓/jk: select s: stop/start r: restart l: logs a: start all x: stop all v: show logs Esc: config q: quit" + }; f.render_widget( - Paragraph::new( - " ↑↓/jk: select s: start/stop r: restart l: logs a: start all x: stop all Esc: config q: quit", - ) - .style(Style::default().fg(Color::DarkGray)), + Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)), chunks[2], ); } @@ -361,6 +374,50 @@ fn draw_runtime_list(f: &mut Frame, app: &App, area: Rect) { } } +fn tag_color(tag: &str) -> Color { + const PALETTE: &[Color] = &[ + Color::Cyan, + Color::Green, + Color::Magenta, + Color::Yellow, + Color::LightBlue, + Color::LightGreen, + Color::LightMagenta, + Color::LightRed, + ]; + let h = tag.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)); + PALETTE[h % PALETTE.len()] +} + +fn draw_joint_logs(f: &mut Frame, app: &App, area: Rect) { + let inner_h = area.height.saturating_sub(2) as usize; + let lines: Vec = { + let logs = app.all_logs.lock().unwrap(); + let skip = logs.len().saturating_sub(inner_h); + logs.iter() + .skip(skip) + .map(|s| { + if let Some(close) = s.find("] ") { + let tag = &s[..close + 1]; // "[CompName]" + let rest = &s[close + 2..]; + Line::from(vec![ + Span::styled(tag.to_string(), Style::default().fg(tag_color(tag))), + Span::raw(" "), + Span::raw(rest.to_string()), + ]) + } else { + Line::from(s.clone()) + } + }) + .collect() + }; + f.render_widget( + Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" All Logs ")), + area, + ); +} + // ─── Logs screen ────────────────────────────────────────────────────────────── pub fn draw_logs(f: &mut Frame, app: &App, comp_idx: usize) { From deec3e8759ca7756d085ee3ab245dc2933b7820c Mon Sep 17 00:00:00 2001 From: Lea Wedmann Date: Tue, 2 Jun 2026 00:08:38 +0200 Subject: [PATCH 6/7] Use events executor for walk --- .../bitbots_rl_walk/bitbots_rl_walk/walk.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bitbots_motion/bitbots_rl_walk/bitbots_rl_walk/walk.py b/src/bitbots_motion/bitbots_rl_walk/bitbots_rl_walk/walk.py index eeb6ef7ba..3091b1bcc 100644 --- a/src/bitbots_motion/bitbots_rl_walk/bitbots_rl_walk/walk.py +++ b/src/bitbots_motion/bitbots_rl_walk/bitbots_rl_walk/walk.py @@ -20,6 +20,7 @@ import numpy as np import onnxruntime as rt from ament_index_python import get_package_share_directory +from rclpy.experimental.events_executor import EventsExecutor from geometry_msgs.msg import Twist from rclpy.node import Node from sensor_msgs.msg import Imu, JointState @@ -195,6 +196,10 @@ def main(): rclpy.init() node = WalkNode() - rclpy.spin(node) - node.destroy_node() - rclpy.try_shutdown() + + executor = EventsExecutor() + executor.add_node(node) + try: + executor.spin() + except KeyboardInterrupt: + pass From fc0fcc4b7e67978c2514f09f0f7d34582919d91b Mon Sep 17 00:00:00 2001 From: Lea Wedmann Date: Tue, 2 Jun 2026 00:09:05 +0200 Subject: [PATCH 7/7] Fix log rendering --- .../bitbots_bringup/tui/Cargo.lock | 48 +++++++++++++ .../bitbots_bringup/tui/Cargo.toml | 1 + .../bitbots_bringup/tui/src/ui.rs | 70 ++++++++++++++----- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock b/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock index 8c6dbbc34..f76f29d3f 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock +++ b/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock @@ -8,6 +8,17 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ansi-to-tui" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0e348dcd256ba06d44d5deabc88a7c0e80ee7303158253ca069bcd9e9b7f57" +dependencies = [ + "nom", + "ratatui", + "thiserror", +] + [[package]] name = "anstream" version = "0.6.21" @@ -387,6 +398,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "0.8.11" @@ -421,6 +438,16 @@ dependencies = [ "libc", ] +[[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 = "once_cell_polyfill" version = "1.70.2" @@ -639,6 +666,7 @@ dependencies = [ name = "teamplayer" version = "0.1.0" dependencies = [ + "ansi-to-tui", "anyhow", "clap", "crossterm", @@ -648,6 +676,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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", +] + [[package]] name = "tokio" version = "1.52.3" diff --git a/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml b/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml index 313033333..d9754baf5 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml +++ b/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml @@ -14,4 +14,5 @@ tokio = { version = "1", features = ["full"] } anyhow = "1" futures = "0.3" nix = { version = "0.27", features = ["signal", "process"] } +ansi-to-tui = "3" clap = { version = "~4.4", features = ["derive"] } diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs b/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs index 38eebf389..2b5e6c1af 100644 --- a/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs +++ b/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs @@ -2,7 +2,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Paragraph, Wrap}, Frame, }; @@ -389,31 +389,66 @@ fn tag_color(tag: &str) -> Color { PALETTE[h % PALETTE.len()] } +/// Convert a raw log string (which may contain ANSI escape codes) into an owned ratatui Line. +fn ansi_to_line(s: &str) -> Line<'static> { + use ansi_to_tui::IntoText; + match s.into_text() { + Ok(text) => Line::from( + text.lines + .into_iter() + .flat_map(|l| l.spans) + .map(|s| Span::styled(s.content.into_owned(), s.style)) + .collect::>(), + ), + Err(_) => Line::from(s.to_owned()), + } +} + fn draw_joint_logs(f: &mut Frame, app: &App, area: Rect) { let inner_h = area.height.saturating_sub(2) as usize; - let lines: Vec = { + let inner_w = area.width.saturating_sub(2) as usize; + + let lines: Vec> = { let logs = app.all_logs.lock().unwrap(); - let skip = logs.len().saturating_sub(inner_h); + // Grab more entries than the panel height — wrapping will consume extra rows. + let skip = logs.len().saturating_sub(inner_h * 3); logs.iter() .skip(skip) - .map(|s| { - if let Some(close) = s.find("] ") { - let tag = &s[..close + 1]; // "[CompName]" - let rest = &s[close + 2..]; - Line::from(vec![ - Span::styled(tag.to_string(), Style::default().fg(tag_color(tag))), + .map(|entry| { + if let Some(close) = entry.find("] ") { + let tag = &entry[..close + 1]; // "[CompName]" + let rest = &entry[close + 2..]; + let mut spans: Vec> = vec![ + Span::styled( + tag.to_owned(), + Style::default().fg(tag_color(tag)).add_modifier(Modifier::BOLD), + ), Span::raw(" "), - Span::raw(rest.to_string()), - ]) + ]; + spans.extend(ansi_to_line(rest).spans); + Line::from(spans) } else { - Line::from(s.clone()) + ansi_to_line(entry) } }) .collect() }; + + // Estimate total visual rows so we can scroll to pin the view to the bottom. + let total_rows: usize = lines + .iter() + .map(|l| { + let chars: usize = l.spans.iter().map(|s| s.content.chars().count()).sum(); + if inner_w > 0 { (chars + inner_w - 1) / inner_w } else { 1 }.max(1) + }) + .sum(); + let scroll = total_rows.saturating_sub(inner_h) as u16; + f.render_widget( Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL).title(" All Logs ")), + .block(Block::default().borders(Borders::ALL).title(" All Logs ")) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)), area, ); } @@ -438,22 +473,23 @@ pub fn draw_logs(f: &mut Frame, app: &App, comp_idx: usize) { ); let log_height = chunks[1].height as usize; - let lines: Vec = { + let lines: Vec> = { let logs = comp.logs.lock().unwrap(); let total = logs.len(); let max_scroll = if total > log_height { total - log_height } else { 0 }; let scroll = app.log_scroll.min(max_scroll); logs.iter() .skip(scroll) - .take(log_height) - .map(|s| Line::from(s.clone())) + .take(log_height * 2) // extra entries so wrapped lines still fill the panel + .map(|s| ansi_to_line(s)) .collect() }; f.render_widget( Paragraph::new(lines) .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::White)), + .style(Style::default().fg(Color::White)) + .wrap(Wrap { trim: false }), chunks[1], );