diff --git a/crates/shepherd-launcher-ui/src/app.rs b/crates/shepherd-launcher-ui/src/app.rs index a9302ac..91e7cc7 100644 --- a/crates/shepherd-launcher-ui/src/app.rs +++ b/crates/shepherd-launcher-ui/src/app.rs @@ -1,6 +1,7 @@ //! Main GTK4 application for the launcher use gtk4::glib; +use gtk4::gdk; use gtk4::prelude::*; use std::path::PathBuf; use std::sync::Arc; @@ -41,6 +42,14 @@ window { border-color: #4a90d9; } +.launcher-tile:focus, +.launcher-tile:focus-visible { + background: #1f3460; + background-color: #1f3460; + border-color: #f8c24e; + outline: none; +} + .launcher-tile:active { background: #0f3460; background-color: #0f3460; @@ -156,6 +165,61 @@ impl LauncherApp { window.set_child(Some(&stack)); + let grid_for_keys = grid.downgrade(); + let window_for_keys = window.downgrade(); + let key_controller = gtk4::EventControllerKey::new(); + key_controller.connect_key_pressed(move |_, key, _, modifiers| { + let Some(window) = window_for_keys.upgrade() else { + return glib::Propagation::Proceed; + }; + let Some(grid) = grid_for_keys.upgrade() else { + return glib::Propagation::Proceed; + }; + + let is_alt = modifiers.contains(gdk::ModifierType::ALT_MASK); + let is_ctrl = modifiers.contains(gdk::ModifierType::CONTROL_MASK); + + if (key == gdk::Key::w || key == gdk::Key::W) && is_ctrl { + window.close(); + return glib::Propagation::Stop; + } + + let handled = match key { + gdk::Key::Up | gdk::Key::KP_Up | gdk::Key::w | gdk::Key::W => { + grid.move_focus(gtk4::DirectionType::Up); + true + } + gdk::Key::Down | gdk::Key::KP_Down | gdk::Key::s | gdk::Key::S => { + grid.move_focus(gtk4::DirectionType::Down); + true + } + gdk::Key::Left | gdk::Key::KP_Left | gdk::Key::a | gdk::Key::A => { + grid.move_focus(gtk4::DirectionType::Left); + true + } + gdk::Key::Right | gdk::Key::KP_Right | gdk::Key::d | gdk::Key::D => { + grid.move_focus(gtk4::DirectionType::Right); + true + } + gdk::Key::Home | gdk::Key::KP_Home => { + window.close(); + true + } + gdk::Key::F4 if is_alt => { + window.close(); + true + } + _ => false, + }; + + if handled { + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + window.add_controller(key_controller); + // Create shared state let state = SharedState::new(); let state_receiver = state.subscribe(); diff --git a/crates/shepherd-launcher-ui/src/grid.rs b/crates/shepherd-launcher-ui/src/grid.rs index 1efcedf..9083e15 100644 --- a/crates/shepherd-launcher-ui/src/grid.rs +++ b/crates/shepherd-launcher-ui/src/grid.rs @@ -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_can_focus(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.focus_first_tile(); } /// Enable or disable all tiles @@ -133,6 +136,31 @@ impl LauncherGrid { tile.set_sensitive(sensitive); } } + + pub fn move_focus(&self, direction: gtk4::DirectionType) { + self.ensure_focus(); + self.imp().flow_box.child_focus(direction); + } + + fn ensure_focus(&self) { + if self + .imp() + .tiles + .borrow() + .iter() + .any(|tile| tile.has_focus()) + { + return; + } + + self.focus_first_tile(); + } + + fn focus_first_tile(&self) { + if let Some(tile) = self.imp().tiles.borrow().first() { + tile.grab_focus(); + } + } } impl Default for LauncherGrid { diff --git a/crates/shepherd-launcher-ui/src/tile.rs b/crates/shepherd-launcher-ui/src/tile.rs index c2b6ad7..77a93eb 100644 --- a/crates/shepherd-launcher-ui/src/tile.rs +++ b/crates/shepherd-launcher-ui/src/tile.rs @@ -51,6 +51,7 @@ mod imp { obj.add_css_class("launcher-tile"); obj.add_css_class("flat"); obj.set_size_request(160, 160); + obj.set_can_focus(true); } } diff --git a/docs/ai/history/2026-02-07 002 launcher keyboard navigation.md b/docs/ai/history/2026-02-07 002 launcher keyboard navigation.md new file mode 100644 index 0000000..f156911 --- /dev/null +++ b/docs/ai/history/2026-02-07 002 launcher keyboard navigation.md @@ -0,0 +1,13 @@ +# Launcher Keyboard & Controller Navigation + +Issue: + +Summary: +- Added keyboard/controller navigation for the launcher grid (arrow keys, WASD, D-pad) with focus movement between tiles. +- Enabled focus styling on tiles and ensured the grid focuses the first tile on state updates. +- Added keyboard shortcuts to close the launcher (Alt+F4, Ctrl+W, Home). + +Key files: +- crates/shepherd-launcher-ui/src/app.rs +- crates/shepherd-launcher-ui/src/grid.rs +- crates/shepherd-launcher-ui/src/tile.rs