Compare commits
1 commit
main
...
u/aarmea/2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0361ecc620 |
8 changed files with 607 additions and 11 deletions
183
Cargo.lock
generated
183
Cargo.lock
generated
|
|
@ -238,6 +238,16 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
|
|
@ -315,6 +325,12 @@ 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 = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
|
|
@ -459,6 +475,40 @@ dependencies = [
|
||||||
"wasip2",
|
"wasip2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gilrs"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbb2c998745a3c1ac90f64f4f7b3a54219fd3612d7705e7798212935641ed18f"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"gilrs-core",
|
||||||
|
"log",
|
||||||
|
"uuid",
|
||||||
|
"vec_map",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gilrs-core"
|
||||||
|
version = "0.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be11a71ac3564f6965839e2ed275bf4fcf5ce16d80d396e1dfdb7b2d80bd587e"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation",
|
||||||
|
"inotify",
|
||||||
|
"io-kit-sys",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"libudev-sys",
|
||||||
|
"log",
|
||||||
|
"nix 0.30.1",
|
||||||
|
"uuid",
|
||||||
|
"vec_map",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.20.12"
|
version = "0.20.12"
|
||||||
|
|
@ -762,6 +812,36 @@ dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"inotify-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify-sys"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "io-kit-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"mach2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
|
|
@ -823,6 +903,16 @@ dependencies = [
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libudev-sys"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
|
@ -844,6 +934,15 @@ version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -892,6 +991,18 @@ dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|
@ -1256,7 +1367,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dirs",
|
"dirs",
|
||||||
"nix",
|
"nix 0.29.0",
|
||||||
"serde",
|
"serde",
|
||||||
"shell-escape",
|
"shell-escape",
|
||||||
"shepherd-api",
|
"shepherd-api",
|
||||||
|
|
@ -1291,7 +1402,7 @@ dependencies = [
|
||||||
name = "shepherd-ipc"
|
name = "shepherd-ipc"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nix",
|
"nix 0.29.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shepherd-api",
|
"shepherd-api",
|
||||||
|
|
@ -1310,6 +1421,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"gilrs",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -1720,6 +1832,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 = "vec_map"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -1792,6 +1910,37 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.83"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
|
|
@ -1805,6 +1954,17 @@ dependencies = [
|
||||||
"windows-strings",
|
"windows-strings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|
@ -1833,6 +1993,16 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
@ -1935,6 +2105,15 @@ dependencies = [
|
||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
|
||||||
|
|
@ -68,5 +68,8 @@ clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
gtk4 = "0.9"
|
gtk4 = "0.9"
|
||||||
gtk4-layer-shell = "0.4"
|
gtk4-layer-shell = "0.4"
|
||||||
|
|
||||||
|
# Gamepad input
|
||||||
|
gilrs = "0.11"
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
tempfile = "3.9"
|
tempfile = "3.9"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ tracing-subscriber = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
|
gilrs = { workspace = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
use gtk4::glib;
|
use gtk4::glib;
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
@ -10,6 +12,7 @@ use tracing::{debug, error, info};
|
||||||
|
|
||||||
use crate::client::{CommandClient, ServiceClient};
|
use crate::client::{CommandClient, ServiceClient};
|
||||||
use crate::grid::LauncherGrid;
|
use crate::grid::LauncherGrid;
|
||||||
|
use crate::input::{key_to_nav_command, GamepadHandler};
|
||||||
use crate::state::{LauncherState, SharedState};
|
use crate::state::{LauncherState, SharedState};
|
||||||
|
|
||||||
/// CSS styling for the launcher
|
/// CSS styling for the launcher
|
||||||
|
|
@ -41,6 +44,13 @@ window {
|
||||||
border-color: #4a90d9;
|
border-color: #4a90d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-tile.selected {
|
||||||
|
background: #1f3460;
|
||||||
|
background-color: #1f3460;
|
||||||
|
border-color: #4a90d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.launcher-tile:active {
|
.launcher-tile:active {
|
||||||
background: #0f3460;
|
background: #0f3460;
|
||||||
background-color: #0f3460;
|
background-color: #0f3460;
|
||||||
|
|
@ -156,6 +166,36 @@ impl LauncherApp {
|
||||||
|
|
||||||
window.set_child(Some(&stack));
|
window.set_child(Some(&stack));
|
||||||
|
|
||||||
|
// Set up keyboard input handling
|
||||||
|
let grid_for_key = grid.downgrade();
|
||||||
|
let key_controller = gtk4::EventControllerKey::new();
|
||||||
|
key_controller.connect_key_pressed(move |_, keyval, _keycode, _state| {
|
||||||
|
if let Some(cmd) = key_to_nav_command(keyval)
|
||||||
|
&& let Some(grid) = grid_for_key.upgrade()
|
||||||
|
{
|
||||||
|
grid.handle_nav_command(cmd);
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
window.add_controller(key_controller);
|
||||||
|
|
||||||
|
// Set up gamepad input handling in a background thread
|
||||||
|
let grid_for_gamepad = grid.downgrade();
|
||||||
|
let gamepad_handler = Rc::new(RefCell::new(GamepadHandler::new()));
|
||||||
|
|
||||||
|
// Poll gamepad at 60Hz using glib timeout
|
||||||
|
glib::timeout_add_local(std::time::Duration::from_millis(16), move || {
|
||||||
|
if let Some(handler) = gamepad_handler.borrow().as_ref() {
|
||||||
|
while let Some(cmd) = handler.try_recv() {
|
||||||
|
if let Some(grid) = grid_for_gamepad.upgrade() {
|
||||||
|
grid.handle_nav_command(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Continue
|
||||||
|
});
|
||||||
|
|
||||||
// Create shared state
|
// Create shared state
|
||||||
let state = SharedState::new();
|
let state = SharedState::new();
|
||||||
let state_receiver = state.subscribe();
|
let state_receiver = state.subscribe();
|
||||||
|
|
@ -339,9 +379,10 @@ impl LauncherApp {
|
||||||
stack.set_visible_child_name("loading");
|
stack.set_visible_child_name("loading");
|
||||||
}
|
}
|
||||||
LauncherState::Idle { entries } => {
|
LauncherState::Idle { entries } => {
|
||||||
if let Some(grid) = grid {
|
if let Some(ref grid) = grid {
|
||||||
grid.set_entries(entries);
|
grid.set_entries(entries);
|
||||||
grid.set_tiles_sensitive(true);
|
grid.set_tiles_sensitive(true);
|
||||||
|
grid.ensure_selection();
|
||||||
}
|
}
|
||||||
if let Some(ref win) = window {
|
if let Some(ref win) = window {
|
||||||
win.set_visible(true);
|
win.set_visible(true);
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ use gtk4::prelude::*;
|
||||||
use gtk4::subclass::prelude::*;
|
use gtk4::subclass::prelude::*;
|
||||||
use shepherd_api::EntryView;
|
use shepherd_api::EntryView;
|
||||||
use shepherd_util::EntryId;
|
use shepherd_util::EntryId;
|
||||||
use std::cell::RefCell;
|
use std::cell::{Cell, RefCell};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::input::NavCommand;
|
||||||
use crate::tile::LauncherTile;
|
use crate::tile::LauncherTile;
|
||||||
|
|
||||||
mod imp {
|
mod imp {
|
||||||
|
|
@ -17,16 +18,22 @@ mod imp {
|
||||||
|
|
||||||
pub struct LauncherGrid {
|
pub struct LauncherGrid {
|
||||||
pub flow_box: gtk4::FlowBox,
|
pub flow_box: gtk4::FlowBox,
|
||||||
|
pub scrolled: gtk4::ScrolledWindow,
|
||||||
pub tiles: RefCell<Vec<LauncherTile>>,
|
pub tiles: RefCell<Vec<LauncherTile>>,
|
||||||
pub on_launch: LaunchCallback,
|
pub on_launch: LaunchCallback,
|
||||||
|
pub selected_index: Cell<Option<usize>>,
|
||||||
|
pub columns: Cell<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LauncherGrid {
|
impl Default for LauncherGrid {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
flow_box: gtk4::FlowBox::new(),
|
flow_box: gtk4::FlowBox::new(),
|
||||||
|
scrolled: gtk4::ScrolledWindow::new(),
|
||||||
tiles: RefCell::new(Vec::new()),
|
tiles: RefCell::new(Vec::new()),
|
||||||
on_launch: Rc::new(RefCell::new(None)),
|
on_launch: Rc::new(RefCell::new(None)),
|
||||||
|
selected_index: Cell::new(None),
|
||||||
|
columns: Cell::new(6), // Will be updated based on actual layout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -48,10 +55,13 @@ mod imp {
|
||||||
obj.set_valign(gtk4::Align::Fill);
|
obj.set_valign(gtk4::Align::Fill);
|
||||||
obj.set_hexpand(true);
|
obj.set_hexpand(true);
|
||||||
obj.set_vexpand(true);
|
obj.set_vexpand(true);
|
||||||
|
obj.set_focusable(true);
|
||||||
|
obj.set_focus_on_click(true);
|
||||||
|
|
||||||
// Configure flow box
|
// Configure flow box
|
||||||
self.flow_box.set_homogeneous(true);
|
self.flow_box.set_homogeneous(true);
|
||||||
self.flow_box.set_selection_mode(gtk4::SelectionMode::None);
|
self.flow_box.set_selection_mode(gtk4::SelectionMode::Single);
|
||||||
|
self.flow_box.set_activate_on_single_click(false);
|
||||||
self.flow_box.set_max_children_per_line(6);
|
self.flow_box.set_max_children_per_line(6);
|
||||||
self.flow_box.set_min_children_per_line(2);
|
self.flow_box.set_min_children_per_line(2);
|
||||||
self.flow_box.set_row_spacing(24);
|
self.flow_box.set_row_spacing(24);
|
||||||
|
|
@ -61,15 +71,16 @@ mod imp {
|
||||||
self.flow_box.set_hexpand(true);
|
self.flow_box.set_hexpand(true);
|
||||||
self.flow_box.set_vexpand(true);
|
self.flow_box.set_vexpand(true);
|
||||||
self.flow_box.add_css_class("launcher-grid");
|
self.flow_box.add_css_class("launcher-grid");
|
||||||
|
self.flow_box.set_focusable(true);
|
||||||
|
|
||||||
// Wrap in a scrolled window
|
// Wrap in a scrolled window
|
||||||
let scrolled = gtk4::ScrolledWindow::new();
|
self.scrolled
|
||||||
scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
|
.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
|
||||||
scrolled.set_child(Some(&self.flow_box));
|
self.scrolled.set_child(Some(&self.flow_box));
|
||||||
scrolled.set_hexpand(true);
|
self.scrolled.set_hexpand(true);
|
||||||
scrolled.set_vexpand(true);
|
self.scrolled.set_vexpand(true);
|
||||||
|
|
||||||
obj.append(&scrolled);
|
obj.append(&self.scrolled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +113,7 @@ impl LauncherGrid {
|
||||||
imp.flow_box.remove(&child);
|
imp.flow_box.remove(&child);
|
||||||
}
|
}
|
||||||
imp.tiles.borrow_mut().clear();
|
imp.tiles.borrow_mut().clear();
|
||||||
|
imp.selected_index.set(None);
|
||||||
|
|
||||||
// Create tiles for enabled entries
|
// Create tiles for enabled entries
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
|
@ -125,6 +137,11 @@ impl LauncherGrid {
|
||||||
imp.flow_box.insert(&tile, -1);
|
imp.flow_box.insert(&tile, -1);
|
||||||
imp.tiles.borrow_mut().push(tile);
|
imp.tiles.borrow_mut().push(tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Select first tile if we have any
|
||||||
|
if !imp.tiles.borrow().is_empty() {
|
||||||
|
self.select_index(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable or disable all tiles
|
/// Enable or disable all tiles
|
||||||
|
|
@ -133,6 +150,147 @@ impl LauncherGrid {
|
||||||
tile.set_sensitive(sensitive);
|
tile.set_sensitive(sensitive);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle a navigation command
|
||||||
|
pub fn handle_nav_command(&self, command: NavCommand) {
|
||||||
|
let imp = self.imp();
|
||||||
|
let tiles = imp.tiles.borrow();
|
||||||
|
let count = tiles.len();
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = imp.selected_index.get().unwrap_or(0);
|
||||||
|
let columns = self.get_columns_count();
|
||||||
|
|
||||||
|
match command {
|
||||||
|
NavCommand::Up => {
|
||||||
|
if current >= columns {
|
||||||
|
self.select_index(current - columns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavCommand::Down => {
|
||||||
|
let next = current + columns;
|
||||||
|
if next < count {
|
||||||
|
self.select_index(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavCommand::Left => {
|
||||||
|
if current > 0 {
|
||||||
|
self.select_index(current - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavCommand::Right => {
|
||||||
|
if current + 1 < count {
|
||||||
|
self.select_index(current + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavCommand::Activate => {
|
||||||
|
drop(tiles);
|
||||||
|
self.activate_selected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select a tile by index
|
||||||
|
fn select_index(&self, index: usize) {
|
||||||
|
let imp = self.imp();
|
||||||
|
let tiles = imp.tiles.borrow();
|
||||||
|
|
||||||
|
if index >= tiles.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove selected class from previous tile
|
||||||
|
if let Some(prev_idx) = imp.selected_index.get()
|
||||||
|
&& let Some(prev_tile) = tiles.get(prev_idx)
|
||||||
|
{
|
||||||
|
prev_tile.remove_css_class("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected class to new tile
|
||||||
|
if let Some(tile) = tiles.get(index) {
|
||||||
|
tile.add_css_class("selected");
|
||||||
|
tile.grab_focus();
|
||||||
|
|
||||||
|
// Scroll to make sure the tile is visible
|
||||||
|
if let Some(child) = imp.flow_box.child_at_index(index as i32) {
|
||||||
|
imp.flow_box.select_child(&child);
|
||||||
|
|
||||||
|
// Ensure the tile is scrolled into view
|
||||||
|
let adj = imp.scrolled.vadjustment();
|
||||||
|
let (_, y) = tile.translate_coordinates(&imp.scrolled, 0.0, 0.0).unwrap_or((0.0, 0.0));
|
||||||
|
let tile_height = tile.height() as f64;
|
||||||
|
let view_height = imp.scrolled.height() as f64;
|
||||||
|
|
||||||
|
if y < 0.0 {
|
||||||
|
adj.set_value(adj.value() + y - 24.0);
|
||||||
|
} else if y + tile_height > view_height {
|
||||||
|
adj.set_value(adj.value() + (y + tile_height - view_height) + 24.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imp.selected_index.set(Some(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate the currently selected tile
|
||||||
|
fn activate_selected(&self) {
|
||||||
|
let imp = self.imp();
|
||||||
|
|
||||||
|
if let Some(index) = imp.selected_index.get() {
|
||||||
|
let tiles = imp.tiles.borrow();
|
||||||
|
if let Some(tile) = tiles.get(index)
|
||||||
|
&& tile.is_sensitive()
|
||||||
|
&& let Some(entry_id) = tile.entry_id()
|
||||||
|
&& let Some(callback) = imp.on_launch.borrow().as_ref()
|
||||||
|
{
|
||||||
|
callback(entry_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current column count based on layout
|
||||||
|
fn get_columns_count(&self) -> usize {
|
||||||
|
let imp = self.imp();
|
||||||
|
let tiles = imp.tiles.borrow();
|
||||||
|
|
||||||
|
if tiles.is_empty() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to determine columns from actual layout
|
||||||
|
// by checking how many tiles are on the first row (same y position)
|
||||||
|
if tiles.len() >= 2 {
|
||||||
|
let first_y = tiles[0].allocation().y();
|
||||||
|
let mut columns = 1;
|
||||||
|
for tile in tiles.iter().skip(1) {
|
||||||
|
if tile.allocation().y() == first_y {
|
||||||
|
columns += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imp.columns.set(columns as u32);
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
imp.columns.get() as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the flow box for focus management
|
||||||
|
pub fn flow_box(&self) -> >k4::FlowBox {
|
||||||
|
&self.imp().flow_box
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a tile is selected (for when grid becomes visible)
|
||||||
|
pub fn ensure_selection(&self) {
|
||||||
|
let imp = self.imp();
|
||||||
|
if imp.selected_index.get().is_none() && !imp.tiles.borrow().is_empty() {
|
||||||
|
self.select_index(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LauncherGrid {
|
impl Default for LauncherGrid {
|
||||||
|
|
|
||||||
210
crates/shepherd-launcher-ui/src/input.rs
Normal file
210
crates/shepherd-launcher-ui/src/input.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
//! Input handling for keyboard and gamepad navigation
|
||||||
|
|
||||||
|
use gilrs::{Axis, Button, Event as GilrsEvent, EventType, Gilrs};
|
||||||
|
use gtk4::gdk;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
|
/// Navigation commands that can be triggered by keyboard or gamepad
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NavCommand {
|
||||||
|
/// Move selection up
|
||||||
|
Up,
|
||||||
|
/// Move selection down
|
||||||
|
Down,
|
||||||
|
/// Move selection left
|
||||||
|
Left,
|
||||||
|
/// Move selection right
|
||||||
|
Right,
|
||||||
|
/// Activate/launch the selected item
|
||||||
|
Activate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Threshold for analog stick to register as a direction
|
||||||
|
const AXIS_THRESHOLD: f32 = 0.5;
|
||||||
|
|
||||||
|
/// Deadzone for analog stick (ignore small movements)
|
||||||
|
const AXIS_DEADZONE: f32 = 0.2;
|
||||||
|
|
||||||
|
/// Delay before repeating analog stick navigation (ms)
|
||||||
|
const ANALOG_REPEAT_DELAY_MS: u64 = 200;
|
||||||
|
|
||||||
|
/// Maps a GTK key to a navigation command
|
||||||
|
pub fn key_to_nav_command(keyval: gdk::Key) -> Option<NavCommand> {
|
||||||
|
match keyval {
|
||||||
|
gdk::Key::Up | gdk::Key::KP_Up | gdk::Key::w | gdk::Key::W => Some(NavCommand::Up),
|
||||||
|
gdk::Key::Down | gdk::Key::KP_Down | gdk::Key::s | gdk::Key::S => Some(NavCommand::Down),
|
||||||
|
gdk::Key::Left | gdk::Key::KP_Left | gdk::Key::a | gdk::Key::A => Some(NavCommand::Left),
|
||||||
|
gdk::Key::Right | gdk::Key::KP_Right | gdk::Key::d | gdk::Key::D => Some(NavCommand::Right),
|
||||||
|
gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::space => Some(NavCommand::Activate),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles gamepad input in a background thread and sends navigation commands
|
||||||
|
pub struct GamepadHandler {
|
||||||
|
command_rx: mpsc::Receiver<NavCommand>,
|
||||||
|
_thread_handle: thread::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GamepadHandler {
|
||||||
|
/// Create a new gamepad handler that polls for input
|
||||||
|
pub fn new() -> Option<Self> {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name("gamepad-input".into())
|
||||||
|
.spawn(move || {
|
||||||
|
Self::gamepad_loop(tx);
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
command_rx: rx,
|
||||||
|
_thread_handle: handle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to receive any pending navigation commands (non-blocking)
|
||||||
|
pub fn try_recv(&self) -> Option<NavCommand> {
|
||||||
|
self.command_rx.try_recv().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main gamepad polling loop
|
||||||
|
fn gamepad_loop(tx: mpsc::Sender<NavCommand>) {
|
||||||
|
let gilrs = match Gilrs::new() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "Failed to initialize gamepad support");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Gamepad handler initialized");
|
||||||
|
|
||||||
|
// Log connected gamepads
|
||||||
|
for (_id, gamepad) in gilrs.gamepads() {
|
||||||
|
debug!(
|
||||||
|
name = gamepad.name(),
|
||||||
|
"Found gamepad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut gilrs = gilrs;
|
||||||
|
|
||||||
|
// Track analog stick state for repeat navigation
|
||||||
|
let mut last_analog_nav: Option<(NavCommand, std::time::Instant)> = None;
|
||||||
|
let mut axis_x: f32 = 0.0;
|
||||||
|
let mut axis_y: f32 = 0.0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Process all pending events
|
||||||
|
while let Some(GilrsEvent { event, .. }) = gilrs.next_event() {
|
||||||
|
match event {
|
||||||
|
EventType::ButtonPressed(button, _) => {
|
||||||
|
if let Some(cmd) = Self::button_to_nav_command(button) {
|
||||||
|
trace!(button = ?button, command = ?cmd, "Gamepad button pressed");
|
||||||
|
let _ = tx.send(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventType::AxisChanged(axis, value, _) => {
|
||||||
|
match axis {
|
||||||
|
Axis::LeftStickX | Axis::RightStickX => {
|
||||||
|
axis_x = value;
|
||||||
|
}
|
||||||
|
Axis::LeftStickY | Axis::RightStickY => {
|
||||||
|
axis_y = value;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle analog stick navigation with repeat
|
||||||
|
let analog_cmd = Self::axis_to_nav_command(axis_x, axis_y);
|
||||||
|
|
||||||
|
match (analog_cmd, last_analog_nav.as_ref()) {
|
||||||
|
(Some(cmd), None) => {
|
||||||
|
// New direction - send immediately
|
||||||
|
trace!(command = ?cmd, "Analog stick navigation");
|
||||||
|
let _ = tx.send(cmd);
|
||||||
|
last_analog_nav = Some((cmd, std::time::Instant::now()));
|
||||||
|
}
|
||||||
|
(Some(cmd), Some((last_cmd, last_time))) => {
|
||||||
|
if cmd != *last_cmd {
|
||||||
|
// Direction changed - send immediately
|
||||||
|
trace!(command = ?cmd, "Analog stick direction changed");
|
||||||
|
let _ = tx.send(cmd);
|
||||||
|
last_analog_nav = Some((cmd, std::time::Instant::now()));
|
||||||
|
} else if last_time.elapsed() >= Duration::from_millis(ANALOG_REPEAT_DELAY_MS) {
|
||||||
|
// Same direction held - repeat
|
||||||
|
trace!(command = ?cmd, "Analog stick repeat");
|
||||||
|
let _ = tx.send(cmd);
|
||||||
|
last_analog_nav = Some((cmd, std::time::Instant::now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, _) => {
|
||||||
|
// Stick returned to center
|
||||||
|
last_analog_nav = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep briefly to avoid busy-waiting
|
||||||
|
thread::sleep(Duration::from_millis(16)); // ~60 Hz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a gamepad button to a navigation command
|
||||||
|
fn button_to_nav_command(button: Button) -> Option<NavCommand> {
|
||||||
|
match button {
|
||||||
|
// D-pad
|
||||||
|
Button::DPadUp => Some(NavCommand::Up),
|
||||||
|
Button::DPadDown => Some(NavCommand::Down),
|
||||||
|
Button::DPadLeft => Some(NavCommand::Left),
|
||||||
|
Button::DPadRight => Some(NavCommand::Right),
|
||||||
|
// Action buttons - A, B, and Start all activate
|
||||||
|
Button::South => Some(NavCommand::Activate), // A on Xbox/Switch, X on PlayStation
|
||||||
|
Button::East => Some(NavCommand::Activate), // B on Xbox, Circle on PlayStation
|
||||||
|
Button::Start => Some(NavCommand::Activate),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps analog stick axes to a navigation command
|
||||||
|
fn axis_to_nav_command(x: f32, y: f32) -> Option<NavCommand> {
|
||||||
|
// Apply deadzone
|
||||||
|
let x = if x.abs() < AXIS_DEADZONE { 0.0 } else { x };
|
||||||
|
let y = if y.abs() < AXIS_DEADZONE { 0.0 } else { y };
|
||||||
|
|
||||||
|
// Determine primary direction (prioritize the axis with larger magnitude)
|
||||||
|
if x.abs() > y.abs() {
|
||||||
|
// Horizontal movement
|
||||||
|
if x > AXIS_THRESHOLD {
|
||||||
|
Some(NavCommand::Right)
|
||||||
|
} else if x < -AXIS_THRESHOLD {
|
||||||
|
Some(NavCommand::Left)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Vertical movement (note: Y axis is typically inverted on gamepads)
|
||||||
|
if y > AXIS_THRESHOLD {
|
||||||
|
Some(NavCommand::Down)
|
||||||
|
} else if y < -AXIS_THRESHOLD {
|
||||||
|
Some(NavCommand::Up)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GamepadHandler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new().expect("Failed to create gamepad handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
mod app;
|
mod app;
|
||||||
mod client;
|
mod client;
|
||||||
mod grid;
|
mod grid;
|
||||||
|
mod input;
|
||||||
mod state;
|
mod state;
|
||||||
mod tile;
|
mod tile;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,8 @@ libgirepository1.0-dev
|
||||||
# Layer shell for HUD overlay
|
# Layer shell for HUD overlay
|
||||||
libgtk4-layer-shell-dev
|
libgtk4-layer-shell-dev
|
||||||
|
|
||||||
|
# Gamepad/joystick support (for launcher input)
|
||||||
|
libudev-dev
|
||||||
|
|
||||||
# Required for rustup
|
# Required for rustup
|
||||||
curl
|
curl
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue