WIP: keyboard and controller support

This commit is contained in:
Albert Armea 2026-02-08 11:11:25 -05:00
parent 8bba628d98
commit e5a4dbdce7
5 changed files with 453 additions and 4 deletions

174
Cargo.lock generated
View file

@ -315,6 +315,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 +465,40 @@ dependencies = [
"wasip2", "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]] [[package]]
name = "gio" name = "gio"
version = "0.20.12" version = "0.20.12"
@ -762,6 +802,26 @@ 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]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@ -823,6 +883,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"
@ -892,6 +962,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"
@ -910,6 +992,26 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -1256,7 +1358,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 +1393,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 +1412,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"dirs", "dirs",
"gilrs",
"gtk4", "gtk4",
"serde", "serde",
"serde_json", "serde_json",
@ -1720,6 +1823,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 +1901,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 +1945,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 +1984,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 +2096,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"

View file

@ -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 = "0.11"
[features] [features]
default = [] default = []

View file

@ -4,9 +4,10 @@ use gtk4::glib;
use gtk4::prelude::*; use gtk4::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, error, info}; use tracing::{debug, error, info, warn};
use crate::client::{CommandClient, ServiceClient}; use crate::client::{CommandClient, ServiceClient};
use crate::grid::LauncherGrid; use crate::grid::LauncherGrid;
@ -41,6 +42,13 @@ window {
border-color: #4a90d9; border-color: #4a90d9;
} }
.launcher-tile:focus,
.launcher-tile:focus-visible {
background: #1f3460;
background-color: #1f3460;
border-color: #ffd166;
}
.launcher-tile:active { .launcher-tile:active {
background: #0f3460; background: #0f3460;
background-color: #0f3460; background-color: #0f3460;
@ -155,6 +163,8 @@ impl LauncherApp {
stack.add_named(&disconnected_view.0, Some("disconnected")); stack.add_named(&disconnected_view.0, Some("disconnected"));
window.set_child(Some(&stack)); window.set_child(Some(&stack));
Self::setup_keyboard_input(&window, &grid);
Self::setup_gamepad_input(&window, &grid);
// Create shared state // Create shared state
let state = SharedState::new(); let state = SharedState::new();
@ -342,6 +352,7 @@ impl LauncherApp {
if let Some(grid) = grid { if let Some(grid) = grid {
grid.set_entries(entries); grid.set_entries(entries);
grid.set_tiles_sensitive(true); grid.set_tiles_sensitive(true);
grid.grab_focus();
} }
if let Some(ref win) = window { if let Some(ref win) = window {
win.set_visible(true); win.set_visible(true);
@ -381,6 +392,164 @@ impl LauncherApp {
window.present(); window.present();
} }
fn setup_keyboard_input(window: &gtk4::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: &gtk4::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 { fn create_loading_view() -> gtk4::Box {
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16); let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
container.set_halign(gtk4::Align::Center); container.set_halign(gtk4::Align::Center);
@ -458,3 +627,11 @@ impl LauncherApp {
(container, retry_button) (container, retry_button)
} }
} }
#[derive(Default)]
struct GamepadAxisState {
left: bool,
right: bool,
up: bool,
down: bool,
}

View file

@ -51,7 +51,7 @@ mod imp {
// 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_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);
@ -60,6 +60,7 @@ mod imp {
self.flow_box.set_valign(gtk4::Align::Center); self.flow_box.set_valign(gtk4::Align::Center);
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.set_focusable(true);
self.flow_box.add_css_class("launcher-grid"); self.flow_box.add_css_class("launcher-grid");
// Wrap in a scrolled window // Wrap in a scrolled window
@ -125,6 +126,8 @@ 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);
} }
self.select_first();
} }
/// Enable or disable all tiles /// Enable or disable all tiles
@ -133,6 +136,82 @@ impl LauncherGrid {
tile.set_sensitive(sensitive); 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 { impl Default for LauncherGrid {

View file

@ -0,0 +1,22 @@
# Controller And Keyboard Launching
Issue: <https://github.com/aarmea/shepherd-launcher/issues/20>
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