From 97ea5f8f12b6e649a4dabb3ea67f57b2e0fb9f8f Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sun, 25 Oct 2026 00:36:41 -0700 Subject: [PATCH] feat(gui): launcher window (create fleet, node controls, actions) --- tines-gui/Cargo.lock | 633 +++++++++++++++++++++++++++++++++- tines-gui/Cargo.toml | 2 +- tines-gui/src/launcher.rs | 330 ++++++++++++++++++ tines-gui/src/lib.rs | 1 + tines-gui/src/main.rs | 73 +++- tines-gui/src/theme.rs | 20 ++ tines-gui/tests/common/mod.rs | 38 ++ tines-gui/tests/fleet.rs | 52 +-- tines-gui/tests/launcher.rs | 108 ++++++ 9 files changed, 1208 insertions(+), 49 deletions(-) create mode 100644 tines-gui/src/launcher.rs create mode 100644 tines-gui/tests/common/mod.rs create mode 100644 tines-gui/tests/launcher.rs diff --git a/tines-gui/Cargo.lock b/tines-gui/Cargo.lock index 145e6d80..149c3c6f 100644 --- a/tines-gui/Cargo.lock +++ b/tines-gui/Cargo.lock @@ -11,6 +11,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + [[package]] name = "accesskit_consumer" version = "0.35.0" @@ -21,6 +35,86 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -106,6 +200,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -115,6 +215,137 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -130,6 +361,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -199,6 +467,19 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bluer" version = "0.17.4" @@ -745,6 +1026,7 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" dependencies = [ + "accesskit_winit", "arboard", "bytemuck", "egui", @@ -818,6 +1100,33 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.36.2" @@ -880,6 +1189,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1020,6 +1349,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1492,7 +1834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.35.0", ] [[package]] @@ -1636,6 +1978,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2180,6 +2531,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2222,6 +2589,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2248,6 +2658,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2599,6 +3020,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "serde_spanned" version = "1.1.2" @@ -2646,6 +3078,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "skrifa" version = "0.44.0" @@ -3084,14 +3522,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] name = "type-map" @@ -3108,6 +3561,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -3140,6 +3604,7 @@ checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -3822,6 +4287,7 @@ dependencies = [ "atomic-waker", "bitflags 2.13.2", "block2 0.5.1", + "bytemuck", "calloop 0.13.0", "cfg_aliases", "concurrent-queue", @@ -3838,6 +4304,7 @@ dependencies = [ "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", "orbclient", + "percent-encoding", "pin-project", "raw-window-handle", "redox_syscall 0.4.1", @@ -3855,6 +4322,8 @@ dependencies = [ "web-sys", "web-time", "windows-sys 0.52.0", + "x11-dl", + "x11rb", "xkbcommon-dl", ] @@ -3873,13 +4342,28 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "x11rb" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ + "as-raw-xcb-connection", "gethostname", + "libc", + "libloading", + "once_cell", "rustix 1.1.5", "x11rb-protocol", ] @@ -3921,6 +4405,112 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -3967,3 +4557,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/tines-gui/Cargo.toml b/tines-gui/Cargo.toml index 0f7df84b..86023ac4 100644 --- a/tines-gui/Cargo.toml +++ b/tines-gui/Cargo.toml @@ -15,7 +15,7 @@ serde_json = "1" notify = "8" [dev-dependencies] -egui_kittest = { version = "0.36", features = ["wgpu"] } +egui_kittest = { version = "0.36", features = ["wgpu", "eframe"] } tempfile = "3" # Like fuzz/, this crate sits outside the workspace (the GUI is a separate diff --git a/tines-gui/src/launcher.rs b/tines-gui/src/launcher.rs new file mode 100644 index 00000000..6f7f93f7 --- /dev/null +++ b/tines-gui/src/launcher.rs @@ -0,0 +1,330 @@ +//! The launcher window: fleet creation, node/container controls, and +//! per-node window spawns. +//! +//! [`Launcher::update`] renders and queues intents — it never spawns a +//! process (except the one-time bus daemon inside `Fleet::create`, which is +//! a non-blocking `spawn`). Everything else drains through +//! [`Launcher::drain_actions`], called by `main.rs` after each frame; the +//! headless tests drive `update` and assert on the queued actions. + +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::process::Child; + +use egui::Context; + +use crate::config::fleet_dir; +use crate::docker::{ContainerId, Docker}; +use crate::fleet::{Fleet, Mode}; + +/// One node container the launcher knows about. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerEntry { + /// The node name (also the dir and container-name stem). + pub name: String, + /// The container id `docker run -d` returned. + pub id: ContainerId, + /// How the node connects to its peers. + pub mode: Mode, +} + +/// An intent the UI queued; `main.rs` executes these by draining the queue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LauncherAction { + /// Add the node to the fleet and start its container. + StartContainer { + /// The node name. + name: String, + /// How the node connects to its peers. + mode: Mode, + }, + /// Stop a running container. + StopContainer(ContainerId), + /// Remove a stopped container. + RemoveContainer(ContainerId), + /// Spawn a dedicated window process for this node. + OpenWindow { + /// The node name. + name: String, + /// The node's config dir (the `--node` arg). + dir: PathBuf, + /// The container the window controls. + id: ContainerId, + /// How the node connects to its peers. + mode: Mode, + }, + /// Build the runtime image. + BuildImage, +} + +/// The launcher window's full state. +pub struct Launcher { + /// The created fleet; `None` until Create succeeds. + pub fleet: Option, + /// The docker frontend. + pub docker: Docker, + /// Known node containers (only successful starts land here). + pub containers: Vec, + /// Spawned node-window processes. + pub children: Vec, + /// Intents queued by [`Launcher::update`], drained by `main.rs`. + pub actions: VecDeque, + + /// The fleet-name field. + pub fleet_name: String, + /// The node-name field for Add. + pub node_name: String, + /// The bt peer field (bt mode only; the pinned `StartContainer` action + /// carries no peer, so it is read from here at drain time). + pub node_peer: String, + /// The mode picker. + pub mode: Mode, + /// The selected row's name (drives the log pane). + pub selected: Option, + /// The selected container's last-fetched log tail. + pub log_tail: Option, + /// The latest error to surface. + pub error: Option, + + /// `docker inspect` results, refreshed at most once per second. + running: HashMap, + /// When the running map was last refreshed. + last_running_check: f64, +} + +impl Default for Launcher { + fn default() -> Self { + Self::new() + } +} + +impl Launcher { + /// An empty launcher: no fleet, no containers, sim mode. + pub fn new() -> Launcher { + Launcher { + fleet: None, + docker: Docker, + containers: Vec::new(), + children: Vec::new(), + actions: VecDeque::new(), + fleet_name: String::new(), + node_name: String::new(), + node_peer: String::new(), + mode: Mode::Sim, + selected: None, + log_tail: None, + error: None, + running: HashMap::new(), + last_running_check: f64::NEG_INFINITY, + } + } + + /// Render the launcher and queue intents. Side effects are limited to + /// the fleet creation itself (a non-blocking daemon spawn) and one + /// `docker logs` fetch when a row is selected. + pub fn update(&mut self, ctx: &Context) { + let now = ctx.input(|i| i.time); + if now - self.last_running_check > 1.0 { + self.last_running_check = now; + self.running = self + .containers + .iter() + .map(|c| (c.id.clone(), self.docker.is_running(&c.id))) + .collect(); + } + + // egui 0.36 is Ui-based: rebuild the root Ui the eframe App would + // have passed, so the panels have a parent. + let mut ui = egui::Ui::new( + ctx.clone(), + egui::Id::new("tines-gui-root"), + egui::UiBuilder::new().max_rect(ctx.content_rect()), + ); + + egui::Panel::top("controls").show(&mut ui, |ui| { + ui.horizontal(|ui| { + ui.label("Mode:"); + ui.selectable_value(&mut self.mode, Mode::Bt, "Bt"); + ui.selectable_value(&mut self.mode, Mode::Sim, "Sim"); + }); + ui.horizontal(|ui| { + ui.label("Fleet name:"); + ui.text_edit_singleline(&mut self.fleet_name); + if ui.button("Create").clicked() { + match Fleet::create(self.fleet_name.trim()) { + Ok(fleet) => { + self.fleet = Some(fleet); + self.error = None; + } + Err(e) => self.error = Some(format!("cannot create fleet: {e}")), + } + } + }); + if self.mode == Mode::Bt { + ui.horizontal(|ui| { + ui.label("Peer (bt):"); + ui.text_edit_singleline(&mut self.node_peer); + }); + } + ui.horizontal(|ui| { + ui.label("Node name:"); + ui.text_edit_singleline(&mut self.node_name); + if ui.button("Add").clicked() { + self.actions.push_back(LauncherAction::StartContainer { + name: self.node_name.trim().to_string(), + mode: self.mode, + }); + } + }); + if ui.button("Build image").clicked() { + self.actions.push_back(LauncherAction::BuildImage); + } + if let Some(error) = &self.error { + ui.colored_label(egui::Color32::from_rgb(0xff, 0x55, 0x55), error); + } + }); + + egui::CentralPanel::default().show(&mut ui, |ui| { + egui::Grid::new("nodes") + .striped(true) + .min_col_width(80.0) + .show(ui, |ui| { + ui.label("node"); + ui.label("mode"); + ui.label("state"); + ui.label(""); + ui.end_row(); + for entry in &self.containers { + let running = self.running.get(&entry.id).copied().unwrap_or(false); + let name = entry.name.clone(); + if ui + .selectable_label(self.selected.as_deref() == Some(&name), &entry.name) + .clicked() + { + self.selected = Some(name.clone()); + self.log_tail = self.docker.logs(&entry.id, 50).ok(); + } + ui.label(match entry.mode { + Mode::Bt => "bt", + Mode::Sim => "sim", + }); + ui.label(if running { "running" } else { "stopped" }); + ui.horizontal(|ui| { + if ui.button("Open").clicked() { + self.actions.push_back(LauncherAction::OpenWindow { + name: name.clone(), + dir: fleet_dir(self.fleet_name.trim()).join(&name), + id: entry.id.clone(), + mode: entry.mode, + }); + } + if ui.button("Stop").clicked() { + self.actions + .push_back(LauncherAction::StopContainer(entry.id.clone())); + } + if ui.button("Remove").clicked() { + self.actions + .push_back(LauncherAction::RemoveContainer(entry.id.clone())); + } + }); + ui.end_row(); + } + }); + }); + + egui::Panel::bottom("logs").show(&mut ui, |ui| { + ui.heading(match &self.selected { + Some(name) => format!("logs — {name}"), + None => "logs — select a container".to_string(), + }); + if let Some(tail) = &self.log_tail { + egui::ScrollArea::vertical() + .max_height(120.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.monospace(tail); + }); + } + }); + } + + /// Execute every queued intent. This is the only place containers are + /// started/stopped/removed and node windows spawned; `main.rs` calls it + /// after each frame. + pub fn drain_actions(&mut self) { + while let Some(action) = self.actions.pop_front() { + match action { + LauncherAction::StartContainer { name, mode } => { + let Some(fleet) = self.fleet.as_ref() else { + self.error = Some("create a fleet first".to_string()); + continue; + }; + let peer = (mode == Mode::Bt && !self.node_peer.trim().is_empty()) + .then(|| self.node_peer.trim().to_string()); + if let Err(e) = fleet.add_node(&name, None, mode, peer.as_deref()) { + self.error = Some(format!("cannot add node {name}: {e}")); + continue; + } + let started = match mode { + Mode::Bt => self.docker.start_bt(&name, self.fleet_name.trim()), + Mode::Sim => self.docker.start_sim(&name, self.fleet_name.trim()), + }; + match started { + Ok(id) => self.containers.push(ContainerEntry { name, id, mode }), + Err(e) => self.error = Some(format!("cannot start {name}: {e}")), + } + } + LauncherAction::StopContainer(id) => { + if let Err(e) = self.docker.stop(&id) { + self.error = Some(format!("cannot stop {}: {e}", id.0)); + } + } + LauncherAction::RemoveContainer(id) => { + if let Err(e) = self.docker.remove(&id) { + self.error = Some(format!("cannot remove {}: {e}", id.0)); + } + self.containers.retain(|c| c.id != id); + if self.selected.as_ref() == Some(&id.0) { + self.selected = None; + self.log_tail = None; + } + } + LauncherAction::OpenWindow { + name, + dir, + id, + mode, + } => { + let mode_arg = match mode { + Mode::Bt => "bt", + Mode::Sim => "sim", + }; + match std::env::current_exe().and_then(|exe| { + std::process::Command::new(exe) + .arg("--node") + .arg(&dir) + .arg(&id.0) + .arg(mode_arg) + .spawn() + }) { + Ok(child) => self.children.push(child), + Err(e) => { + self.error = Some(format!("cannot open window for {name}: {e}")); + } + } + } + LauncherAction::BuildImage => { + if let Err(e) = self.docker.build_image() { + self.error = Some(format!("image build failed: {e}")); + } + } + } + } + } +} + +impl eframe::App for Launcher { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + Launcher::update(self, ui.ctx()); + } +} diff --git a/tines-gui/src/lib.rs b/tines-gui/src/lib.rs index 157f2667..227f677e 100644 --- a/tines-gui/src/lib.rs +++ b/tines-gui/src/lib.rs @@ -11,4 +11,5 @@ pub mod config; pub mod docker; pub mod fleet; pub mod ipc_client; +pub mod launcher; pub mod theme; diff --git a/tines-gui/src/main.rs b/tines-gui/src/main.rs index 0303a37b..a2c5f298 100644 --- a/tines-gui/src/main.rs +++ b/tines-gui/src/main.rs @@ -1,9 +1,72 @@ -//! The console binary. Task 8 wires the real UI; for now the launcher -//! sentence is the whole program. +//! tines-gui entry point: no args opens the launcher window; +//! `--node ` opens a node window (Task 8b). -fn main() { +use eframe::egui; + +fn main() -> Result<(), Box> { let args: Vec = std::env::args().collect(); - if args.len() < 2 { - println!("tines-gui: launcher"); + match args.as_slice() { + [_, flag, dir, id, mode] if flag == "--node" => { + let _ = (dir, id, mode); + eprintln!("tines-gui: node window is Task 8b — not wired yet"); + std::process::exit(1); + } + [_] => run_launcher()?, + _ => { + eprintln!("usage: tines-gui [--node ]"); + std::process::exit(2); + } + } + Ok(()) +} + +/// The launcher window: renders via `Launcher::update`, then drains the +/// action queue — the only place subprocess spawns happen. +struct LauncherApp { + launcher: tines_gui::launcher::Launcher, + theme: tines_gui::theme::OmarchyTheme, + watching: bool, +} + +impl eframe::App for LauncherApp { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + self.theme = tines_gui::theme::OmarchyTheme::load(); + self.theme.apply(&ctx); + if !self.watching { + let ctx = ctx.clone(); + if tines_gui::theme::OmarchyTheme::watch(move || ctx.request_repaint()).is_ok() { + self.watching = true; + } + } + self.launcher.update(&ctx); + self.launcher.drain_actions(); } } + +fn run_launcher() -> Result<(), Box> { + let viewport = egui::ViewportBuilder::default() + .with_app_id("tines-gui") + .with_title("tines — fleet console") + .with_inner_size([760.0, 520.0]); + let options = eframe::NativeOptions { + viewport, + glow_options: eframe::egui_glow::GlowConfiguration { + vsync: false, + ..Default::default() + }, + ..Default::default() + }; + eframe::run_native( + "tines-gui", + options, + Box::new(|_cc| { + Ok(Box::new(LauncherApp { + launcher: tines_gui::launcher::Launcher::new(), + theme: tines_gui::theme::OmarchyTheme::load(), + watching: false, + })) + }), + ) + .map_err(|e| format!("eframe: {e}").into()) +} diff --git a/tines-gui/src/theme.rs b/tines-gui/src/theme.rs index 087e1b29..6b34d03d 100644 --- a/tines-gui/src/theme.rs +++ b/tines-gui/src/theme.rs @@ -39,6 +39,26 @@ impl OmarchyTheme { } } + /// Apply the palette: egui visuals plus an opaque background fill so + /// the window is never transparent on Wayland. Call every frame. + pub fn apply(&self, ctx: &egui::Context) { + let mut visuals = egui::Visuals::dark(); + visuals.panel_fill = self.background; + visuals.window_fill = self.background; + visuals.window_stroke = egui::Stroke::NONE; + visuals.override_text_color = Some(self.foreground); + visuals.selection.bg_fill = self.accent; + visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, self.accent); + visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, self.accent); + visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, self.accent); + ctx.set_visuals(visuals); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("tines-gui-bg"), + )); + painter.rect_filled(ctx.content_rect(), 0.0, self.background); + } + /// Watch the omarchy `current` theme dir (recursively) and call /// `on_change` whenever it changes — the caller repaints from there. /// Returns an error only if the watcher cannot be installed. diff --git a/tines-gui/tests/common/mod.rs b/tines-gui/tests/common/mod.rs new file mode 100644 index 00000000..33a56b8b --- /dev/null +++ b/tines-gui/tests/common/mod.rs @@ -0,0 +1,38 @@ +//! Shared test setup for the integration suites. Compiled per test binary; +//! only declare `mod common;` in tests that use these helpers. + +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::sync::OnceLock; + +/// One shared scratch setup per test binary: a temp dir that becomes both +/// XDG_STATE_HOME (fleet dirs) and a PATH dir holding a fake `tines-busd`. +/// Env is process-global, so it is set exactly once before any test touches +/// `fleet_dir` — a per-test `set_var` from parallel test threads would race. +pub fn env_with_fake_busd() -> &'static PathBuf { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let dir = std::env::temp_dir().join(format!("tines-gui-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + // Fake tines-busd: create the socket named by argv[1], then stay + // alive so the spawning Fleet keeps a live child. + let fake = dir.join("tines-busd"); + std::fs::write(&fake, "#!/bin/sh\ntouch \"$1\"\nsleep 60\n").unwrap(); + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let path = format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + // SAFETY: set once, before any fleet_dir call; this process is a + // test binary that exits afterwards, so nothing else observes it. + unsafe { + std::env::set_var("XDG_STATE_HOME", &dir); + std::env::set_var("PATH", path); + } + dir + }) +} diff --git a/tines-gui/tests/fleet.rs b/tines-gui/tests/fleet.rs index 05d42f84..3d78be11 100644 --- a/tines-gui/tests/fleet.rs +++ b/tines-gui/tests/fleet.rs @@ -1,48 +1,16 @@ //! Fleet layout and node-config tests. The bus daemon is a fake `tines-busd` -//! seeded onto PATH (the real binary isn't on PATH for unit tests): it -//! records its argv and creates the socket file, so the fleet's spawn -//! protocol is what's under test — the real daemon is covered by tines-bus's -//! own suite and Task 4's e2e. +//! seeded onto PATH (see `common`; the real binary isn't on PATH for unit +//! tests): it creates the socket file named by argv[1], so the fleet's +//! spawn protocol is what's under test — the real daemon is covered by +//! tines-bus's own suite and Task 4's e2e. + +mod common; use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; -use std::sync::OnceLock; use std::time::{Duration, Instant}; use tines_gui::fleet::{Fleet, Mode}; -/// One shared scratch setup for every test in this binary: a temp dir that -/// becomes both XDG_STATE_HOME (fleet dirs) and a PATH dir holding the fake -/// `tines-busd`. Env is process-global, so it is set exactly once before any -/// test touches fleet_dir. -fn setup() -> &'static PathBuf { - static DIR: OnceLock = OnceLock::new(); - DIR.get_or_init(|| { - let dir = std::env::temp_dir().join(format!("tines-gui-fleet-test-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - // Fake tines-busd: touch the socket named by argv[1], then stay alive. - let fake = dir.join("tines-busd"); - std::fs::write(&fake, "#!/bin/sh\ntouch \"$1\"\nsleep 60\n").unwrap(); - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); - - let path = format!( - "{}:{}", - dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - // SAFETY: set once, before any fleet_dir call; this process is a - // test binary that exits afterwards, so nothing else observes it. - unsafe { - std::env::set_var("XDG_STATE_HOME", &dir); - std::env::set_var("PATH", path); - } - dir - }) -} - fn fleet_name(tag: &str) -> String { format!( "test-{tag}-{}", @@ -52,7 +20,7 @@ fn fleet_name(tag: &str) -> String { #[test] fn fleet_dir_layout() { - setup(); + common::env_with_fake_busd(); let fleet = Fleet::create(&fleet_name("layout")).expect("fleet creates"); let dir = tines_gui::config::fleet_dir(&fleet_name("layout")); let meta = std::fs::metadata(&dir).expect("fleet dir exists"); @@ -75,7 +43,7 @@ fn fleet_dir_layout() { #[test] fn add_node_bus_config_mounts_data() { - setup(); + common::env_with_fake_busd(); let fleet_name = fleet_name("bus"); let fleet = Fleet::create(&fleet_name).expect("fleet creates"); let spec = fleet @@ -95,7 +63,7 @@ fn add_node_bus_config_mounts_data() { #[test] fn add_node_bt_config_has_peer() { - setup(); + common::env_with_fake_busd(); let fleet_name = fleet_name("bt"); let fleet = Fleet::create(&fleet_name).expect("fleet creates"); let peer = "22".repeat(16); @@ -114,7 +82,7 @@ fn add_node_bt_config_has_peer() { #[test] fn generated_id_roundtrips() { - setup(); + common::env_with_fake_busd(); let fleet_name = fleet_name("gen"); let fleet = Fleet::create(&fleet_name).expect("fleet creates"); let spec = fleet diff --git a/tines-gui/tests/launcher.rs b/tines-gui/tests/launcher.rs new file mode 100644 index 00000000..79df1033 --- /dev/null +++ b/tines-gui/tests/launcher.rs @@ -0,0 +1,108 @@ +//! Launcher UI tests (egui_kittest, headless). No real docker or daemon: +//! the fake `tines-busd` is seeded on PATH (see `common`), and the +//! containers are injected directly into the launcher's state. + +mod common; + +use egui::accesskit; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use tines_gui::config::fleet_dir; +use tines_gui::docker::ContainerId; +use tines_gui::fleet::Mode; +use tines_gui::launcher::{ContainerEntry, Launcher, LauncherAction}; + +/// The test-side action drain: peek at what the UI queued (main.rs owns the +/// real drain with the subprocess spawns). +fn drain(launcher: &mut Launcher) -> Vec { + launcher.actions.drain(..).collect() +} + +#[test] +fn create_fleet_action_queues_spawn() { + common::env_with_fake_busd(); + let mut harness = Harness::new_eframe(|_cc| Launcher::new()); + harness.run(); + + // Type the fleet name into the first text field, then click Create. + harness + .get_all_by_role(accesskit::Role::TextInput) + .next() + .expect("fleet name field") + .focus(); + harness.run(); + harness + .get_all_by_role(accesskit::Role::TextInput) + .next() + .expect("fleet name field") + .type_text("yard"); + harness.run(); + harness.get_by_label("Create").click(); + harness.run(); + + let launcher = harness.state(); + assert!( + launcher.fleet.is_some(), + "Create must set the fleet (spawning the bus daemon)" + ); + // The action queue must be drainable; fleet creation itself is not an + // action (it is the fleet field). + let launcher = harness.state_mut(); + assert!(drain(launcher).is_empty()); +} + +#[test] +fn open_window_queues_spawn() { + common::env_with_fake_busd(); + let mut harness = Harness::new_eframe(|_cc| { + let mut launcher = Launcher::new(); + launcher.fleet_name = "yard".to_string(); + launcher.containers.push(ContainerEntry { + name: "alpha".to_string(), + id: ContainerId("deadbeef".to_string()), + mode: Mode::Sim, + }); + launcher + }); + harness.run(); + + harness.get_by_label("Open").click(); + harness.run(); + + let actions = drain(harness.state_mut()); + assert_eq!( + actions, + vec![LauncherAction::OpenWindow { + name: "alpha".to_string(), + dir: fleet_dir("yard").join("alpha"), + id: ContainerId("deadbeef".to_string()), + mode: Mode::Sim, + }] + ); +} + +#[test] +fn stop_queues_stop_action() { + common::env_with_fake_busd(); + let mut harness = Harness::new_eframe(|_cc| { + let mut launcher = Launcher::new(); + launcher.containers.push(ContainerEntry { + name: "bravo".to_string(), + id: ContainerId("cafebabe".to_string()), + mode: Mode::Bt, + }); + launcher + }); + harness.run(); + + harness.get_by_label("Stop").click(); + harness.run(); + + let actions = drain(harness.state_mut()); + assert_eq!( + actions, + vec![LauncherAction::StopContainer(ContainerId( + "cafebabe".to_string() + ))] + ); +}