From b6f1275d6b7c745cf0078b2696756cc4ab5c59a7 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Jul 2026 18:36:09 +0200 Subject: [PATCH] Web client: scroll the keyboard cursor back into view Moving the library or queue cursor with the keyboard (j/k, page keys, first/last) updated the selection but never scrolled the list, so the selected row could slide out of the scroll box and disappear. Each pane now runs an effect that re-scrolls its `.selected` row into view whenever the cursor moves. The queue cursor is its own signal, so this fires on moves but not on every stream-driven queue refresh; the library cursor lives in the library pane signal. The scroll is deferred to the next animation frame (the freshly-rendered row must be in the DOM) and uses `block: nearest`, so an already-visible row does not jump. Click/drag selection needs no help -- the pointer is already on-screen. Co-Authored-By: Claude Opus 4.8 (1M context) --- cbd-web/Cargo.toml | 2 ++ cbd-web/src/app.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/cbd-web/Cargo.toml b/cbd-web/Cargo.toml index 4d914d0..9c18394 100644 --- a/cbd-web/Cargo.toml +++ b/cbd-web/Cargo.toml @@ -25,6 +25,8 @@ web-sys = { workspace = true, features = [ "KeyboardEvent", "Location", "Performance", + "ScrollIntoViewOptions", + "ScrollLogicalPosition", "Storage", "Window", ] } diff --git a/cbd-web/src/app.rs b/cbd-web/src/app.rs index 2db02bc..6c736cb 100644 --- a/cbd-web/src/app.rs +++ b/cbd-web/src/app.rs @@ -65,6 +65,27 @@ fn apply_theme(theme: &str) { } } +/// Scrolls the selected row of a pane back into view after a keyboard move, +/// so the cursor never drifts out of the scroll box (the click handlers +/// need no help — the pointer is already on-screen). `pane_selector` picks +/// the pane (`.pane.library` / `.pane.queue`); we then find its `.selected` +/// row. Deferred to the next animation frame so the freshly-rendered row is +/// in the DOM. `block: nearest` scrolls the minimum amount — an already +/// visible row does not move. +fn scroll_selected_into_view(pane_selector: &'static str) { + request_animation_frame(move || { + let selector = format!("{pane_selector} .list li.selected"); + if let Some(el) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.query_selector(&selector).ok().flatten()) + { + let opts = web_sys::ScrollIntoViewOptions::new(); + opts.set_block(web_sys::ScrollLogicalPosition::Nearest); + el.scroll_into_view_with_scroll_into_view_options(&opts); + } + }); +} + // ---- the store --------------------------------------------------------- /// Every signal the components share. `Copy` so closures capture it @@ -733,6 +754,13 @@ fn LibraryView(store: Store) -> impl IntoView { let focused = move || store.focus.get() == Focus::Library; let library = store.library; + // Keep the keyboard cursor visible: whenever the library cursor (or its + // listing) changes, scroll the selected row back into the scroll box. + Effect::new(move |_| { + let _ = library.with(|p| p.selected); + scroll_selected_into_view(".pane.library"); + }); + let toolbar = move || { let pane = library.get(); let selection = pane.selected_item(); @@ -888,6 +916,14 @@ fn LibraryView(store: Store) -> impl IntoView { #[component] fn QueueView(store: Store) -> impl IntoView { let focused = move || store.focus.get() == Focus::Queue; + + // Keep the keyboard cursor visible. The cursor is its own signal, so this + // fires on moves but not on every stream-driven queue refresh. + Effect::new(move |_| { + let _ = store.queue_cursor.get(); + scroll_selected_into_view(".pane.queue"); + }); + view! {