From d3325843a9472e5ea4011f68467f73b92c76a75e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 2 Oct 2026 20:10:32 +0200 Subject: [PATCH] feat(app): keyboard navigation across the library and episode list Tiles and episode stills light up under focus as they do under the pointer and scroll into view when the arrow keys or Tab reach them; Enter opens or plays. Global keys now check for a focused text field rather than any focused widget, so / and Esc still work over a tile. --- crates/pstr-app/src/app.rs | 4 +++- crates/pstr-app/src/ui/mod.rs | 15 ++++++++++++--- crates/pstr-app/src/ui/shortcuts.rs | 2 ++ crates/pstr-app/src/ui/title.rs | 19 ++++++++++++++++--- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index b3b52dd..e475666 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -1901,7 +1901,9 @@ fn changed_art( fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec) { use egui::{Key, Modifiers}; - let typing = ctx.egui_wants_keyboard_input(); + // A text field, not any focused widget: a tile reached with Tab or the + // arrow keys has focus too, and "/" over it should still search. + let typing = ctx.text_edit_focused(); let mut tab = None; ctx.input_mut(|input| { if input.consume_key(Modifiers::COMMAND, Key::F) diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 1a42506..169a199 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -514,6 +514,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { let (rect, response) = ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click()); + // Reached with the arrow keys or Tab: bring it into view, before the + // early return below would skip a tile that is just off screen. + if response.gained_focus() { + response.scroll_to_me(None); + } if !ui.is_rect_visible(rect) { return response; } @@ -523,9 +528,13 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { // ride it, so they arrive together rather than each snapping on its own. // egui drives this from wall-clock time, so it is the same speed whatever // the frame rate — and it needs no state kept here. - let hover = - ui.ctx() - .animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.12); + // Focus lights a tile the way the pointer does, so the keyboard can be + // followed across the grid. + let hover = ui.ctx().animate_bool_with_time( + response.id.with("hover"), + response.hovered() || response.has_focus(), + 0.12, + ); // The picture arrives whenever its download finishes, several frames after // the tile first drew. Fading it in over the placeholder turns a wall of // letters popping into stills into one settle. diff --git a/crates/pstr-app/src/ui/shortcuts.rs b/crates/pstr-app/src/ui/shortcuts.rs index 53a9233..e32d5c3 100644 --- a/crates/pstr-app/src/ui/shortcuts.rs +++ b/crates/pstr-app/src/ui/shortcuts.rs @@ -16,6 +16,8 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[ ("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"), ("Ctrl ,", "Settings"), ("Ctrl V", "Paste a share link to add it"), + ("Tab or arrows", "Move between titles and episodes"), + ("Enter", "Open or play what is highlighted"), ("Alt ← or mouse back", "Back"), ("?", "This list"), ], diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 0e70716..3d25d55 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -740,9 +740,14 @@ fn still( ); } - let hover = ui - .ctx() - .animate_bool_with_time(response.id, response.hovered(), 0.12); + if response.gained_focus() { + response.scroll_to_me(None); + } + let hover = ui.ctx().animate_bool_with_time( + response.id, + response.hovered() || response.has_focus(), + 0.12, + ); if hover > 0.0 { painter.rect_filled( rect, @@ -750,6 +755,14 @@ fn still( egui::Color32::from_black_alpha((90.0 * hover) as u8), ); } + if response.has_focus() { + painter.rect_stroke( + rect, + radius, + egui::Stroke::new(2.0, theme::accent()), + egui::StrokeKind::Inside, + ); + } // A play mark, always faintly there and full under the pointer. let centre = rect.center(); painter.circle_filled(