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.
This commit is contained in:
nvrl committed 2026-10-02 20:16:27 +02:00
1 parent 79c3f6885d
commit d3325843a9
4 files changed
+33 -7

No files matched your search

+3 -1
View File
@@ -1901,7 +1901,9 @@ fn changed_art(
fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>) {
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)
+12 -3
View File
@@ -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.
+2
View File
@@ -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"),
],
+16 -3
View File
@@ -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(