From 95700bf31f930485b75b2f79efd3df8fe44c2ea3 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 26 Jul 2026 10:35:21 +0200 Subject: [PATCH] cbd-web: queue marks, visual mode, and the register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the web client level with the TUI in the same change rather than deferring parity again — which also closes the library visual mode that was left out when v/V landed in the terminal. The web queue owns no list (just a cursor, with rows rendered straight from the server signal), so its marks live on QueueCursor beside the snapshot: mark flags plus the paths they were taken against, carried across each update by the same greedy in-order path match the TUI uses. Same rule, same seven reconciliation cases tested here too, so the two clients cannot drift. Adds s / v / V / y / p / P in the queue, v / V / y in the library, marks rendered on queue rows, register count in the queue toolbar, and the visual auto-leave rule. The "insert" button became "paste". Library movement now paints in visual mode — a dead-code warning on the wasm target was what caught that it did not. Docs: the TUI page gains a register section (what it is, and that it is per client, one slot, and re-resolves paths on paste), the web page points at it, queue.md explains why Remove takes positions and Insert takes paths, and the README walkthrough covers the keys. All of them state plainly that p changed meaning. Verified on both targets: cbd-web clippy is clean for native *and* wasm32-unknown-unknown (mod app only compiles for wasm, so native alone proves nothing), 20 tests pass, and the trunk bundle builds. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 12 +- cbd-tui/src/app/mod.rs | 3 +- cbd-tui/src/app/queue.rs | 16 +- cbd-web/src/app.rs | 218 +++++++++++++++++------ cbd-web/src/keymap.rs | 45 ++++- cbd-web/src/state.rs | 373 ++++++++++++++++++++++++++++++++++++++- docs/src/clients/tui.md | 57 ++++-- docs/src/clients/web.md | 9 +- docs/src/queue.md | 22 +++ plan/queue-register.md | 53 +++--- plan/summary.md | 63 +++++++ 11 files changed, 770 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 4f2227d..b74d4b4 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,17 @@ live as you type — `Enter` keeps the filter, `Esc` clears it. `s` marks the selected row; `v` (or `V`) enters **visual mode**, where movement marks or unmarks everything you sweep over, vim-style. The sweep is anchored where you entered it, so moving back reverses it. `Esc` (or -any non-movement key) leaves visual mode. +any non-movement key) leaves visual mode. Both panes have marks and visual +mode. + +In the queue this feeds a vim-style **register**: `y` yanks the selection +into it, `d` deletes the marked rows *into* it, and `c`/`C` fill it with +whatever they clear — so an accidental clear is recoverable. `p` pastes it +after the cursor and `P` before it, which makes `d` then `P` an exact undo +and `d` … `p` a move. The register is per client, in memory, one slot, and +holds paths — so a paste re-resolves (a yanked album expands to its +tracks). Note `p` no longer inserts the library selection: that flow is now +`y` on the left, then `p` on the right. - `w` saves the selection (a library subtree, or in the queue pane the queue) as a new folder under `/crabidy/` of **link** files — diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index b2f6b5e..342f6e0 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -720,7 +720,8 @@ impl App { .constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref()) .split(main[1]); - self.queue.render(f, right_side[0], queue_focused); + self.queue + .render(f, right_side[0], queue_focused, self.register.len()); self.now_playing.render(f, right_side[1]); // The node-creation/rename input: one line inside the bottom of the diff --git a/cbd-tui/src/app/queue.rs b/cbd-tui/src/app/queue.rs index 1730a9c..e310d49 100644 --- a/cbd-tui/src/app/queue.rs +++ b/cbd-tui/src/app/queue.rs @@ -212,7 +212,10 @@ impl Queue { self.update_selection(); } - pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { + /// Draws the pane. `register_len` is the number of entries `p`/`P` would + /// paste; a non-zero count is shown in the title so a paste is never + /// blind. + pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool, register_len: usize) { let selected = self.list_state.selected(); // Render only the visible rows; `view` is the rendered index the // selection bar keys off, `real` the queue position (which drives @@ -280,9 +283,10 @@ impl Queue { // Visual (paint-select) mode: movement toggles marks. "Queue — VISUAL".to_string() } else { - match self.filter.query() { - Some(query) => format!("Queue — /{query}▏"), - None => "Queue".to_string(), + match (self.filter.query(), register_len) { + (Some(query), _) => format!("Queue — /{query}▏"), + (None, 0) => "Queue".to_string(), + (None, n) => format!("Queue — register: {n}"), } }), ) @@ -369,7 +373,7 @@ mod tests { let backend = TestBackend::new(40, 8); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal - .draw(|f| queue.render(f, f.area(), true)) + .draw(|f| queue.render(f, f.area(), true, 0)) .expect("draw"); let buffer = terminal.backend().buffer().clone(); (0..buffer.area.height) @@ -455,7 +459,7 @@ mod tests { let backend = TestBackend::new(40, 8); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal - .draw(|f| queue.render(f, f.area(), true)) + .draw(|f| queue.render(f, f.area(), true, 0)) .expect("draw"); let buffer = terminal.backend().buffer().clone(); for y in 0..buffer.area.height { diff --git a/cbd-web/src/app.rs b/cbd-web/src/app.rs index 0bad675..034eded 100644 --- a/cbd-web/src/app.rs +++ b/cbd-web/src/app.rs @@ -16,7 +16,7 @@ use crate::keymap::{self, Action}; use crate::rpc::Rpc; use crate::state::{ format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane, - NamePurpose, QueueCursor, UiItemKind, + NamePurpose, QueueCursor, Register, UiItemKind, }; const VOLUME_STEP: f32 = 0.1; @@ -119,6 +119,9 @@ struct Store { queue_cursor: RwSignal, focus: RwSignal, dialog: RwSignal>, + /// What `y`/`d`/`c`/`C` set aside and `p`/`P` paste back + /// (architecture/queue-register.md). Per client, one slot, in memory. + register: RwSignal, toast: RwSignal>, /// The transport; local because the wasm client is not `Send`. rpc: StoredValue, LocalStorage>, @@ -150,6 +153,7 @@ impl Store { queue_cursor: RwSignal::new(QueueCursor::default()), focus: RwSignal::new(Focus::Library), dialog: RwSignal::new(None), + register: RwSignal::new(Register::default()), toast: RwSignal::new(None), rpc: StoredValue::new_local(None), cache: StoredValue::new_local(HashMap::new()), @@ -191,9 +195,11 @@ impl Store { StreamUpdate::Queue(queue) => { self.queue_pos.set(queue.current_position); self.resolving.set(queue.resolving); + // Marks follow their own track across the snapshot rather + // than their index (architecture/queue-register.md D5). + let paths: Vec = queue.tracks.iter().map(|t| t.path.clone()).collect(); self.queue.set(queue.tracks); - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.clamp(len)); + self.queue_cursor.update(|c| c.reconcile(paths)); } StreamUpdate::Mods(mods) => self.mods.set(mods), StreamUpdate::QueueTrack(queue_track) => { @@ -286,7 +292,98 @@ impl Store { } /// Executes one keymap action — the web twin of the TUI dispatch. + /// Move the library cursor, painting the swept range when visual mode is + /// active — the web mirror of the TUI's `library_move`. + fn library_move(&self, mv: impl FnOnce(&mut LibraryPane)) { + self.library.update(|pane| { + let old = pane.selected; + mv(pane); + pane.paint_between(old, pane.selected); + }); + } + + /// Move the queue cursor, painting the swept range when visual mode is + /// active — the web mirror of the TUI's `queue_move`. + fn queue_move(&self, mv: impl FnOnce(&mut QueueCursor, usize)) { + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor.update(|cursor| { + let old = cursor.selected; + mv(cursor, len); + let new = cursor.selected; + cursor.paint_between(old, new); + }); + } + + /// Put the tracks at `positions` in the register. Returns whether + /// anything was stored, so callers can decide about clearing marks. + fn yank_positions(&self, positions: &[usize]) -> bool { + let (paths, labels) = self.queue.with_untracked(|queue| { + let mut paths = Vec::new(); + let mut labels = Vec::new(); + for pos in positions { + if let Some(track) = queue.get(*pos) { + paths.push(track.path.clone()); + labels.push(track_label(track)); + } + } + (paths, labels) + }); + if paths.is_empty() { + return false; + } + self.register.update(|r| r.set(paths, labels)); + true + } + + /// `p`/`P`: insert the register at the cursor. An empty register is a + /// no-op, and the register survives so it can be pasted again. + fn paste(&self, before: bool) { + let paths = self.register.with_untracked(|r| r.paths().to_vec()); + if paths.is_empty() { + return; + } + let position = self + .queue_cursor + .with_untracked(|c| c.paste_position(before)); + self.queue_op(async move |mut rpc: Rpc| rpc.insert_tracks(position, paths).await); + } + + /// Whether `action` keeps a pane in visual mode. Everything else leaves + /// it before running, exactly as in the TUI. + fn keeps_visual(action: Action) -> bool { + matches!( + action, + Action::LibraryFirst + | Action::LibraryLast + | Action::LibraryNext + | Action::LibraryPrev + | Action::LibraryJumpDown + | Action::LibraryJumpUp + | Action::LibraryVisualMode + | Action::QueueFirst + | Action::QueueLast + | Action::QueueNext + | Action::QueuePrev + | Action::QueueJumpDown + | Action::QueueJumpUp + | Action::QueueSelectCurrent + | Action::QueueVisualMode + ) + } + fn dispatch(&self, action: Action) { + if !Self::keeps_visual(action) { + if self.library.with_untracked(LibraryPane::is_visual) { + self.library.update(LibraryPane::exit_visual); + } + if self.queue_cursor.with_untracked(QueueCursor::is_visual) { + self.queue_cursor.update(QueueCursor::exit_visual); + } + } + self.dispatch_inner(action) + } + + fn dispatch_inner(&self, action: Action) { match action { Action::OpenHelp => self.dialog.set(Some(Dialog::Help)), Action::CloseHelp => self.dialog.set(None), @@ -309,12 +406,12 @@ impl Store { Action::ToggleMute => self.call(async |mut rpc: Rpc| rpc.toggle_mute().await), Action::ToggleShuffle => self.call(async |mut rpc: Rpc| rpc.toggle_shuffle().await), Action::ToggleRepeat => self.call(async |mut rpc: Rpc| rpc.toggle_repeat().await), - Action::LibraryNext => self.library.update(|p| p.select_by(1)), - Action::LibraryPrev => self.library.update(|p| p.select_by(-1)), - Action::LibraryFirst => self.library.update(LibraryPane::select_first), - Action::LibraryLast => self.library.update(LibraryPane::select_last), - Action::LibraryJumpDown => self.library.update(|p| p.select_by(JUMP)), - Action::LibraryJumpUp => self.library.update(|p| p.select_by(-JUMP)), + Action::LibraryNext => self.library_move(|p| p.select_by(1)), + Action::LibraryPrev => self.library_move(|p| p.select_by(-1)), + Action::LibraryFirst => self.library_move(LibraryPane::select_first), + Action::LibraryLast => self.library_move(LibraryPane::select_last), + Action::LibraryJumpDown => self.library_move(|p| p.select_by(JUMP)), + Action::LibraryJumpUp => self.library_move(|p| p.select_by(-JUMP)), Action::LibraryAscend => { if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) { self.open_library_node(parent); @@ -355,17 +452,6 @@ impl Store { self.queue_op(async |mut rpc: Rpc| rpc.queue_tracks(paths).await); } } - Action::QueueInsertHere => { - let position = self.queue_cursor.with_untracked(|c| c.selected as u32); - if let Some(paths) = self - .library - .with_untracked(LibraryPane::queueable_selection) - { - self.queue_op(async move |mut rpc: Rpc| { - rpc.insert_tracks(position, paths).await - }); - } - } Action::LibraryCreateNode => { let creatable = self .library @@ -425,33 +511,17 @@ impl Store { })); } } - Action::QueueNext => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.select_by(1, len)); - } - Action::QueuePrev => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.select_by(-1, len)); - } - Action::QueueFirst => self.queue_cursor.update(|c| c.selected = 0), + Action::QueueNext => self.queue_move(|c, len| c.select_by(1, len)), + Action::QueuePrev => self.queue_move(|c, len| c.select_by(-1, len)), + Action::QueueFirst => self.queue_move(|c, _| c.selected = 0), Action::QueueLast => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor - .update(|c| c.selected = len.saturating_sub(1)); - } - Action::QueueJumpDown => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.select_by(JUMP, len)); - } - Action::QueueJumpUp => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.select_by(-JUMP, len)); + self.queue_move(|c, len| c.selected = len.saturating_sub(1)); } + Action::QueueJumpDown => self.queue_move(|c, len| c.select_by(JUMP, len)), + Action::QueueJumpUp => self.queue_move(|c, len| c.select_by(-JUMP, len)), Action::QueueSelectCurrent => { let current = self.queue_pos.get_untracked() as usize; - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor - .update(|c| c.selected = current.min(len.saturating_sub(1))); + self.queue_move(|c, len| c.selected = current.min(len.saturating_sub(1))); } Action::QueuePlaySelected => { let position = self.queue_cursor.with_untracked(|c| c.selected as u32); @@ -460,15 +530,52 @@ impl Store { } } Action::QueueRemoveTrack => { - let position = self.queue_cursor.with_untracked(|c| c.selected as u32); - if self.queue.with_untracked(|q| !q.is_empty()) { - self.call(async move |mut rpc: Rpc| rpc.remove_tracks(vec![position]).await); + let positions = self + .queue_cursor + .with_untracked(QueueCursor::action_positions); + if positions.is_empty() { + return; + } + // Hand them to the register first, so `p`/`P` brings them back. + self.yank_positions(&positions); + self.queue_cursor.update(QueueCursor::remove_marks); + let wire: Vec = positions.iter().map(|p| *p as u32).collect(); + self.call(async move |mut rpc: Rpc| rpc.remove_tracks(wire).await); + } + Action::QueueToggleMark => self.queue_cursor.update(QueueCursor::toggle_mark), + Action::QueueVisualMode => self.queue_cursor.update(QueueCursor::toggle_visual), + Action::QueueYank => { + let positions = self + .queue_cursor + .with_untracked(QueueCursor::action_positions); + if self.yank_positions(&positions) { + self.queue_cursor.update(QueueCursor::remove_marks); + } + } + Action::QueuePaste => self.paste(false), + Action::QueuePasteBefore => self.paste(true), + Action::LibraryVisualMode => self.library.update(LibraryPane::toggle_visual), + Action::LibraryYank => { + if let Some((paths, labels)) = + self.library.with_untracked(LibraryPane::yank_selection) + { + self.register.update(|r| r.set(paths, labels)); + self.library.update(LibraryPane::remove_marks); } } Action::QueueClearKeepCurrent => { + let current = self.queue_pos.get_untracked() as usize; + let keep: Vec = (0..self.queue.with_untracked(Vec::len)) + .filter(|pos| *pos != current) + .collect(); + self.yank_positions(&keep); self.call(async |mut rpc: Rpc| rpc.clear_queue(true).await) } - Action::QueueClearAll => self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await), + Action::QueueClearAll => { + let all: Vec = (0..self.queue.with_untracked(Vec::len)).collect(); + self.yank_positions(&all); + self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await) + } Action::QueueSaveAs => { if self.queue.with_untracked(|q| !q.is_empty()) { self.dialog.set(Some(Dialog::Name { @@ -935,14 +1042,19 @@ fn QueueView(store: Store) -> impl IntoView { "queue" {move || store.resolving.get().then_some(" (loading…)")} + {move || { + // A paste is blind unless the register is visible. + let reg = store.register.get(); + (!reg.is_empty()).then(|| format!(" — register: {}", reg.len())) + }}