diff --git a/Cargo.lock b/Cargo.lock index aa53fc9..8ea9a10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index acea57a..49843af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/shepherd-launcher-ui/Cargo.toml b/crates/shepherd-launcher-ui/Cargo.toml index 8483d9a..5610330 100644 --- a/crates/shepherd-launcher-ui/Cargo.toml +++ b/crates/shepherd-launcher-ui/Cargo.toml @@ -24,6 +24,7 @@ tracing-subscriber = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } dirs = "5.0" +gilrs = { workspace = true } [features] default = [] diff --git a/crates/shepherd-launcher-ui/src/app.rs b/crates/shepherd-launcher-ui/src/app.rs index a9302ac..6832cfe 100644 --- a/crates/shepherd-launcher-ui/src/app.rs +++ b/crates/shepherd-launcher-ui/src/app.rs @@ -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); diff --git a/crates/shepherd-launcher-ui/src/grid.rs b/crates/shepherd-launcher-ui/src/grid.rs index 1efcedf..7ee1a62 100644 --- a/crates/shepherd-launcher-ui/src/grid.rs +++ b/crates/shepherd-launcher-ui/src/grid.rs @@ -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>, pub on_launch: LaunchCallback, + pub selected_index: Cell>, + pub columns: Cell, } 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 { diff --git a/crates/shepherd-launcher-ui/src/input.rs b/crates/shepherd-launcher-ui/src/input.rs new file mode 100644 index 0000000..32cfcbb --- /dev/null +++ b/crates/shepherd-launcher-ui/src/input.rs @@ -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 { + 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, + _thread_handle: thread::JoinHandle<()>, +} + +impl GamepadHandler { + /// Create a new gamepad handler that polls for input + pub fn new() -> Option { + 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 { + self.command_rx.try_recv().ok() + } + + /// The main gamepad polling loop + fn gamepad_loop(tx: mpsc::Sender) { + 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 { + 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 { + // 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") + } +} diff --git a/crates/shepherd-launcher-ui/src/main.rs b/crates/shepherd-launcher-ui/src/main.rs index 054a3cf..c7a3f9f 100644 --- a/crates/shepherd-launcher-ui/src/main.rs +++ b/crates/shepherd-launcher-ui/src/main.rs @@ -6,6 +6,7 @@ mod app; mod client; mod grid; +mod input; mod state; mod tile; diff --git a/scripts/deps/build.pkgs b/scripts/deps/build.pkgs index 9ef8903..0e7c6e7 100644 --- a/scripts/deps/build.pkgs +++ b/scripts/deps/build.pkgs @@ -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