From e5a4dbdce72ae0f4e43dd9e3b53a3b7c88c844f7 Mon Sep 17 00:00:00 2001 From: Albert Armea Date: Sun, 8 Feb 2026 11:11:25 -0500 Subject: [PATCH] WIP: keyboard and controller support --- Cargo.lock | 174 ++++++++++++++++- crates/shepherd-launcher-ui/Cargo.toml | 1 + crates/shepherd-launcher-ui/src/app.rs | 179 +++++++++++++++++- crates/shepherd-launcher-ui/src/grid.rs | 81 +++++++- ...7 002 controller and keyboard launching.md | 22 +++ 5 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 docs/ai/history/2026-02-07 002 controller and keyboard launching.md diff --git a/Cargo.lock b/Cargo.lock index aa53fc9..ac65fad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,6 +315,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 +465,40 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gilrs" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa85c2e35dc565c90511917897ea4eae16b77f2773d5223536f7b602536d462" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d23f2cc5144060a7f8d9e02d3fce5d06705376568256a509cdbc3c24d47e4f04" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix 0.30.1", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows", +] + [[package]] name = "gio" version = "0.20.12" @@ -762,6 +802,26 @@ 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 = "is_terminal_polyfill" version = "1.70.2" @@ -823,6 +883,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" @@ -892,6 +962,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" @@ -910,6 +992,26 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags", + "libc", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1256,7 +1358,7 @@ version = "0.1.0" dependencies = [ "async-trait", "dirs", - "nix", + "nix 0.29.0", "serde", "shell-escape", "shepherd-api", @@ -1291,7 +1393,7 @@ dependencies = [ name = "shepherd-ipc" version = "0.1.0" dependencies = [ - "nix", + "nix 0.29.0", "serde", "serde_json", "shepherd-api", @@ -1310,6 +1412,7 @@ dependencies = [ "chrono", "clap", "dirs", + "gilrs", "gtk4", "serde", "serde_json", @@ -1720,6 +1823,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 +1901,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 +1945,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 +1984,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 +2096,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/crates/shepherd-launcher-ui/Cargo.toml b/crates/shepherd-launcher-ui/Cargo.toml index 8483d9a..10044b3 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 = "0.11" [features] default = [] diff --git a/crates/shepherd-launcher-ui/src/app.rs b/crates/shepherd-launcher-ui/src/app.rs index a9302ac..5e9d94e 100644 --- a/crates/shepherd-launcher-ui/src/app.rs +++ b/crates/shepherd-launcher-ui/src/app.rs @@ -4,9 +4,10 @@ use gtk4::glib; use gtk4::prelude::*; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use tokio::runtime::Runtime; use tokio::sync::mpsc; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; use crate::client::{CommandClient, ServiceClient}; use crate::grid::LauncherGrid; @@ -41,6 +42,13 @@ window { border-color: #4a90d9; } +.launcher-tile:focus, +.launcher-tile:focus-visible { + background: #1f3460; + background-color: #1f3460; + border-color: #ffd166; +} + .launcher-tile:active { background: #0f3460; background-color: #0f3460; @@ -155,6 +163,8 @@ impl LauncherApp { stack.add_named(&disconnected_view.0, Some("disconnected")); window.set_child(Some(&stack)); + Self::setup_keyboard_input(&window, &grid); + Self::setup_gamepad_input(&window, &grid); // Create shared state let state = SharedState::new(); @@ -342,6 +352,7 @@ impl LauncherApp { if let Some(grid) = grid { grid.set_entries(entries); grid.set_tiles_sensitive(true); + grid.grab_focus(); } if let Some(ref win) = window { win.set_visible(true); @@ -381,6 +392,164 @@ impl LauncherApp { window.present(); } + fn setup_keyboard_input(window: >k4::ApplicationWindow, grid: &LauncherGrid) { + let key_controller = gtk4::EventControllerKey::new(); + key_controller.set_propagation_phase(gtk4::PropagationPhase::Capture); + let grid_weak = grid.downgrade(); + key_controller.connect_key_pressed(move |_, key, _, _| { + let Some(grid) = grid_weak.upgrade() else { + return glib::Propagation::Proceed; + }; + + let handled = match key { + gtk4::gdk::Key::Up | gtk4::gdk::Key::w | gtk4::gdk::Key::W => { + grid.move_selection(0, -1); + true + } + gtk4::gdk::Key::Down | gtk4::gdk::Key::s | gtk4::gdk::Key::S => { + grid.move_selection(0, 1); + true + } + gtk4::gdk::Key::Left | gtk4::gdk::Key::a | gtk4::gdk::Key::A => { + grid.move_selection(-1, 0); + true + } + gtk4::gdk::Key::Right | gtk4::gdk::Key::d | gtk4::gdk::Key::D => { + grid.move_selection(1, 0); + true + } + gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter | gtk4::gdk::Key::space => { + grid.launch_selected(); + true + } + _ => false, + }; + + if handled { + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + window.add_controller(key_controller); + + let exit_controller = gtk4::EventControllerKey::new(); + let window_weak = window.downgrade(); + exit_controller.connect_key_pressed(move |_, key, _, modifiers| { + let alt_f4 = key == gtk4::gdk::Key::F4 + && modifiers.intersects(gtk4::gdk::ModifierType::ALT_MASK); + let ctrl_w = (key == gtk4::gdk::Key::w || key == gtk4::gdk::Key::W) + && modifiers.intersects(gtk4::gdk::ModifierType::CONTROL_MASK); + let home = key == gtk4::gdk::Key::Home || key == gtk4::gdk::Key::HomePage; + + if alt_f4 || ctrl_w || home { + if let Some(window) = window_weak.upgrade() { + window.close(); + } + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + window.add_controller(exit_controller); + } + + fn setup_gamepad_input(window: >k4::ApplicationWindow, grid: &LauncherGrid) { + let mut gilrs = match gilrs::Gilrs::new() { + Ok(gilrs) => gilrs, + Err(e) => { + warn!(error = %e, "Gamepad input unavailable"); + return; + } + }; + + let grid_weak = grid.downgrade(); + let window_weak = window.downgrade(); + let mut axis_state = GamepadAxisState::default(); + + glib::timeout_add_local(Duration::from_millis(16), move || { + while let Some(event) = gilrs.next_event() { + let Some(grid) = grid_weak.upgrade() else { + return glib::ControlFlow::Break; + }; + + match event.event { + gilrs::EventType::ButtonPressed(button, _) => match button { + gilrs::Button::DPadUp => grid.move_selection(0, -1), + gilrs::Button::DPadDown => grid.move_selection(0, 1), + gilrs::Button::DPadLeft => grid.move_selection(-1, 0), + gilrs::Button::DPadRight => grid.move_selection(1, 0), + gilrs::Button::South | gilrs::Button::East | gilrs::Button::Start => { + grid.launch_selected(); + } + gilrs::Button::Mode => { + if let Some(window) = window_weak.upgrade() { + window.close(); + return glib::ControlFlow::Break; + } + } + _ => {} + }, + gilrs::EventType::AxisChanged(axis, value, _) => { + Self::handle_gamepad_axis(&grid, axis, value, &mut axis_state); + } + _ => {} + } + } + + glib::ControlFlow::Continue + }); + } + + fn handle_gamepad_axis( + grid: &LauncherGrid, + axis: gilrs::Axis, + value: f32, + axis_state: &mut GamepadAxisState, + ) { + const THRESHOLD: f32 = 0.65; + + match axis { + gilrs::Axis::LeftStickX | gilrs::Axis::DPadX => { + if value <= -THRESHOLD { + if !axis_state.left { + grid.move_selection(-1, 0); + } + axis_state.left = true; + axis_state.right = false; + } else if value >= THRESHOLD { + if !axis_state.right { + grid.move_selection(1, 0); + } + axis_state.right = true; + axis_state.left = false; + } else { + axis_state.left = false; + axis_state.right = false; + } + } + gilrs::Axis::LeftStickY | gilrs::Axis::DPadY => { + if value <= -THRESHOLD { + if !axis_state.up { + grid.move_selection(0, -1); + } + axis_state.up = true; + axis_state.down = false; + } else if value >= THRESHOLD { + if !axis_state.down { + grid.move_selection(0, 1); + } + axis_state.down = true; + axis_state.up = false; + } else { + axis_state.up = false; + axis_state.down = false; + } + } + _ => {} + } + } + fn create_loading_view() -> gtk4::Box { let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16); container.set_halign(gtk4::Align::Center); @@ -458,3 +627,11 @@ impl LauncherApp { (container, retry_button) } } + +#[derive(Default)] +struct GamepadAxisState { + left: bool, + right: bool, + up: bool, + down: bool, +} diff --git a/crates/shepherd-launcher-ui/src/grid.rs b/crates/shepherd-launcher-ui/src/grid.rs index 1efcedf..87be608 100644 --- a/crates/shepherd-launcher-ui/src/grid.rs +++ b/crates/shepherd-launcher-ui/src/grid.rs @@ -51,7 +51,7 @@ mod imp { // 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_max_children_per_line(6); self.flow_box.set_min_children_per_line(2); self.flow_box.set_row_spacing(24); @@ -60,6 +60,7 @@ mod imp { self.flow_box.set_valign(gtk4::Align::Center); self.flow_box.set_hexpand(true); self.flow_box.set_vexpand(true); + self.flow_box.set_focusable(true); self.flow_box.add_css_class("launcher-grid"); // Wrap in a scrolled window @@ -125,6 +126,8 @@ impl LauncherGrid { imp.flow_box.insert(&tile, -1); imp.tiles.borrow_mut().push(tile); } + + self.select_first(); } /// Enable or disable all tiles @@ -133,6 +136,82 @@ impl LauncherGrid { tile.set_sensitive(sensitive); } } + + pub fn select_first(&self) { + let imp = self.imp(); + if let Some(child) = imp.flow_box.child_at_index(0) { + imp.flow_box.select_child(&child); + child.grab_focus(); + } + } + + pub fn move_selection(&self, dx: i32, dy: i32) { + let imp = self.imp(); + let tile_count = imp.tiles.borrow().len() as i32; + if tile_count == 0 { + return; + } + + let current_index = imp + .flow_box + .selected_children() + .first() + .map(|child| child.index()) + .unwrap_or(0); + + let columns = self.estimated_columns(tile_count); + let mut new_index = current_index + dx + (dy * columns); + new_index = new_index.clamp(0, tile_count - 1); + + if let Some(child) = imp.flow_box.child_at_index(new_index) { + imp.flow_box.select_child(&child); + child.grab_focus(); + } + } + + pub fn launch_selected(&self) { + let imp = self.imp(); + let maybe_child = imp.flow_box.selected_children().first().cloned(); + let Some(child) = maybe_child else { + return; + }; + + let index = child.index(); + if index < 0 { + return; + } + + let tile = imp.tiles.borrow().get(index as usize).cloned(); + if let Some(tile) = tile { + if !tile.is_sensitive() { + return; + } + if let Some(entry_id) = tile.entry_id() + && let Some(callback) = imp.on_launch.borrow().as_ref() + { + callback(entry_id); + } + } + } + + fn estimated_columns(&self, tile_count: i32) -> i32 { + let imp = self.imp(); + let width = imp.flow_box.allocation().width(); + + let max_cols = imp.flow_box.max_children_per_line() as i32; + let min_cols = imp.flow_box.min_children_per_line() as i32; + let fallback = max_cols.clamp(min_cols, tile_count.max(1)); + + if width <= 0 { + return fallback.max(1); + } + + // Tile width is 160 and column spacing is 24. + let estimated = width / (160 + 24); + estimated + .clamp(min_cols.max(1), max_cols.max(1)) + .clamp(1, tile_count.max(1)) + } } impl Default for LauncherGrid { diff --git a/docs/ai/history/2026-02-07 002 controller and keyboard launching.md b/docs/ai/history/2026-02-07 002 controller and keyboard launching.md new file mode 100644 index 0000000..26c0386 --- /dev/null +++ b/docs/ai/history/2026-02-07 002 controller and keyboard launching.md @@ -0,0 +1,22 @@ +# Controller And Keyboard Launching + +Issue: + +Prompt summary: +- Launching activities required pointer input. +- Requested non-pointer controls: + - Selection via arrow keys, WASD, D-pad, or analog stick + - Launch via Enter, Space, controller A/B/Start + - Exit via Alt+F4, Ctrl+W, controller home +- Goal was better accessibility and support for pointer-less handheld systems. + +Implemented summary: +- Added keyboard navigation and activation support in launcher UI grid. +- Added explicit keyboard exit shortcuts at the window level. +- Added gamepad input handling via `gilrs` for D-pad, analog stick, A/B/Start launch, and home exit. +- Added focused tile styling so non-pointer selection is visible. + +Key files: +- crates/shepherd-launcher-ui/src/app.rs +- crates/shepherd-launcher-ui/src/grid.rs +- crates/shepherd-launcher-ui/Cargo.toml