WIP: keyboard and gamepad

This commit is contained in:
Albert Armea 2026-01-06 21:46:21 -05:00
parent dc58817aea
commit 0361ecc620
8 changed files with 607 additions and 11 deletions

183
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -24,6 +24,7 @@ tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
dirs = "5.0"
gilrs = { workspace = true }
[features]
default = []

View file

@ -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);

View file

@ -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) -> &gtk4::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 {

View 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")
}
}

View file

@ -6,6 +6,7 @@
mod app;
mod client;
mod grid;
mod input;
mod state;
mod tile;

View file

@ -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