WIP: Launcher UI
This commit is contained in:
parent
ac2d2abfed
commit
e2013eb694
19 changed files with 2971 additions and 19 deletions
700
Cargo.lock
generated
700
Cargo.lock
generated
|
|
@ -123,6 +123,29 @@ version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cairo-rs"
|
||||||
|
version = "0.20.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cairo-sys-rs"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.51"
|
version = "1.2.51"
|
||||||
|
|
@ -133,6 +156,16 @@ dependencies = [
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-expr"
|
||||||
|
version = "0.20.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21be0e1ce6cdb2ee7fff840f922fb04ead349e5cfb1e750b769132d44ce04720"
|
||||||
|
dependencies = [
|
||||||
|
"smallvec",
|
||||||
|
"target-lexicon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -245,12 +278,143 @@ version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "field-offset"
|
||||||
|
version = "0.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||||
|
dependencies = [
|
||||||
|
"memoffset",
|
||||||
|
"rustc_version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gdk-pixbuf"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c"
|
||||||
|
dependencies = [
|
||||||
|
"gdk-pixbuf-sys",
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gdk-pixbuf-sys"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96"
|
||||||
|
dependencies = [
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gdk4"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"gdk-pixbuf",
|
||||||
|
"gdk4-sys",
|
||||||
|
"gio",
|
||||||
|
"gl",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
"pango",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gdk4-sys"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"gdk-pixbuf-sys",
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"pkg-config",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
|
|
@ -263,6 +427,245 @@ dependencies = [
|
||||||
"wasip2",
|
"wasip2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gio"
|
||||||
|
version = "0.20.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"gio-sys",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gio-sys"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gl"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404"
|
||||||
|
dependencies = [
|
||||||
|
"gl_generator",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gl_generator"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
|
||||||
|
dependencies = [
|
||||||
|
"khronos_api",
|
||||||
|
"log",
|
||||||
|
"xml-rs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glib"
|
||||||
|
version = "0.20.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
"gio-sys",
|
||||||
|
"glib-macros",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glib-macros"
|
||||||
|
version = "0.20.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glib-sys"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gobject-sys"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "graphene-rs"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b"
|
||||||
|
dependencies = [
|
||||||
|
"glib",
|
||||||
|
"graphene-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "graphene-sys"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gsk4"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"gdk4",
|
||||||
|
"glib",
|
||||||
|
"graphene-rs",
|
||||||
|
"gsk4-sys",
|
||||||
|
"libc",
|
||||||
|
"pango",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gsk4-sys"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"gdk4-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"graphene-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtk4"
|
||||||
|
version = "0.9.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"field-offset",
|
||||||
|
"futures-channel",
|
||||||
|
"gdk-pixbuf",
|
||||||
|
"gdk4",
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"graphene-rs",
|
||||||
|
"gsk4",
|
||||||
|
"gtk4-macros",
|
||||||
|
"gtk4-sys",
|
||||||
|
"libc",
|
||||||
|
"pango",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtk4-layer-shell"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3e1e1b1516be3d7ca089dfa6a1e688e268c74aef50c0c25fe8c46b1ba8ed1cc"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"gdk4",
|
||||||
|
"glib",
|
||||||
|
"glib-sys",
|
||||||
|
"gtk4",
|
||||||
|
"gtk4-layer-shell-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtk4-layer-shell-sys"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3057dc117db2d664a9b45f1956568701914e80cf9f2c8cef0a755af4c1c8105"
|
||||||
|
dependencies = [
|
||||||
|
"gdk4-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gtk4-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtk4-macros"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtk4-sys"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"gdk-pixbuf-sys",
|
||||||
|
"gdk4-sys",
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"graphene-sys",
|
||||||
|
"gsk4-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
|
|
@ -349,6 +752,12 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "khronos_api"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -471,6 +880,30 @@ version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pango"
|
||||||
|
version = "0.20.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c"
|
||||||
|
dependencies = [
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pango-sys"
|
||||||
|
version = "0.20.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
|
|
@ -500,12 +933,27 @@ version = "0.2.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-utils"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-crate"
|
||||||
|
version = "3.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||||
|
dependencies = [
|
||||||
|
"toml_edit 0.23.10+spec-1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
|
|
@ -570,6 +1018,15 @@ dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.3"
|
version = "1.1.3"
|
||||||
|
|
@ -595,6 +1052,12 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -647,6 +1110,15 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -678,7 +1150,7 @@ dependencies = [
|
||||||
"shepherd-util",
|
"shepherd-util",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"toml",
|
"toml 0.8.23",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -729,6 +1201,25 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shepherd-hud"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"gtk4",
|
||||||
|
"gtk4-layer-shell",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"shepherd-api",
|
||||||
|
"shepherd-ipc",
|
||||||
|
"shepherd-util",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shepherd-ipc"
|
name = "shepherd-ipc"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -744,6 +1235,24 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shepherd-launcher-ui"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"gtk4",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"shepherd-api",
|
||||||
|
"shepherd-ipc",
|
||||||
|
"shepherd-util",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shepherd-store"
|
name = "shepherd-store"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -813,6 +1322,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
|
|
@ -846,6 +1361,25 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-deps"
|
||||||
|
version = "7.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-expr",
|
||||||
|
"heck",
|
||||||
|
"pkg-config",
|
||||||
|
"toml 0.9.10+spec-1.1.0",
|
||||||
|
"version-compare",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "target-lexicon"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.24.0"
|
version = "3.24.0"
|
||||||
|
|
@ -923,9 +1457,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime",
|
"toml_datetime 0.6.11",
|
||||||
"toml_edit",
|
"toml_edit 0.22.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.10+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned 1.0.4",
|
||||||
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -937,6 +1486,15 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.7.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.22.27"
|
version = "0.22.27"
|
||||||
|
|
@ -945,18 +1503,45 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime",
|
"toml_datetime 0.6.11",
|
||||||
"toml_write",
|
"toml_write",
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.23.10+spec-1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
|
"toml_parser",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.0.6+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_write"
|
name = "toml_write"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.0.6+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|
@ -1067,6 +1652,12 @@ version = "0.2.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version-compare"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|
@ -1192,13 +1783,22 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.53.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1210,6 +1810,22 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
|
"windows_aarch64_msvc 0.52.6",
|
||||||
|
"windows_i686_gnu 0.52.6",
|
||||||
|
"windows_i686_gnullvm 0.52.6",
|
||||||
|
"windows_i686_msvc 0.52.6",
|
||||||
|
"windows_x86_64_gnu 0.52.6",
|
||||||
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
|
"windows_x86_64_msvc 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.53.5"
|
version = "0.53.5"
|
||||||
|
|
@ -1217,58 +1833,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.53.1",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.53.1",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.53.1",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.53.1",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.53.1",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.53.1",
|
||||||
"windows_x86_64_msvc",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
|
|
@ -1290,6 +1954,12 @@ version = "0.46.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xml-rs"
|
||||||
|
version = "0.8.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.31"
|
version = "0.8.31"
|
||||||
|
|
|
||||||
17
Cargo.toml
17
Cargo.toml
|
|
@ -10,14 +10,16 @@ members = [
|
||||||
"crates/shepherd-host-linux",
|
"crates/shepherd-host-linux",
|
||||||
"crates/shepherd-ipc",
|
"crates/shepherd-ipc",
|
||||||
"crates/shepherdd",
|
"crates/shepherdd",
|
||||||
|
"crates/shepherd-launcher-ui",
|
||||||
|
"crates/shepherd-hud",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "GPL-3.0"
|
||||||
authors = ["Shepherd Contributors"]
|
authors = ["Albert Armea"]
|
||||||
repository = "https://github.com/shepherd-project/shepherdd"
|
repository = "https://github.com/aarmea/shepherd-launcher"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Internal crates
|
# Internal crates
|
||||||
|
|
@ -59,5 +61,12 @@ bitflags = "2.4"
|
||||||
# Unix-specific
|
# Unix-specific
|
||||||
nix = { version = "0.29", features = ["signal", "process", "user", "socket"] }
|
nix = { version = "0.29", features = ["signal", "process", "user", "socket"] }
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
|
||||||
|
# GTK4 UI
|
||||||
|
gtk4 = "0.9"
|
||||||
|
gtk4-layer-shell = "0.4"
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
tempfile = "3.9"
|
tempfile = "3.9"
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,7 @@ mod tests {
|
||||||
policy_loaded: true,
|
policy_loaded: true,
|
||||||
current_session: None,
|
current_session: None,
|
||||||
entry_count: 5,
|
entry_count: 5,
|
||||||
|
entries: vec![],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,9 @@ pub struct DaemonStateSnapshot {
|
||||||
pub policy_loaded: bool,
|
pub policy_loaded: bool,
|
||||||
pub current_session: Option<SessionInfo>,
|
pub current_session: Option<SessionInfo>,
|
||||||
pub entry_count: usize,
|
pub entry_count: usize,
|
||||||
|
/// Available entries for UI display
|
||||||
|
#[serde(default)]
|
||||||
|
pub entries: Vec<EntryView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Role for authorization
|
/// Role for authorization
|
||||||
|
|
|
||||||
|
|
@ -456,11 +456,15 @@ impl CoreEngine {
|
||||||
s.to_session_info(MonotonicInstant::now())
|
s.to_session_info(MonotonicInstant::now())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build entry views for the snapshot
|
||||||
|
let entries = self.list_entries(Local::now());
|
||||||
|
|
||||||
DaemonStateSnapshot {
|
DaemonStateSnapshot {
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
policy_loaded: true,
|
policy_loaded: true,
|
||||||
current_session,
|
current_session,
|
||||||
entry_count: self.policy.entries.len(),
|
entry_count: self.policy.entries.len(),
|
||||||
|
entries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
29
crates/shepherd-hud/Cargo.toml
Normal file
29
crates/shepherd-hud/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[package]
|
||||||
|
name = "shepherd-hud"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "GTK4 HUD overlay for shepherdd - always-visible status bar"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "shepherd-hud"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
shepherd-api = { workspace = true }
|
||||||
|
shepherd-ipc = { workspace = true }
|
||||||
|
shepherd-util = { workspace = true }
|
||||||
|
|
||||||
|
gtk4 = { workspace = true }
|
||||||
|
gtk4-layer-shell = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
459
crates/shepherd-hud/src/app.rs
Normal file
459
crates/shepherd-hud/src/app.rs
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
//! HUD Application
|
||||||
|
//!
|
||||||
|
//! The main GTK4 application for the HUD overlay.
|
||||||
|
//! Uses gtk4-layer-shell to create an always-visible overlay.
|
||||||
|
|
||||||
|
use crate::battery::BatteryStatus;
|
||||||
|
use crate::state::{SessionState, SharedState};
|
||||||
|
use crate::time_display::TimeDisplay;
|
||||||
|
use crate::volume::VolumeStatus;
|
||||||
|
use gtk4::glib;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||||
|
use shepherd_api::commands::Command;
|
||||||
|
use shepherd_ipc::IpcClient;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
/// The HUD application
|
||||||
|
pub struct HudApp {
|
||||||
|
app: gtk4::Application,
|
||||||
|
socket_path: PathBuf,
|
||||||
|
anchor: String,
|
||||||
|
height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HudApp {
|
||||||
|
pub fn new(socket_path: PathBuf, anchor: String, height: i32) -> Self {
|
||||||
|
let app = gtk4::Application::builder()
|
||||||
|
.application_id("org.shepherd.hud")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
app,
|
||||||
|
socket_path,
|
||||||
|
anchor,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&self) -> i32 {
|
||||||
|
let socket_path = self.socket_path.clone();
|
||||||
|
let anchor = self.anchor.clone();
|
||||||
|
let height = self.height;
|
||||||
|
|
||||||
|
self.app.connect_activate(move |app| {
|
||||||
|
let state = SharedState::new();
|
||||||
|
let window = build_hud_window(app, &anchor, height, state.clone());
|
||||||
|
|
||||||
|
// Start the IPC event listener
|
||||||
|
let state_clone = state.clone();
|
||||||
|
let socket_clone = socket_path.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = run_event_loop(socket_clone, state_clone) {
|
||||||
|
tracing::error!("Event loop error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start periodic updates for battery/volume
|
||||||
|
start_metrics_updates(state.clone());
|
||||||
|
|
||||||
|
// Subscribe to state changes
|
||||||
|
let window_clone = window.clone();
|
||||||
|
let state_clone = state.clone();
|
||||||
|
glib::timeout_add_local(Duration::from_millis(100), move || {
|
||||||
|
let session_state = state_clone.session_state();
|
||||||
|
let visible = session_state.is_visible();
|
||||||
|
window_clone.set_visible(visible);
|
||||||
|
glib::ControlFlow::Continue
|
||||||
|
});
|
||||||
|
|
||||||
|
window.present();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.app.run().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_hud_window(
|
||||||
|
app: >k4::Application,
|
||||||
|
anchor: &str,
|
||||||
|
height: i32,
|
||||||
|
state: SharedState,
|
||||||
|
) -> gtk4::ApplicationWindow {
|
||||||
|
let window = gtk4::ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.default_height(height)
|
||||||
|
.decorated(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Initialize layer shell
|
||||||
|
window.init_layer_shell();
|
||||||
|
window.set_layer(Layer::Overlay);
|
||||||
|
window.set_namespace("shepherd-hud");
|
||||||
|
|
||||||
|
// Set anchors based on position
|
||||||
|
match anchor {
|
||||||
|
"bottom" => {
|
||||||
|
window.set_anchor(Edge::Bottom, true);
|
||||||
|
window.set_anchor(Edge::Left, true);
|
||||||
|
window.set_anchor(Edge::Right, true);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Default to top
|
||||||
|
window.set_anchor(Edge::Top, true);
|
||||||
|
window.set_anchor(Edge::Left, true);
|
||||||
|
window.set_anchor(Edge::Right, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set exclusive zone so other windows don't overlap
|
||||||
|
window.set_exclusive_zone(height);
|
||||||
|
|
||||||
|
// Load CSS
|
||||||
|
load_css();
|
||||||
|
|
||||||
|
// Build the HUD content
|
||||||
|
let content = build_hud_content(state);
|
||||||
|
window.set_child(Some(&content));
|
||||||
|
|
||||||
|
window
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_hud_content(state: SharedState) -> gtk4::Box {
|
||||||
|
let container = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.spacing(16)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(6)
|
||||||
|
.margin_bottom(6)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
container.add_css_class("hud-bar");
|
||||||
|
|
||||||
|
// Left section: App name and time
|
||||||
|
let left_box = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.spacing(12)
|
||||||
|
.hexpand(true)
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let app_label = gtk4::Label::new(Some("No session"));
|
||||||
|
app_label.add_css_class("app-name");
|
||||||
|
left_box.append(&app_label);
|
||||||
|
|
||||||
|
let time_display = TimeDisplay::new();
|
||||||
|
left_box.append(&time_display);
|
||||||
|
|
||||||
|
container.append(&left_box);
|
||||||
|
|
||||||
|
// Center section: Warning banner (hidden by default)
|
||||||
|
let warning_box = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.spacing(8)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let warning_icon = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||||
|
warning_icon.set_pixel_size(20);
|
||||||
|
warning_box.append(&warning_icon);
|
||||||
|
|
||||||
|
let warning_label = gtk4::Label::new(Some("Time running out!"));
|
||||||
|
warning_label.add_css_class("warning-text");
|
||||||
|
warning_box.append(&warning_label);
|
||||||
|
|
||||||
|
warning_box.add_css_class("warning-banner");
|
||||||
|
container.append(&warning_box);
|
||||||
|
|
||||||
|
// Right section: System indicators and close button
|
||||||
|
let right_box = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.spacing(8)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Volume indicator
|
||||||
|
let volume_button = gtk4::Button::builder()
|
||||||
|
.icon_name("audio-volume-medium-symbolic")
|
||||||
|
.has_frame(false)
|
||||||
|
.build();
|
||||||
|
volume_button.add_css_class("indicator-button");
|
||||||
|
volume_button.connect_clicked(|_| {
|
||||||
|
if let Err(e) = VolumeStatus::toggle_mute() {
|
||||||
|
tracing::error!("Failed to toggle mute: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
right_box.append(&volume_button);
|
||||||
|
|
||||||
|
// Battery indicator
|
||||||
|
let battery_box = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.spacing(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let battery_icon = gtk4::Image::from_icon_name("battery-good-symbolic");
|
||||||
|
battery_icon.set_pixel_size(20);
|
||||||
|
battery_box.append(&battery_icon);
|
||||||
|
|
||||||
|
let battery_label = gtk4::Label::new(Some("--%"));
|
||||||
|
battery_label.add_css_class("battery-label");
|
||||||
|
battery_box.append(&battery_label);
|
||||||
|
|
||||||
|
right_box.append(&battery_box);
|
||||||
|
|
||||||
|
// Pause button
|
||||||
|
let pause_button = gtk4::Button::builder()
|
||||||
|
.icon_name("media-playback-pause-symbolic")
|
||||||
|
.has_frame(false)
|
||||||
|
.tooltip_text("Pause session")
|
||||||
|
.build();
|
||||||
|
pause_button.add_css_class("control-button");
|
||||||
|
|
||||||
|
let state_for_pause = state.clone();
|
||||||
|
pause_button.connect_clicked(move |btn| {
|
||||||
|
let session_state = state_for_pause.session_state();
|
||||||
|
if let Some(session_id) = session_state.session_id() {
|
||||||
|
// Toggle pause state - this would need to send command to daemon
|
||||||
|
// For now, just log
|
||||||
|
tracing::info!("Pause toggled for session {}", session_id);
|
||||||
|
}
|
||||||
|
// Toggle icon
|
||||||
|
let icon_name = btn.icon_name().unwrap_or_default();
|
||||||
|
if icon_name == "media-playback-pause-symbolic" {
|
||||||
|
btn.set_icon_name("media-playback-start-symbolic");
|
||||||
|
btn.set_tooltip_text(Some("Resume session"));
|
||||||
|
} else {
|
||||||
|
btn.set_icon_name("media-playback-pause-symbolic");
|
||||||
|
btn.set_tooltip_text(Some("Pause session"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
right_box.append(&pause_button);
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
let close_button = gtk4::Button::builder()
|
||||||
|
.icon_name("window-close-symbolic")
|
||||||
|
.has_frame(false)
|
||||||
|
.tooltip_text("End session")
|
||||||
|
.build();
|
||||||
|
close_button.add_css_class("close-button");
|
||||||
|
|
||||||
|
let state_for_close = state.clone();
|
||||||
|
close_button.connect_clicked(move |_| {
|
||||||
|
let session_state = state_for_close.session_state();
|
||||||
|
if let Some(session_id) = session_state.session_id() {
|
||||||
|
tracing::info!("Requesting end session for {}", session_id);
|
||||||
|
// This would need to send EndSession command to daemon
|
||||||
|
}
|
||||||
|
});
|
||||||
|
right_box.append(&close_button);
|
||||||
|
|
||||||
|
container.append(&right_box);
|
||||||
|
|
||||||
|
// Set up state updates
|
||||||
|
let app_label_clone = app_label.clone();
|
||||||
|
let time_display_clone = time_display.clone();
|
||||||
|
let warning_box_clone = warning_box.clone();
|
||||||
|
let warning_label_clone = warning_label.clone();
|
||||||
|
let battery_icon_clone = battery_icon.clone();
|
||||||
|
let battery_label_clone = battery_label.clone();
|
||||||
|
let volume_button_clone = volume_button.clone();
|
||||||
|
|
||||||
|
glib::timeout_add_local(Duration::from_millis(500), move || {
|
||||||
|
// Update session state
|
||||||
|
let session_state = state.session_state();
|
||||||
|
match &session_state {
|
||||||
|
SessionState::NoSession => {
|
||||||
|
app_label_clone.set_text("No session");
|
||||||
|
time_display_clone.set_remaining(None);
|
||||||
|
warning_box_clone.set_visible(false);
|
||||||
|
}
|
||||||
|
SessionState::Active {
|
||||||
|
entry_name,
|
||||||
|
time_remaining_secs,
|
||||||
|
paused,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
app_label_clone.set_text(entry_name);
|
||||||
|
time_display_clone.set_remaining(*time_remaining_secs);
|
||||||
|
time_display_clone.set_paused(*paused);
|
||||||
|
warning_box_clone.set_visible(false);
|
||||||
|
}
|
||||||
|
SessionState::Warning {
|
||||||
|
entry_name,
|
||||||
|
time_remaining_secs,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
app_label_clone.set_text(entry_name);
|
||||||
|
time_display_clone.set_remaining(Some(*time_remaining_secs));
|
||||||
|
warning_label_clone.set_text(&format!(
|
||||||
|
"Only {} seconds remaining!",
|
||||||
|
time_remaining_secs
|
||||||
|
));
|
||||||
|
warning_box_clone.set_visible(true);
|
||||||
|
}
|
||||||
|
SessionState::Ending { reason, .. } => {
|
||||||
|
app_label_clone.set_text("Session ending...");
|
||||||
|
warning_label_clone.set_text(reason);
|
||||||
|
warning_box_clone.set_visible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update battery
|
||||||
|
let battery = BatteryStatus::read();
|
||||||
|
battery_icon_clone.set_icon_name(Some(battery.icon_name()));
|
||||||
|
if let Some(percent) = battery.percent {
|
||||||
|
battery_label_clone.set_text(&format!("{}%", percent));
|
||||||
|
} else {
|
||||||
|
battery_label_clone.set_text("--%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update volume
|
||||||
|
let volume = VolumeStatus::read();
|
||||||
|
volume_button_clone.set_icon_name(volume.icon_name());
|
||||||
|
|
||||||
|
glib::ControlFlow::Continue
|
||||||
|
});
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_css() {
|
||||||
|
let css = r#"
|
||||||
|
.hud-bar {
|
||||||
|
background-color: rgba(30, 30, 30, 0.95);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #88c0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display.time-warning {
|
||||||
|
color: #ebcb8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display.time-critical {
|
||||||
|
color: #bf616a;
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-banner {
|
||||||
|
background-color: rgba(235, 203, 139, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
color: #ebcb8b;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-button,
|
||||||
|
.control-button {
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-button:hover,
|
||||||
|
.control-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #bf616a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background-color: rgba(191, 97, 106, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a3be8c;
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let provider = gtk4::CssProvider::new();
|
||||||
|
provider.load_from_data(css);
|
||||||
|
|
||||||
|
gtk4::style_context_add_provider_for_display(
|
||||||
|
>k4::gdk::Display::default().expect("Could not get display"),
|
||||||
|
&provider,
|
||||||
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_event_loop(socket_path: PathBuf, state: SharedState) -> anyhow::Result<()> {
|
||||||
|
let rt = Runtime::new()?;
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
loop {
|
||||||
|
tracing::info!("Connecting to shepherdd at {:?}", socket_path);
|
||||||
|
|
||||||
|
match IpcClient::connect(&socket_path).await {
|
||||||
|
Ok(client) => {
|
||||||
|
tracing::info!("Connected to shepherdd");
|
||||||
|
|
||||||
|
let mut stream = match client.subscribe().await {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to subscribe: {}", e);
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match stream.next().await {
|
||||||
|
Ok(event) => {
|
||||||
|
tracing::debug!("Received event: {:?}", event);
|
||||||
|
state.handle_event(&event);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Event stream error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to connect to shepherdd: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before reconnecting
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_metrics_updates(_state: SharedState) {
|
||||||
|
// Battery and volume are now updated in the main UI loop
|
||||||
|
// This function could be used for more expensive operations
|
||||||
|
// that don't need to run as frequently
|
||||||
|
}
|
||||||
130
crates/shepherd-hud/src/battery.rs
Normal file
130
crates/shepherd-hud/src/battery.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
//! Battery monitoring module
|
||||||
|
//!
|
||||||
|
//! Monitors battery status via sysfs or UPower D-Bus interface.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Battery status
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct BatteryStatus {
|
||||||
|
/// Battery percentage (0-100)
|
||||||
|
pub percent: Option<u8>,
|
||||||
|
/// Whether the battery is charging
|
||||||
|
pub charging: bool,
|
||||||
|
/// Whether AC power is connected
|
||||||
|
pub ac_connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BatteryStatus {
|
||||||
|
/// Read battery status from sysfs
|
||||||
|
pub fn read() -> Self {
|
||||||
|
let mut status = BatteryStatus::default();
|
||||||
|
|
||||||
|
// Try to find a battery in /sys/class/power_supply
|
||||||
|
let power_supply = Path::new("/sys/class/power_supply");
|
||||||
|
if !power_supply.exists() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(power_supply) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
|
||||||
|
// Check for battery
|
||||||
|
if name_str.starts_with("BAT") {
|
||||||
|
if let Some((percent, charging)) = read_battery_info(&path) {
|
||||||
|
status.percent = Some(percent);
|
||||||
|
status.charging = charging;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for AC adapter
|
||||||
|
if name_str.starts_with("AC") || name_str.contains("ADP") {
|
||||||
|
if let Some(online) = read_ac_status(&path) {
|
||||||
|
status.ac_connected = online;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get an icon name for the current battery status
|
||||||
|
pub fn icon_name(&self) -> &'static str {
|
||||||
|
match (self.percent, self.charging) {
|
||||||
|
(None, _) => "battery-missing-symbolic",
|
||||||
|
(Some(p), true) if p >= 90 => "battery-full-charging-symbolic",
|
||||||
|
(Some(p), true) if p >= 60 => "battery-good-charging-symbolic",
|
||||||
|
(Some(p), true) if p >= 30 => "battery-low-charging-symbolic",
|
||||||
|
(Some(_), true) => "battery-caution-charging-symbolic",
|
||||||
|
(Some(p), false) if p >= 90 => "battery-full-symbolic",
|
||||||
|
(Some(p), false) if p >= 60 => "battery-good-symbolic",
|
||||||
|
(Some(p), false) if p >= 30 => "battery-low-symbolic",
|
||||||
|
(Some(p), false) if p >= 10 => "battery-caution-symbolic",
|
||||||
|
(Some(_), false) => "battery-empty-symbolic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if battery is critically low
|
||||||
|
pub fn is_critical(&self) -> bool {
|
||||||
|
matches!(self.percent, Some(p) if p < 10 && !self.charging)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_battery_info(path: &Path) -> Option<(u8, bool)> {
|
||||||
|
// Read capacity
|
||||||
|
let capacity_path = path.join("capacity");
|
||||||
|
let capacity: u8 = fs::read_to_string(&capacity_path)
|
||||||
|
.ok()?
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
// Read status
|
||||||
|
let status_path = path.join("status");
|
||||||
|
let status = fs::read_to_string(&status_path).ok()?;
|
||||||
|
let charging = status.trim().eq_ignore_ascii_case("charging")
|
||||||
|
|| status.trim().eq_ignore_ascii_case("full");
|
||||||
|
|
||||||
|
Some((capacity.min(100), charging))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_ac_status(path: &Path) -> Option<bool> {
|
||||||
|
let online_path = path.join("online");
|
||||||
|
let online = fs::read_to_string(&online_path).ok()?;
|
||||||
|
Some(online.trim() == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_battery_icon_names() {
|
||||||
|
let status = BatteryStatus {
|
||||||
|
percent: Some(95),
|
||||||
|
charging: false,
|
||||||
|
ac_connected: false,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "battery-full-symbolic");
|
||||||
|
|
||||||
|
let status = BatteryStatus {
|
||||||
|
percent: Some(50),
|
||||||
|
charging: true,
|
||||||
|
ac_connected: true,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "battery-low-charging-symbolic");
|
||||||
|
|
||||||
|
let status = BatteryStatus {
|
||||||
|
percent: Some(5),
|
||||||
|
charging: false,
|
||||||
|
ac_connected: false,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "battery-empty-symbolic");
|
||||||
|
assert!(status.is_critical());
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/shepherd-hud/src/main.rs
Normal file
58
crates/shepherd-hud/src/main.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
//! Shepherd HUD - Always-visible overlay
|
||||||
|
//!
|
||||||
|
//! This is the heads-up display that remains visible during active sessions.
|
||||||
|
//! It shows time remaining, battery, volume, and provides session controls.
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod battery;
|
||||||
|
mod state;
|
||||||
|
mod time_display;
|
||||||
|
mod volume;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
/// Shepherd HUD - Always-visible overlay for shepherdd sessions
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "shepherd-hud")]
|
||||||
|
#[command(about = "GTK4 layer-shell HUD for shepherdd", long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Socket path for shepherdd connection
|
||||||
|
#[arg(short, long, default_value = "/run/shepherdd/shepherdd.sock")]
|
||||||
|
socket: PathBuf,
|
||||||
|
|
||||||
|
/// Log level
|
||||||
|
#[arg(short, long, default_value = "info")]
|
||||||
|
log_level: String,
|
||||||
|
|
||||||
|
/// Anchor position (top, bottom)
|
||||||
|
#[arg(short, long, default_value = "top")]
|
||||||
|
anchor: String,
|
||||||
|
|
||||||
|
/// Height of the HUD bar in pixels
|
||||||
|
#[arg(long, default_value = "48")]
|
||||||
|
height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new(&args.log_level)),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("Starting Shepherd HUD");
|
||||||
|
|
||||||
|
// Run GTK application
|
||||||
|
let application = app::HudApp::new(args.socket, args.anchor, args.height);
|
||||||
|
let exit_code = application.run();
|
||||||
|
|
||||||
|
std::process::exit(exit_code);
|
||||||
|
}
|
||||||
238
crates/shepherd-hud/src/state.rs
Normal file
238
crates/shepherd-hud/src/state.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
//! State management for the HUD
|
||||||
|
//!
|
||||||
|
//! The HUD subscribes to events from shepherdd and tracks session state.
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
use shepherd_api::events::{Event, EventPayload};
|
||||||
|
use shepherd_api::types::SessionEndReason;
|
||||||
|
use shepherd_util::{EntryId, SessionId};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
/// The current state of the session as seen by the HUD
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SessionState {
|
||||||
|
/// No active session - HUD should be hidden
|
||||||
|
NoSession,
|
||||||
|
|
||||||
|
/// Session is active
|
||||||
|
Active {
|
||||||
|
session_id: SessionId,
|
||||||
|
entry_id: EntryId,
|
||||||
|
entry_name: String,
|
||||||
|
started_at: std::time::Instant,
|
||||||
|
time_limit_secs: Option<u64>,
|
||||||
|
time_remaining_secs: Option<u64>,
|
||||||
|
paused: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Warning shown - time running low
|
||||||
|
Warning {
|
||||||
|
session_id: SessionId,
|
||||||
|
entry_id: EntryId,
|
||||||
|
entry_name: String,
|
||||||
|
time_remaining_secs: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Session is ending
|
||||||
|
Ending {
|
||||||
|
session_id: SessionId,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionState {
|
||||||
|
/// Check if there's an active or warning session
|
||||||
|
pub fn is_visible(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
SessionState::Active { .. } | SessionState::Warning { .. } | SessionState::Ending { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current session ID if any
|
||||||
|
pub fn session_id(&self) -> Option<&SessionId> {
|
||||||
|
match self {
|
||||||
|
SessionState::NoSession => None,
|
||||||
|
SessionState::Active { session_id, .. } => Some(session_id),
|
||||||
|
SessionState::Warning { session_id, .. } => Some(session_id),
|
||||||
|
SessionState::Ending { session_id, .. } => Some(session_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System metrics for display
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SystemMetrics {
|
||||||
|
/// Battery percentage (0-100)
|
||||||
|
pub battery_percent: Option<u8>,
|
||||||
|
/// Whether battery is charging
|
||||||
|
pub battery_charging: bool,
|
||||||
|
/// Volume percentage (0-100)
|
||||||
|
pub volume_percent: Option<u8>,
|
||||||
|
/// Whether volume is muted
|
||||||
|
pub volume_muted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared state for the HUD
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SharedState {
|
||||||
|
/// Session state sender
|
||||||
|
session_tx: Arc<watch::Sender<SessionState>>,
|
||||||
|
/// Session state receiver
|
||||||
|
session_rx: watch::Receiver<SessionState>,
|
||||||
|
/// System metrics sender
|
||||||
|
metrics_tx: Arc<watch::Sender<SystemMetrics>>,
|
||||||
|
/// System metrics receiver
|
||||||
|
metrics_rx: watch::Receiver<SystemMetrics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (session_tx, session_rx) = watch::channel(SessionState::NoSession);
|
||||||
|
let (metrics_tx, metrics_rx) = watch::channel(SystemMetrics::default());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
session_tx: Arc::new(session_tx),
|
||||||
|
session_rx,
|
||||||
|
metrics_tx: Arc::new(metrics_tx),
|
||||||
|
metrics_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current session state
|
||||||
|
pub fn session_state(&self) -> SessionState {
|
||||||
|
self.session_rx.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to session state changes
|
||||||
|
pub fn subscribe_session(&self) -> watch::Receiver<SessionState> {
|
||||||
|
self.session_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to metrics changes
|
||||||
|
pub fn subscribe_metrics(&self) -> watch::Receiver<SystemMetrics> {
|
||||||
|
self.metrics_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update session state
|
||||||
|
pub fn set_session_state(&self, state: SessionState) {
|
||||||
|
let _ = self.session_tx.send(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update system metrics
|
||||||
|
pub fn set_metrics(&self, metrics: SystemMetrics) {
|
||||||
|
let _ = self.metrics_tx.send(metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update time remaining for current session
|
||||||
|
pub fn update_time_remaining(&self, remaining_secs: u64) {
|
||||||
|
self.session_tx.send_modify(|state| {
|
||||||
|
if let SessionState::Active {
|
||||||
|
time_remaining_secs,
|
||||||
|
..
|
||||||
|
} = state
|
||||||
|
{
|
||||||
|
*time_remaining_secs = Some(remaining_secs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an event from shepherdd
|
||||||
|
pub fn handle_event(&self, event: &Event) {
|
||||||
|
match &event.payload {
|
||||||
|
EventPayload::SessionStarted {
|
||||||
|
session_id,
|
||||||
|
entry_id,
|
||||||
|
label,
|
||||||
|
deadline,
|
||||||
|
} => {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let time_remaining = if *deadline > now {
|
||||||
|
(*deadline - now).num_seconds().max(0) as u64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
self.set_session_state(SessionState::Active {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
entry_id: entry_id.clone(),
|
||||||
|
entry_name: label.clone(),
|
||||||
|
started_at: std::time::Instant::now(),
|
||||||
|
time_limit_secs: Some(time_remaining),
|
||||||
|
time_remaining_secs: Some(time_remaining),
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
EventPayload::SessionEnded { session_id, .. } => {
|
||||||
|
if self.session_state().session_id() == Some(session_id) {
|
||||||
|
self.set_session_state(SessionState::NoSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EventPayload::WarningIssued {
|
||||||
|
session_id,
|
||||||
|
time_remaining,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.session_tx.send_modify(|state| {
|
||||||
|
if let SessionState::Active {
|
||||||
|
session_id: sid,
|
||||||
|
entry_id,
|
||||||
|
entry_name,
|
||||||
|
..
|
||||||
|
} = state
|
||||||
|
{
|
||||||
|
if sid == session_id {
|
||||||
|
*state = SessionState::Warning {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
entry_id: entry_id.clone(),
|
||||||
|
entry_name: entry_name.clone(),
|
||||||
|
time_remaining_secs: time_remaining.as_secs(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
EventPayload::SessionExpiring { session_id } => {
|
||||||
|
if self.session_state().session_id() == Some(session_id) {
|
||||||
|
self.set_session_state(SessionState::Ending {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
reason: "Time expired".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EventPayload::StateChanged(snapshot) => {
|
||||||
|
if let Some(session) = &snapshot.current_session {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let time_remaining = if session.deadline > now {
|
||||||
|
(session.deadline - now).num_seconds().max(0) as u64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
self.set_session_state(SessionState::Active {
|
||||||
|
session_id: session.session_id.clone(),
|
||||||
|
entry_id: session.entry_id.clone(),
|
||||||
|
entry_name: session.label.clone(),
|
||||||
|
started_at: std::time::Instant::now(),
|
||||||
|
time_limit_secs: Some(time_remaining),
|
||||||
|
time_remaining_secs: Some(time_remaining),
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.set_session_state(SessionState::NoSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SharedState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
154
crates/shepherd-hud/src/time_display.rs
Normal file
154
crates/shepherd-hud/src/time_display.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
//! Time display widget
|
||||||
|
//!
|
||||||
|
//! Shows elapsed time, remaining time, or countdown.
|
||||||
|
|
||||||
|
use gtk4::glib;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::subclass::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TimeDisplay {
|
||||||
|
pub label: RefCell<Option<gtk4::Label>>,
|
||||||
|
pub total_secs: RefCell<Option<u64>>,
|
||||||
|
pub remaining_secs: RefCell<Option<u64>>,
|
||||||
|
pub paused: RefCell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for TimeDisplay {
|
||||||
|
const NAME: &'static str = "ShepherdTimeDisplay";
|
||||||
|
type Type = super::TimeDisplay;
|
||||||
|
type ParentType = gtk4::Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for TimeDisplay {
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
|
||||||
|
let obj = self.obj();
|
||||||
|
obj.set_orientation(gtk4::Orientation::Horizontal);
|
||||||
|
obj.set_spacing(4);
|
||||||
|
|
||||||
|
// Time icon
|
||||||
|
let icon = gtk4::Image::from_icon_name("preferences-system-time-symbolic");
|
||||||
|
icon.set_pixel_size(20);
|
||||||
|
obj.append(&icon);
|
||||||
|
|
||||||
|
// Time label
|
||||||
|
let label = gtk4::Label::new(Some("--:--"));
|
||||||
|
label.add_css_class("time-display");
|
||||||
|
obj.append(&label);
|
||||||
|
|
||||||
|
*self.label.borrow_mut() = Some(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for TimeDisplay {}
|
||||||
|
impl BoxImpl for TimeDisplay {}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct TimeDisplay(ObjectSubclass<imp::TimeDisplay>)
|
||||||
|
@extends gtk4::Box, gtk4::Widget,
|
||||||
|
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeDisplay {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
glib::Object::builder().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the time limit in seconds
|
||||||
|
pub fn set_time_limit(&self, total_secs: Option<u64>) {
|
||||||
|
let imp = self.imp();
|
||||||
|
*imp.total_secs.borrow_mut() = total_secs;
|
||||||
|
self.update_display();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the remaining time in seconds
|
||||||
|
pub fn set_remaining(&self, remaining_secs: Option<u64>) {
|
||||||
|
let imp = self.imp();
|
||||||
|
*imp.remaining_secs.borrow_mut() = remaining_secs;
|
||||||
|
self.update_display();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set paused state
|
||||||
|
pub fn set_paused(&self, paused: bool) {
|
||||||
|
let imp = self.imp();
|
||||||
|
*imp.paused.borrow_mut() = paused;
|
||||||
|
self.update_display();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the display based on current state
|
||||||
|
fn update_display(&self) {
|
||||||
|
let imp = self.imp();
|
||||||
|
|
||||||
|
if let Some(label) = imp.label.borrow().as_ref() {
|
||||||
|
let remaining = *imp.remaining_secs.borrow();
|
||||||
|
let paused = *imp.paused.borrow();
|
||||||
|
|
||||||
|
let text = if let Some(secs) = remaining {
|
||||||
|
let formatted = format_duration(secs);
|
||||||
|
if paused {
|
||||||
|
format!("{} ⏸", formatted)
|
||||||
|
} else {
|
||||||
|
formatted
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"--:--".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
label.set_text(&text);
|
||||||
|
|
||||||
|
// Update styling based on remaining time
|
||||||
|
label.remove_css_class("time-warning");
|
||||||
|
label.remove_css_class("time-critical");
|
||||||
|
|
||||||
|
if let Some(secs) = remaining {
|
||||||
|
if secs <= 60 {
|
||||||
|
label.add_css_class("time-critical");
|
||||||
|
} else if secs <= 300 {
|
||||||
|
label.add_css_class("time-warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TimeDisplay {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a duration in seconds as HH:MM:SS or MM:SS
|
||||||
|
fn format_duration(secs: u64) -> String {
|
||||||
|
let hours = secs / 3600;
|
||||||
|
let minutes = (secs % 3600) / 60;
|
||||||
|
let seconds = secs % 60;
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||||
|
} else {
|
||||||
|
format!("{:02}:{:02}", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_duration() {
|
||||||
|
assert_eq!(format_duration(0), "00:00");
|
||||||
|
assert_eq!(format_duration(59), "00:59");
|
||||||
|
assert_eq!(format_duration(60), "01:00");
|
||||||
|
assert_eq!(format_duration(3599), "59:59");
|
||||||
|
assert_eq!(format_duration(3600), "01:00:00");
|
||||||
|
assert_eq!(format_duration(3661), "01:01:01");
|
||||||
|
}
|
||||||
|
}
|
||||||
131
crates/shepherd-hud/src/volume.rs
Normal file
131
crates/shepherd-hud/src/volume.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
//! Volume monitoring and control module
|
||||||
|
//!
|
||||||
|
//! Monitors and controls system volume via PulseAudio/PipeWire.
|
||||||
|
//! Uses the `pactl` command-line tool for simplicity.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Volume status
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct VolumeStatus {
|
||||||
|
/// Volume percentage (0-100+)
|
||||||
|
pub percent: u8,
|
||||||
|
/// Whether audio is muted
|
||||||
|
pub muted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeStatus {
|
||||||
|
/// Read volume status using pactl
|
||||||
|
pub fn read() -> Self {
|
||||||
|
let mut status = VolumeStatus::default();
|
||||||
|
|
||||||
|
// Get default sink info
|
||||||
|
if let Ok(output) = Command::new("pactl")
|
||||||
|
.args(["get-sink-volume", "@DEFAULT_SINK@"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if let Ok(stdout) = String::from_utf8(output.stdout) {
|
||||||
|
// Output looks like: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..."
|
||||||
|
if let Some(percent_str) = stdout.split('/').nth(1) {
|
||||||
|
if let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::<u8>() {
|
||||||
|
status.percent = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mute status
|
||||||
|
if let Ok(output) = Command::new("pactl")
|
||||||
|
.args(["get-sink-mute", "@DEFAULT_SINK@"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if let Ok(stdout) = String::from_utf8(output.stdout) {
|
||||||
|
// Output looks like: "Mute: yes" or "Mute: no"
|
||||||
|
status.muted = stdout.contains("yes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle mute state
|
||||||
|
pub fn toggle_mute() -> anyhow::Result<()> {
|
||||||
|
Command::new("pactl")
|
||||||
|
.args(["set-sink-mute", "@DEFAULT_SINK@", "toggle"])
|
||||||
|
.status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increase volume by a step
|
||||||
|
pub fn volume_up(step: u8) -> anyhow::Result<()> {
|
||||||
|
Command::new("pactl")
|
||||||
|
.args([
|
||||||
|
"set-sink-volume",
|
||||||
|
"@DEFAULT_SINK@",
|
||||||
|
&format!("+{}%", step),
|
||||||
|
])
|
||||||
|
.status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrease volume by a step
|
||||||
|
pub fn volume_down(step: u8) -> anyhow::Result<()> {
|
||||||
|
Command::new("pactl")
|
||||||
|
.args([
|
||||||
|
"set-sink-volume",
|
||||||
|
"@DEFAULT_SINK@",
|
||||||
|
&format!("-{}%", step),
|
||||||
|
])
|
||||||
|
.status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume to a specific percentage
|
||||||
|
pub fn set_volume(percent: u8) -> anyhow::Result<()> {
|
||||||
|
Command::new("pactl")
|
||||||
|
.args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)])
|
||||||
|
.status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get an icon name for the current volume status
|
||||||
|
pub fn icon_name(&self) -> &'static str {
|
||||||
|
if self.muted {
|
||||||
|
"audio-volume-muted-symbolic"
|
||||||
|
} else if self.percent == 0 {
|
||||||
|
"audio-volume-muted-symbolic"
|
||||||
|
} else if self.percent < 33 {
|
||||||
|
"audio-volume-low-symbolic"
|
||||||
|
} else if self.percent < 66 {
|
||||||
|
"audio-volume-medium-symbolic"
|
||||||
|
} else {
|
||||||
|
"audio-volume-high-symbolic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_volume_icon_names() {
|
||||||
|
let status = VolumeStatus {
|
||||||
|
percent: 0,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "audio-volume-muted-symbolic");
|
||||||
|
|
||||||
|
let status = VolumeStatus {
|
||||||
|
percent: 50,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "audio-volume-medium-symbolic");
|
||||||
|
|
||||||
|
let status = VolumeStatus {
|
||||||
|
percent: 100,
|
||||||
|
muted: true,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "audio-volume-muted-symbolic");
|
||||||
|
}
|
||||||
|
}
|
||||||
28
crates/shepherd-launcher-ui/Cargo.toml
Normal file
28
crates/shepherd-launcher-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[package]
|
||||||
|
name = "shepherd-launcher-ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "GTK4 launcher UI for shepherdd - main grid interface"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "shepherd-launcher"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
shepherd-api = { workspace = true }
|
||||||
|
shepherd-ipc = { workspace = true }
|
||||||
|
shepherd-util = { workspace = true }
|
||||||
|
|
||||||
|
gtk4 = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
349
crates/shepherd-launcher-ui/src/app.rs
Normal file
349
crates/shepherd-launcher-ui/src/app.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
//! Main GTK4 application for the launcher
|
||||||
|
|
||||||
|
use gtk4::glib;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use crate::client::{ClientCommand, CommandClient, DaemonClient};
|
||||||
|
use crate::grid::LauncherGrid;
|
||||||
|
use crate::state::{LauncherState, SharedState};
|
||||||
|
|
||||||
|
/// CSS styling for the launcher
|
||||||
|
const LAUNCHER_CSS: &str = r#"
|
||||||
|
window {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-grid {
|
||||||
|
padding: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-tile {
|
||||||
|
background-color: #16213e;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
min-width: 140px;
|
||||||
|
min-height: 140px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-tile:hover {
|
||||||
|
background-color: #1f3460;
|
||||||
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-tile:active {
|
||||||
|
background-color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-tile:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-label {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
color: #888888;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-label {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launching-spinner {
|
||||||
|
min-width: 64px;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-active-box {
|
||||||
|
padding: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-label {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub struct LauncherApp {
|
||||||
|
socket_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LauncherApp {
|
||||||
|
pub fn new(socket_path: PathBuf) -> Self {
|
||||||
|
Self { socket_path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&self) -> i32 {
|
||||||
|
let app = gtk4::Application::builder()
|
||||||
|
.application_id("org.shepherd.launcher")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let socket_path = self.socket_path.clone();
|
||||||
|
|
||||||
|
app.connect_activate(move |app| {
|
||||||
|
Self::build_ui(app, socket_path.clone());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.run().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ui(app: >k4::Application, socket_path: PathBuf) {
|
||||||
|
// Load CSS
|
||||||
|
let provider = gtk4::CssProvider::new();
|
||||||
|
provider.load_from_string(LAUNCHER_CSS);
|
||||||
|
gtk4::style_context_add_provider_for_display(
|
||||||
|
>k4::gdk::Display::default().expect("Could not get default display"),
|
||||||
|
&provider,
|
||||||
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create main window
|
||||||
|
let window = gtk4::ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.title("Shepherd Launcher")
|
||||||
|
.default_width(1280)
|
||||||
|
.default_height(720)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Make fullscreen
|
||||||
|
window.fullscreen();
|
||||||
|
|
||||||
|
// Create main stack for different views
|
||||||
|
let stack = gtk4::Stack::new();
|
||||||
|
stack.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||||
|
stack.set_transition_duration(300);
|
||||||
|
|
||||||
|
// Create views
|
||||||
|
let grid = LauncherGrid::new();
|
||||||
|
let loading_view = Self::create_loading_view();
|
||||||
|
let error_view = Self::create_error_view();
|
||||||
|
let session_view = Self::create_session_view();
|
||||||
|
let disconnected_view = Self::create_disconnected_view();
|
||||||
|
|
||||||
|
stack.add_named(&grid, Some("grid"));
|
||||||
|
stack.add_named(&loading_view, Some("loading"));
|
||||||
|
stack.add_named(&error_view.0, Some("error"));
|
||||||
|
stack.add_named(&session_view.0, Some("session"));
|
||||||
|
stack.add_named(&disconnected_view.0, Some("disconnected"));
|
||||||
|
|
||||||
|
window.set_child(Some(&stack));
|
||||||
|
|
||||||
|
// Create shared state
|
||||||
|
let state = SharedState::new();
|
||||||
|
let state_receiver = state.subscribe();
|
||||||
|
|
||||||
|
// Create tokio runtime for async operations
|
||||||
|
let runtime = Arc::new(Runtime::new().expect("Failed to create tokio runtime"));
|
||||||
|
|
||||||
|
// Create command channel
|
||||||
|
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
// Create command client for sending commands
|
||||||
|
let command_client = Arc::new(CommandClient::new(&socket_path));
|
||||||
|
|
||||||
|
// Connect grid launch callback
|
||||||
|
let cmd_client = command_client.clone();
|
||||||
|
let state_clone = state.clone();
|
||||||
|
let rt = runtime.clone();
|
||||||
|
grid.connect_launch(move |entry_id| {
|
||||||
|
info!(entry_id = %entry_id, "Launch requested");
|
||||||
|
state_clone.set(LauncherState::Launching {
|
||||||
|
entry_id: entry_id.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = cmd_client.clone();
|
||||||
|
let state = state_clone.clone();
|
||||||
|
let entry_id = entry_id.clone();
|
||||||
|
rt.spawn(async move {
|
||||||
|
match client.launch(&entry_id).await {
|
||||||
|
Ok(response) => {
|
||||||
|
debug!(response = ?response, "Launch response");
|
||||||
|
// State will be updated via events
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Launch failed");
|
||||||
|
state.set(LauncherState::Error {
|
||||||
|
message: format!("Launch failed: {}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect retry button
|
||||||
|
let cmd_client = command_client.clone();
|
||||||
|
let state_clone = state.clone();
|
||||||
|
let rt = runtime.clone();
|
||||||
|
disconnected_view.1.connect_clicked(move |_| {
|
||||||
|
info!("Retry connection requested");
|
||||||
|
state_clone.set(LauncherState::Connecting);
|
||||||
|
|
||||||
|
let client = cmd_client.clone();
|
||||||
|
let state = state_clone.clone();
|
||||||
|
rt.spawn(async move {
|
||||||
|
match client.get_state().await {
|
||||||
|
Ok(_) => {
|
||||||
|
// Will trigger state update
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Reconnect failed");
|
||||||
|
state.set(LauncherState::Disconnected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start daemon client in background
|
||||||
|
let state_for_client = state.clone();
|
||||||
|
let socket_for_client = socket_path.clone();
|
||||||
|
runtime.spawn(async move {
|
||||||
|
let client = DaemonClient::new(socket_for_client, state_for_client, command_rx);
|
||||||
|
client.run().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up state change handler
|
||||||
|
let stack_weak = stack.downgrade();
|
||||||
|
let grid_weak = grid.downgrade();
|
||||||
|
let error_label = error_view.1.clone();
|
||||||
|
let session_label = session_view.1.clone();
|
||||||
|
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let mut receiver = state_receiver;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
receiver.changed().await.ok();
|
||||||
|
|
||||||
|
let state = receiver.borrow().clone();
|
||||||
|
|
||||||
|
let Some(stack) = stack_weak.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let grid = grid_weak.upgrade();
|
||||||
|
|
||||||
|
match state {
|
||||||
|
LauncherState::Disconnected => {
|
||||||
|
stack.set_visible_child_name("disconnected");
|
||||||
|
}
|
||||||
|
LauncherState::Connecting => {
|
||||||
|
stack.set_visible_child_name("loading");
|
||||||
|
}
|
||||||
|
LauncherState::Idle { entries } => {
|
||||||
|
if let Some(grid) = grid {
|
||||||
|
grid.set_entries(entries);
|
||||||
|
grid.set_tiles_sensitive(true);
|
||||||
|
}
|
||||||
|
stack.set_visible_child_name("grid");
|
||||||
|
}
|
||||||
|
LauncherState::Launching { entry_id } => {
|
||||||
|
if let Some(grid) = grid {
|
||||||
|
grid.set_tiles_sensitive(false);
|
||||||
|
}
|
||||||
|
stack.set_visible_child_name("loading");
|
||||||
|
}
|
||||||
|
LauncherState::SessionActive {
|
||||||
|
session_id: _,
|
||||||
|
entry_label,
|
||||||
|
time_remaining: _,
|
||||||
|
} => {
|
||||||
|
session_label.set_text(&format!("Running: {}", entry_label));
|
||||||
|
stack.set_visible_child_name("session");
|
||||||
|
}
|
||||||
|
LauncherState::Error { message } => {
|
||||||
|
error_label.set_text(&message);
|
||||||
|
stack.set_visible_child_name("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_loading_view() -> gtk4::Box {
|
||||||
|
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
|
||||||
|
container.set_halign(gtk4::Align::Center);
|
||||||
|
container.set_valign(gtk4::Align::Center);
|
||||||
|
|
||||||
|
let spinner = gtk4::Spinner::new();
|
||||||
|
spinner.set_spinning(true);
|
||||||
|
spinner.add_css_class("launching-spinner");
|
||||||
|
container.append(&spinner);
|
||||||
|
|
||||||
|
let label = gtk4::Label::new(Some("Loading..."));
|
||||||
|
label.add_css_class("status-label");
|
||||||
|
container.append(&label);
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_error_view() -> (gtk4::Box, gtk4::Label) {
|
||||||
|
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
|
||||||
|
container.set_halign(gtk4::Align::Center);
|
||||||
|
container.set_valign(gtk4::Align::Center);
|
||||||
|
|
||||||
|
let icon = gtk4::Image::from_icon_name("dialog-error");
|
||||||
|
icon.set_pixel_size(64);
|
||||||
|
container.append(&icon);
|
||||||
|
|
||||||
|
let label = gtk4::Label::new(Some("An error occurred"));
|
||||||
|
label.add_css_class("error-label");
|
||||||
|
label.set_wrap(true);
|
||||||
|
label.set_max_width_chars(40);
|
||||||
|
container.append(&label);
|
||||||
|
|
||||||
|
(container, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_session_view() -> (gtk4::Box, gtk4::Label) {
|
||||||
|
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||||
|
container.set_halign(gtk4::Align::Center);
|
||||||
|
container.set_valign(gtk4::Align::Center);
|
||||||
|
container.add_css_class("session-active-box");
|
||||||
|
|
||||||
|
let label = gtk4::Label::new(Some("Session Active"));
|
||||||
|
label.add_css_class("session-label");
|
||||||
|
container.append(&label);
|
||||||
|
|
||||||
|
let hint = gtk4::Label::new(Some("Use the HUD to view time remaining"));
|
||||||
|
hint.add_css_class("status-label");
|
||||||
|
container.append(&hint);
|
||||||
|
|
||||||
|
(container, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_disconnected_view() -> (gtk4::Box, gtk4::Button) {
|
||||||
|
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||||
|
container.set_halign(gtk4::Align::Center);
|
||||||
|
container.set_valign(gtk4::Align::Center);
|
||||||
|
|
||||||
|
let icon = gtk4::Image::from_icon_name("network-offline");
|
||||||
|
icon.set_pixel_size(64);
|
||||||
|
container.append(&icon);
|
||||||
|
|
||||||
|
let label = gtk4::Label::new(Some("System not ready"));
|
||||||
|
label.add_css_class("status-label");
|
||||||
|
container.append(&label);
|
||||||
|
|
||||||
|
let retry_button = gtk4::Button::with_label("Retry");
|
||||||
|
retry_button.add_css_class("launcher-tile");
|
||||||
|
container.append(&retry_button);
|
||||||
|
|
||||||
|
(container, retry_button)
|
||||||
|
}
|
||||||
|
}
|
||||||
239
crates/shepherd-launcher-ui/src/client.rs
Normal file
239
crates/shepherd-launcher-ui/src/client.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
//! IPC client wrapper for the launcher UI
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use shepherd_api::{Command, Event, Response, ResponsePayload, ResponseResult};
|
||||||
|
use shepherd_ipc::IpcClient;
|
||||||
|
use shepherd_util::EntryId;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use crate::state::{LauncherState, SharedState};
|
||||||
|
|
||||||
|
/// Messages from UI to client task
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ClientCommand {
|
||||||
|
/// Request to launch an entry
|
||||||
|
Launch(EntryId),
|
||||||
|
/// Request to stop current session
|
||||||
|
StopCurrent,
|
||||||
|
/// Request fresh state
|
||||||
|
RefreshState,
|
||||||
|
/// Shutdown the client
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client connection manager
|
||||||
|
pub struct DaemonClient {
|
||||||
|
socket_path: std::path::PathBuf,
|
||||||
|
state: SharedState,
|
||||||
|
command_rx: mpsc::UnboundedReceiver<ClientCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DaemonClient {
|
||||||
|
pub fn new(
|
||||||
|
socket_path: impl AsRef<Path>,
|
||||||
|
state: SharedState,
|
||||||
|
command_rx: mpsc::UnboundedReceiver<ClientCommand>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
socket_path: socket_path.as_ref().to_path_buf(),
|
||||||
|
state,
|
||||||
|
command_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the client connection loop
|
||||||
|
pub async fn run(mut self) {
|
||||||
|
loop {
|
||||||
|
match self.connect_and_run().await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("Client loop exited normally");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Connection error");
|
||||||
|
self.state.set(LauncherState::Disconnected);
|
||||||
|
|
||||||
|
// Wait before reconnecting
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_and_run(&mut self) -> Result<()> {
|
||||||
|
self.state.set(LauncherState::Connecting);
|
||||||
|
|
||||||
|
info!(path = %self.socket_path.display(), "Connecting to daemon");
|
||||||
|
|
||||||
|
let mut client = IpcClient::connect(&self.socket_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to daemon")?;
|
||||||
|
|
||||||
|
info!("Connected to daemon");
|
||||||
|
|
||||||
|
// Get initial state
|
||||||
|
let response = client.send(Command::GetState).await?;
|
||||||
|
self.handle_response(response)?;
|
||||||
|
|
||||||
|
// Subscribe to events
|
||||||
|
let response = client.send(Command::SubscribeEvents).await?;
|
||||||
|
if let ResponseResult::Err(e) = response.result {
|
||||||
|
warn!(error = %e.message, "Failed to subscribe to events");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entries list
|
||||||
|
let response = client.send(Command::ListEntries { at_time: None }).await?;
|
||||||
|
self.handle_response(response)?;
|
||||||
|
|
||||||
|
// Now consume client for event stream
|
||||||
|
let mut events = client.subscribe().await?;
|
||||||
|
|
||||||
|
// Main event loop
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Handle commands from UI
|
||||||
|
Some(cmd) = self.command_rx.recv() => {
|
||||||
|
match cmd {
|
||||||
|
ClientCommand::Shutdown => {
|
||||||
|
info!("Shutdown requested");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ClientCommand::Launch(entry_id) => {
|
||||||
|
// We can't send commands after subscribing since client is consumed
|
||||||
|
// Need to reconnect for commands
|
||||||
|
warn!("Launch command received but cannot send after subscribe");
|
||||||
|
// For now, trigger a reconnect
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ClientCommand::StopCurrent => {
|
||||||
|
warn!("Stop command received but cannot send after subscribe");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ClientCommand::RefreshState => {
|
||||||
|
// Trigger reconnect to refresh
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle events from daemon
|
||||||
|
event_result = events.next() => {
|
||||||
|
match event_result {
|
||||||
|
Ok(event) => {
|
||||||
|
debug!(event = ?event, "Received event");
|
||||||
|
self.state.handle_event(event);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Event stream error");
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_response(&self, response: Response) -> Result<()> {
|
||||||
|
match response.result {
|
||||||
|
ResponseResult::Ok(payload) => {
|
||||||
|
match payload {
|
||||||
|
ResponsePayload::State(snapshot) => {
|
||||||
|
if let Some(session) = snapshot.current_session {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let time_remaining = if session.deadline > now {
|
||||||
|
(session.deadline - now).to_std().ok()
|
||||||
|
} else {
|
||||||
|
Some(Duration::ZERO)
|
||||||
|
};
|
||||||
|
self.state.set(LauncherState::SessionActive {
|
||||||
|
session_id: session.session_id,
|
||||||
|
entry_label: session.label,
|
||||||
|
time_remaining,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.state.set(LauncherState::Idle {
|
||||||
|
entries: snapshot.entries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::Entries(entries) => {
|
||||||
|
// Only update if we're in idle state
|
||||||
|
if matches!(self.state.get(), LauncherState::Idle { .. } | LauncherState::Connecting) {
|
||||||
|
self.state.set(LauncherState::Idle { entries });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::LaunchApproved { session_id, deadline } => {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let time_remaining = if deadline > now {
|
||||||
|
(deadline - now).to_std().ok()
|
||||||
|
} else {
|
||||||
|
Some(Duration::ZERO)
|
||||||
|
};
|
||||||
|
self.state.set(LauncherState::SessionActive {
|
||||||
|
session_id,
|
||||||
|
entry_label: "Starting...".into(),
|
||||||
|
time_remaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ResponsePayload::LaunchDenied { reasons } => {
|
||||||
|
let message = reasons
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.message.as_deref().unwrap_or("Denied"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
self.state.set(LauncherState::Error { message });
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
ResponseResult::Err(e) => {
|
||||||
|
self.state.set(LauncherState::Error {
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate command client for sending commands (not subscribed)
|
||||||
|
pub struct CommandClient {
|
||||||
|
socket_path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandClient {
|
||||||
|
pub fn new(socket_path: impl AsRef<Path>) -> Self {
|
||||||
|
Self {
|
||||||
|
socket_path: socket_path.as_ref().to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn launch(&self, entry_id: &EntryId) -> Result<Response> {
|
||||||
|
let mut client = IpcClient::connect(&self.socket_path).await?;
|
||||||
|
client.send(Command::Launch {
|
||||||
|
entry_id: entry_id.clone(),
|
||||||
|
}).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_current(&self) -> Result<Response> {
|
||||||
|
let mut client = IpcClient::connect(&self.socket_path).await?;
|
||||||
|
client.send(Command::StopCurrent {
|
||||||
|
mode: shepherd_api::StopMode::Graceful,
|
||||||
|
}).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_state(&self) -> Result<Response> {
|
||||||
|
let mut client = IpcClient::connect(&self.socket_path).await?;
|
||||||
|
client.send(Command::GetState).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_entries(&self) -> Result<Response> {
|
||||||
|
let mut client = IpcClient::connect(&self.socket_path).await?;
|
||||||
|
client.send(Command::ListEntries { at_time: None }).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
crates/shepherd-launcher-ui/src/grid.rs
Normal file
140
crates/shepherd-launcher-ui/src/grid.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
//! Grid widget containing launcher tiles
|
||||||
|
|
||||||
|
use gtk4::glib;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::subclass::prelude::*;
|
||||||
|
use shepherd_api::EntryView;
|
||||||
|
use shepherd_util::EntryId;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use crate::tile::LauncherTile;
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub struct LauncherGrid {
|
||||||
|
pub flow_box: gtk4::FlowBox,
|
||||||
|
pub tiles: RefCell<Vec<LauncherTile>>,
|
||||||
|
pub on_launch: RefCell<Option<Box<dyn Fn(EntryId) + 'static>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LauncherGrid {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
flow_box: gtk4::FlowBox::new(),
|
||||||
|
tiles: RefCell::new(Vec::new()),
|
||||||
|
on_launch: RefCell::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for LauncherGrid {
|
||||||
|
const NAME: &'static str = "ShepherdLauncherGrid";
|
||||||
|
type Type = super::LauncherGrid;
|
||||||
|
type ParentType = gtk4::Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for LauncherGrid {
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
|
||||||
|
let obj = self.obj();
|
||||||
|
obj.set_orientation(gtk4::Orientation::Vertical);
|
||||||
|
obj.set_halign(gtk4::Align::Fill);
|
||||||
|
obj.set_valign(gtk4::Align::Fill);
|
||||||
|
obj.set_hexpand(true);
|
||||||
|
obj.set_vexpand(true);
|
||||||
|
|
||||||
|
// Configure flow box
|
||||||
|
self.flow_box.set_homogeneous(true);
|
||||||
|
self.flow_box.set_selection_mode(gtk4::SelectionMode::None);
|
||||||
|
self.flow_box.set_max_children_per_line(6);
|
||||||
|
self.flow_box.set_min_children_per_line(2);
|
||||||
|
self.flow_box.set_row_spacing(24);
|
||||||
|
self.flow_box.set_column_spacing(24);
|
||||||
|
self.flow_box.set_halign(gtk4::Align::Center);
|
||||||
|
self.flow_box.set_valign(gtk4::Align::Center);
|
||||||
|
self.flow_box.set_hexpand(true);
|
||||||
|
self.flow_box.set_vexpand(true);
|
||||||
|
self.flow_box.add_css_class("launcher-grid");
|
||||||
|
|
||||||
|
// Wrap in a scrolled window
|
||||||
|
let scrolled = gtk4::ScrolledWindow::new();
|
||||||
|
scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
|
||||||
|
scrolled.set_child(Some(&self.flow_box));
|
||||||
|
scrolled.set_hexpand(true);
|
||||||
|
scrolled.set_vexpand(true);
|
||||||
|
|
||||||
|
obj.append(&scrolled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for LauncherGrid {}
|
||||||
|
impl BoxImpl for LauncherGrid {}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct LauncherGrid(ObjectSubclass<imp::LauncherGrid>)
|
||||||
|
@extends gtk4::Box, gtk4::Widget,
|
||||||
|
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LauncherGrid {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
glib::Object::builder().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the callback for when an entry is launched
|
||||||
|
pub fn connect_launch<F: Fn(EntryId) + 'static>(&self, callback: F) {
|
||||||
|
*self.imp().on_launch.borrow_mut() = Some(Box::new(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the grid with new entries
|
||||||
|
pub fn set_entries(&self, entries: Vec<EntryView>) {
|
||||||
|
let imp = self.imp();
|
||||||
|
|
||||||
|
// Clear existing tiles
|
||||||
|
while let Some(child) = imp.flow_box.first_child() {
|
||||||
|
imp.flow_box.remove(&child);
|
||||||
|
}
|
||||||
|
imp.tiles.borrow_mut().clear();
|
||||||
|
|
||||||
|
// Create tiles for enabled entries
|
||||||
|
for entry in entries {
|
||||||
|
// Skip disabled entries
|
||||||
|
if !entry.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tile = LauncherTile::new();
|
||||||
|
tile.set_entry(entry);
|
||||||
|
|
||||||
|
// Connect click handler
|
||||||
|
let on_launch = imp.on_launch.clone();
|
||||||
|
tile.connect_clicked(move |tile| {
|
||||||
|
if let Some(entry_id) = tile.entry_id() {
|
||||||
|
if let Some(callback) = on_launch.borrow().as_ref() {
|
||||||
|
callback(entry_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
imp.flow_box.append(&tile);
|
||||||
|
imp.tiles.borrow_mut().push(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable all tiles
|
||||||
|
pub fn set_tiles_sensitive(&self, sensitive: bool) {
|
||||||
|
for tile in self.imp().tiles.borrow().iter() {
|
||||||
|
tile.set_sensitive(sensitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LauncherGrid {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/shepherd-launcher-ui/src/main.rs
Normal file
50
crates/shepherd-launcher-ui/src/main.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
//! Shepherd Launcher UI - Main grid interface
|
||||||
|
//!
|
||||||
|
//! This is the primary user-facing shell for the kiosk-style environment.
|
||||||
|
//! It displays available entries from shepherdd and allows launching them.
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod client;
|
||||||
|
mod grid;
|
||||||
|
mod state;
|
||||||
|
mod tile;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
/// Shepherd Launcher - Child-friendly kiosk launcher
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "shepherd-launcher")]
|
||||||
|
#[command(about = "GTK4 launcher UI for shepherdd", long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Socket path for shepherdd connection
|
||||||
|
#[arg(short, long, default_value = "/run/shepherdd/shepherdd.sock")]
|
||||||
|
socket: PathBuf,
|
||||||
|
|
||||||
|
/// Log level
|
||||||
|
#[arg(short, long, default_value = "info")]
|
||||||
|
log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new(&args.log_level)),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("Starting Shepherd Launcher UI");
|
||||||
|
|
||||||
|
// Run GTK application
|
||||||
|
let application = app::LauncherApp::new(args.socket);
|
||||||
|
let exit_code = application.run();
|
||||||
|
|
||||||
|
std::process::exit(exit_code);
|
||||||
|
}
|
||||||
139
crates/shepherd-launcher-ui/src/state.rs
Normal file
139
crates/shepherd-launcher-ui/src/state.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
//! Launcher application state management
|
||||||
|
|
||||||
|
use shepherd_api::{DaemonStateSnapshot, EntryView, Event, EventPayload};
|
||||||
|
use shepherd_util::SessionId;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
/// Current state of the launcher UI
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum LauncherState {
|
||||||
|
/// Not connected to daemon
|
||||||
|
Disconnected,
|
||||||
|
/// Connected, waiting for initial state
|
||||||
|
Connecting,
|
||||||
|
/// Connected, no session running - show grid
|
||||||
|
Idle { entries: Vec<EntryView> },
|
||||||
|
/// Launch requested, waiting for response
|
||||||
|
Launching { entry_id: String },
|
||||||
|
/// Session is running
|
||||||
|
SessionActive {
|
||||||
|
session_id: SessionId,
|
||||||
|
entry_label: String,
|
||||||
|
time_remaining: Option<Duration>,
|
||||||
|
},
|
||||||
|
/// Error state
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LauncherState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared state container
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SharedState {
|
||||||
|
sender: watch::Sender<LauncherState>,
|
||||||
|
receiver: watch::Receiver<LauncherState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (sender, receiver) = watch::channel(LauncherState::default());
|
||||||
|
Self { sender, receiver }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, state: LauncherState) {
|
||||||
|
let _ = self.sender.send(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self) -> LauncherState {
|
||||||
|
self.receiver.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> watch::Receiver<LauncherState> {
|
||||||
|
self.receiver.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update state from daemon event
|
||||||
|
pub fn handle_event(&self, event: Event) {
|
||||||
|
match event.payload {
|
||||||
|
EventPayload::StateChanged(snapshot) => {
|
||||||
|
self.apply_snapshot(snapshot);
|
||||||
|
}
|
||||||
|
EventPayload::SessionStarted {
|
||||||
|
session_id,
|
||||||
|
entry_id: _,
|
||||||
|
label,
|
||||||
|
deadline,
|
||||||
|
} => {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let time_remaining = if deadline > now {
|
||||||
|
(deadline - now).to_std().ok()
|
||||||
|
} else {
|
||||||
|
Some(Duration::ZERO)
|
||||||
|
};
|
||||||
|
self.set(LauncherState::SessionActive {
|
||||||
|
session_id,
|
||||||
|
entry_label: label,
|
||||||
|
time_remaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
EventPayload::SessionEnded { .. } => {
|
||||||
|
// Will be followed by StateChanged, but set to connecting
|
||||||
|
// to ensure grid reloads
|
||||||
|
self.set(LauncherState::Connecting);
|
||||||
|
}
|
||||||
|
EventPayload::SessionExpiring { .. } => {
|
||||||
|
// Time's up indicator handled by HUD
|
||||||
|
}
|
||||||
|
EventPayload::WarningIssued { .. } => {
|
||||||
|
// Warnings handled by HUD
|
||||||
|
}
|
||||||
|
EventPayload::PolicyReloaded { .. } => {
|
||||||
|
// Request fresh state
|
||||||
|
self.set(LauncherState::Connecting);
|
||||||
|
}
|
||||||
|
EventPayload::EntryAvailabilityChanged { .. } => {
|
||||||
|
// Request fresh state
|
||||||
|
self.set(LauncherState::Connecting);
|
||||||
|
}
|
||||||
|
EventPayload::Shutdown => {
|
||||||
|
// Daemon is shutting down
|
||||||
|
self.set(LauncherState::Disconnected);
|
||||||
|
}
|
||||||
|
EventPayload::AuditEntry { .. } => {
|
||||||
|
// Audit events are for admin clients, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_snapshot(&self, snapshot: DaemonStateSnapshot) {
|
||||||
|
if let Some(session) = snapshot.current_session {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let time_remaining = if session.deadline > now {
|
||||||
|
(session.deadline - now).to_std().ok()
|
||||||
|
} else {
|
||||||
|
Some(Duration::ZERO)
|
||||||
|
};
|
||||||
|
self.set(LauncherState::SessionActive {
|
||||||
|
session_id: session.session_id,
|
||||||
|
entry_label: session.label,
|
||||||
|
time_remaining,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.set(LauncherState::Idle {
|
||||||
|
entries: snapshot.entries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SharedState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
121
crates/shepherd-launcher-ui/src/tile.rs
Normal file
121
crates/shepherd-launcher-ui/src/tile.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
//! Individual tile widget for the launcher grid
|
||||||
|
|
||||||
|
use gtk4::glib;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::subclass::prelude::*;
|
||||||
|
use shepherd_api::EntryView;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct LauncherTile {
|
||||||
|
pub entry: RefCell<Option<EntryView>>,
|
||||||
|
pub icon: gtk4::Image,
|
||||||
|
pub label: gtk4::Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for LauncherTile {
|
||||||
|
const NAME: &'static str = "ShepherdLauncherTile";
|
||||||
|
type Type = super::LauncherTile;
|
||||||
|
type ParentType = gtk4::Button;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for LauncherTile {
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
|
||||||
|
let obj = self.obj();
|
||||||
|
|
||||||
|
// Create layout
|
||||||
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||||
|
content.set_halign(gtk4::Align::Center);
|
||||||
|
content.set_valign(gtk4::Align::Center);
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
self.icon.set_pixel_size(96);
|
||||||
|
self.icon.set_icon_name(Some("application-x-executable"));
|
||||||
|
content.append(&self.icon);
|
||||||
|
|
||||||
|
// Label
|
||||||
|
self.label.set_wrap(true);
|
||||||
|
self.label.set_wrap_mode(gtk4::pango::WrapMode::Word);
|
||||||
|
self.label.set_justify(gtk4::Justification::Center);
|
||||||
|
self.label.set_max_width_chars(12);
|
||||||
|
self.label.add_css_class("tile-label");
|
||||||
|
content.append(&self.label);
|
||||||
|
|
||||||
|
obj.set_child(Some(&content));
|
||||||
|
obj.add_css_class("launcher-tile");
|
||||||
|
obj.set_size_request(160, 160);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for LauncherTile {}
|
||||||
|
impl ButtonImpl for LauncherTile {}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct LauncherTile(ObjectSubclass<imp::LauncherTile>)
|
||||||
|
@extends gtk4::Button, gtk4::Widget,
|
||||||
|
@implements gtk4::Accessible, gtk4::Actionable, gtk4::Buildable, gtk4::ConstraintTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LauncherTile {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
glib::Object::builder().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_entry(&self, entry: EntryView) {
|
||||||
|
let imp = self.imp();
|
||||||
|
|
||||||
|
// Set label
|
||||||
|
imp.label.set_text(&entry.label);
|
||||||
|
|
||||||
|
// Set icon
|
||||||
|
if let Some(ref icon_ref) = entry.icon_ref {
|
||||||
|
// Try to use the icon reference as an icon name
|
||||||
|
imp.icon.set_icon_name(Some(icon_ref));
|
||||||
|
} else {
|
||||||
|
// Default icon based on entry kind
|
||||||
|
let icon_name = match entry.kind_tag {
|
||||||
|
shepherd_api::EntryKindTag::Process => "application-x-executable",
|
||||||
|
shepherd_api::EntryKindTag::Vm => "computer",
|
||||||
|
shepherd_api::EntryKindTag::Media => "video-x-generic",
|
||||||
|
shepherd_api::EntryKindTag::Custom => "applications-other",
|
||||||
|
};
|
||||||
|
imp.icon.set_icon_name(Some(icon_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry is available if enabled and has no blocking reasons
|
||||||
|
let available = entry.enabled && entry.reasons.is_empty();
|
||||||
|
self.set_sensitive(available);
|
||||||
|
|
||||||
|
// Add tooltip with reason if not available
|
||||||
|
if !available && !entry.reasons.is_empty() {
|
||||||
|
// Format the first reason for tooltip
|
||||||
|
let reason_text = format!("{:?}", entry.reasons[0]);
|
||||||
|
self.set_tooltip_text(Some(&reason_text));
|
||||||
|
} else {
|
||||||
|
self.set_tooltip_text(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
*imp.entry.borrow_mut() = Some(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry(&self) -> Option<EntryView> {
|
||||||
|
self.imp().entry.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry_id(&self) -> Option<shepherd_util::EntryId> {
|
||||||
|
self.imp().entry.borrow().as_ref().map(|e| e.entry_id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LauncherTile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue