WIP: keyboard and gamepad
This commit is contained in:
parent
dc58817aea
commit
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"
|
||||
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]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
|
|
@ -315,6 +325,12 @@ version = "0.1.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
|
|
@ -459,6 +475,40 @@ dependencies = [
|
|||
"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]]
|
||||
name = "gio"
|
||||
version = "0.20.12"
|
||||
|
|
@ -762,6 +812,36 @@ dependencies = [
|
|||
"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]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
|
|
@ -823,6 +903,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
|
|
@ -844,6 +934,15 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
|
|
@ -892,6 +991,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
|
@ -1256,7 +1367,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs",
|
||||
"nix",
|
||||
"nix 0.29.0",
|
||||
"serde",
|
||||
"shell-escape",
|
||||
"shepherd-api",
|
||||
|
|
@ -1291,7 +1402,7 @@ dependencies = [
|
|||
name = "shepherd-ipc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"nix 0.29.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shepherd-api",
|
||||
|
|
@ -1310,6 +1421,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"gilrs",
|
||||
"gtk4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1720,6 +1832,12 @@ version = "0.2.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
|
@ -1792,6 +1910,37 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
|
@ -1805,6 +1954,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
|
|
@ -1833,6 +1993,16 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
|
@ -1935,6 +2105,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
|
|
|
|||
|
|
@ -68,5 +68,8 @@ clap = { version = "4.5", features = ["derive", "env"] }
|
|||
gtk4 = "0.9"
|
||||
gtk4-layer-shell = "0.4"
|
||||
|
||||
# Gamepad input
|
||||
gilrs = "0.11"
|
||||
|
||||
# Testing
|
||||
tempfile = "3.9"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ tracing-subscriber = { workspace = true }
|
|||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = "5.0"
|
||||
gilrs = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
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;
|
||||
|
|
@ -10,6 +12,7 @@ use tracing::{debug, error, info};
|
|||
|
||||
use crate::client::{CommandClient, ServiceClient};
|
||||
use crate::grid::LauncherGrid;
|
||||
use crate::input::{key_to_nav_command, GamepadHandler};
|
||||
use crate::state::{LauncherState, SharedState};
|
||||
|
||||
/// CSS styling for the launcher
|
||||
|
|
@ -41,6 +44,13 @@ window {
|
|||
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 {
|
||||
background: #0f3460;
|
||||
background-color: #0f3460;
|
||||
|
|
@ -156,6 +166,36 @@ impl LauncherApp {
|
|||
|
||||
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
|
||||
let state = SharedState::new();
|
||||
let state_receiver = state.subscribe();
|
||||
|
|
@ -339,9 +379,10 @@ impl LauncherApp {
|
|||
stack.set_visible_child_name("loading");
|
||||
}
|
||||
LauncherState::Idle { entries } => {
|
||||
if let Some(grid) = grid {
|
||||
if let Some(ref grid) = grid {
|
||||
grid.set_entries(entries);
|
||||
grid.set_tiles_sensitive(true);
|
||||
grid.ensure_selection();
|
||||
}
|
||||
if let Some(ref win) = window {
|
||||
win.set_visible(true);
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ use gtk4::prelude::*;
|
|||
use gtk4::subclass::prelude::*;
|
||||
use shepherd_api::EntryView;
|
||||
use shepherd_util::EntryId;
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::input::NavCommand;
|
||||
use crate::tile::LauncherTile;
|
||||
|
||||
mod imp {
|
||||
|
|
@ -17,16 +18,22 @@ mod imp {
|
|||
|
||||
pub struct LauncherGrid {
|
||||
pub flow_box: gtk4::FlowBox,
|
||||
pub scrolled: gtk4::ScrolledWindow,
|
||||
pub tiles: RefCell<Vec<LauncherTile>>,
|
||||
pub on_launch: LaunchCallback,
|
||||
pub selected_index: Cell<Option<usize>>,
|
||||
pub columns: Cell<u32>,
|
||||
}
|
||||
|
||||
impl Default for LauncherGrid {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flow_box: gtk4::FlowBox::new(),
|
||||
scrolled: gtk4::ScrolledWindow::new(),
|
||||
tiles: RefCell::new(Vec::new()),
|
||||
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_hexpand(true);
|
||||
obj.set_vexpand(true);
|
||||
obj.set_focusable(true);
|
||||
obj.set_focus_on_click(true);
|
||||
|
||||
// Configure flow box
|
||||
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_min_children_per_line(2);
|
||||
self.flow_box.set_row_spacing(24);
|
||||
|
|
@ -61,15 +71,16 @@ mod imp {
|
|||
self.flow_box.set_hexpand(true);
|
||||
self.flow_box.set_vexpand(true);
|
||||
self.flow_box.add_css_class("launcher-grid");
|
||||
self.flow_box.set_focusable(true);
|
||||
|
||||
// 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);
|
||||
self.scrolled
|
||||
.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
|
||||
self.scrolled.set_child(Some(&self.flow_box));
|
||||
self.scrolled.set_hexpand(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.tiles.borrow_mut().clear();
|
||||
imp.selected_index.set(None);
|
||||
|
||||
// Create tiles for enabled entries
|
||||
for entry in entries {
|
||||
|
|
@ -125,6 +137,11 @@ impl LauncherGrid {
|
|||
imp.flow_box.insert(&tile, -1);
|
||||
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
|
||||
|
|
@ -133,6 +150,147 @@ impl LauncherGrid {
|
|||
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 {
|
||||
|
|
|
|||
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 client;
|
||||
mod grid;
|
||||
mod input;
|
||||
mod state;
|
||||
mod tile;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,5 +27,8 @@ libgirepository1.0-dev
|
|||
# Layer shell for HUD overlay
|
||||
libgtk4-layer-shell-dev
|
||||
|
||||
# Gamepad/joystick support (for launcher input)
|
||||
libudev-dev
|
||||
|
||||
# Required for rustup
|
||||
curl
|
||||
|
|
|
|||
Loading…
Reference in a new issue