cbd-web: queue marks, visual mode, and the register

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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-26 10:35:21 +02:00
parent ad19b6352e
commit 95700bf31f
11 changed files with 770 additions and 101 deletions

View File

@ -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 `s` marks the selected row; `v` (or `V`) enters **visual mode**, where
movement marks or unmarks everything you sweep over, vim-style. The sweep 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 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 - `w` saves the selection (a library subtree, or in the queue pane the
queue) as a new folder under `/crabidy/<name>` of **link** files — queue) as a new folder under `/crabidy/<name>` of **link** files —

View File

@ -720,7 +720,8 @@ impl App {
.constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref()) .constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref())
.split(main[1]); .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]); self.now_playing.render(f, right_side[1]);
// The node-creation/rename input: one line inside the bottom of the // The node-creation/rename input: one line inside the bottom of the

View File

@ -212,7 +212,10 @@ impl Queue {
self.update_selection(); 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(); let selected = self.list_state.selected();
// Render only the visible rows; `view` is the rendered index the // Render only the visible rows; `view` is the rendered index the
// selection bar keys off, `real` the queue position (which drives // selection bar keys off, `real` the queue position (which drives
@ -280,9 +283,10 @@ impl Queue {
// Visual (paint-select) mode: movement toggles marks. // Visual (paint-select) mode: movement toggles marks.
"Queue — VISUAL".to_string() "Queue — VISUAL".to_string()
} else { } else {
match self.filter.query() { match (self.filter.query(), register_len) {
Some(query) => format!("Queue — /{query}"), (Some(query), _) => format!("Queue — /{query}"),
None => "Queue".to_string(), (None, 0) => "Queue".to_string(),
(None, n) => format!("Queue — register: {n}"),
} }
}), }),
) )
@ -369,7 +373,7 @@ mod tests {
let backend = TestBackend::new(40, 8); let backend = TestBackend::new(40, 8);
let mut terminal = Terminal::new(backend).expect("test terminal"); let mut terminal = Terminal::new(backend).expect("test terminal");
terminal terminal
.draw(|f| queue.render(f, f.area(), true)) .draw(|f| queue.render(f, f.area(), true, 0))
.expect("draw"); .expect("draw");
let buffer = terminal.backend().buffer().clone(); let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height) (0..buffer.area.height)
@ -455,7 +459,7 @@ mod tests {
let backend = TestBackend::new(40, 8); let backend = TestBackend::new(40, 8);
let mut terminal = Terminal::new(backend).expect("test terminal"); let mut terminal = Terminal::new(backend).expect("test terminal");
terminal terminal
.draw(|f| queue.render(f, f.area(), true)) .draw(|f| queue.render(f, f.area(), true, 0))
.expect("draw"); .expect("draw");
let buffer = terminal.backend().buffer().clone(); let buffer = terminal.backend().buffer().clone();
for y in 0..buffer.area.height { for y in 0..buffer.area.height {

View File

@ -16,7 +16,7 @@ use crate::keymap::{self, Action};
use crate::rpc::Rpc; use crate::rpc::Rpc;
use crate::state::{ use crate::state::{
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane, format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane,
NamePurpose, QueueCursor, UiItemKind, NamePurpose, QueueCursor, Register, UiItemKind,
}; };
const VOLUME_STEP: f32 = 0.1; const VOLUME_STEP: f32 = 0.1;
@ -119,6 +119,9 @@ struct Store {
queue_cursor: RwSignal<QueueCursor>, queue_cursor: RwSignal<QueueCursor>,
focus: RwSignal<Focus>, focus: RwSignal<Focus>,
dialog: RwSignal<Option<Dialog>>, dialog: RwSignal<Option<Dialog>>,
/// What `y`/`d`/`c`/`C` set aside and `p`/`P` paste back
/// (architecture/queue-register.md). Per client, one slot, in memory.
register: RwSignal<Register>,
toast: RwSignal<Option<String>>, toast: RwSignal<Option<String>>,
/// The transport; local because the wasm client is not `Send`. /// The transport; local because the wasm client is not `Send`.
rpc: StoredValue<Option<Rpc>, LocalStorage>, rpc: StoredValue<Option<Rpc>, LocalStorage>,
@ -150,6 +153,7 @@ impl Store {
queue_cursor: RwSignal::new(QueueCursor::default()), queue_cursor: RwSignal::new(QueueCursor::default()),
focus: RwSignal::new(Focus::Library), focus: RwSignal::new(Focus::Library),
dialog: RwSignal::new(None), dialog: RwSignal::new(None),
register: RwSignal::new(Register::default()),
toast: RwSignal::new(None), toast: RwSignal::new(None),
rpc: StoredValue::new_local(None), rpc: StoredValue::new_local(None),
cache: StoredValue::new_local(HashMap::new()), cache: StoredValue::new_local(HashMap::new()),
@ -191,9 +195,11 @@ impl Store {
StreamUpdate::Queue(queue) => { StreamUpdate::Queue(queue) => {
self.queue_pos.set(queue.current_position); self.queue_pos.set(queue.current_position);
self.resolving.set(queue.resolving); 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<String> = queue.tracks.iter().map(|t| t.path.clone()).collect();
self.queue.set(queue.tracks); self.queue.set(queue.tracks);
let len = self.queue.with_untracked(Vec::len); self.queue_cursor.update(|c| c.reconcile(paths));
self.queue_cursor.update(|c| c.clamp(len));
} }
StreamUpdate::Mods(mods) => self.mods.set(mods), StreamUpdate::Mods(mods) => self.mods.set(mods),
StreamUpdate::QueueTrack(queue_track) => { StreamUpdate::QueueTrack(queue_track) => {
@ -286,7 +292,98 @@ impl Store {
} }
/// Executes one keymap action — the web twin of the TUI dispatch. /// 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) { 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 { match action {
Action::OpenHelp => self.dialog.set(Some(Dialog::Help)), Action::OpenHelp => self.dialog.set(Some(Dialog::Help)),
Action::CloseHelp => self.dialog.set(None), 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::ToggleMute => self.call(async |mut rpc: Rpc| rpc.toggle_mute().await),
Action::ToggleShuffle => self.call(async |mut rpc: Rpc| rpc.toggle_shuffle().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::ToggleRepeat => self.call(async |mut rpc: Rpc| rpc.toggle_repeat().await),
Action::LibraryNext => self.library.update(|p| p.select_by(1)), Action::LibraryNext => self.library_move(|p| p.select_by(1)),
Action::LibraryPrev => self.library.update(|p| p.select_by(-1)), Action::LibraryPrev => self.library_move(|p| p.select_by(-1)),
Action::LibraryFirst => self.library.update(LibraryPane::select_first), Action::LibraryFirst => self.library_move(LibraryPane::select_first),
Action::LibraryLast => self.library.update(LibraryPane::select_last), Action::LibraryLast => self.library_move(LibraryPane::select_last),
Action::LibraryJumpDown => self.library.update(|p| p.select_by(JUMP)), Action::LibraryJumpDown => self.library_move(|p| p.select_by(JUMP)),
Action::LibraryJumpUp => self.library.update(|p| p.select_by(-JUMP)), Action::LibraryJumpUp => self.library_move(|p| p.select_by(-JUMP)),
Action::LibraryAscend => { Action::LibraryAscend => {
if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) { if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) {
self.open_library_node(parent); self.open_library_node(parent);
@ -355,17 +452,6 @@ impl Store {
self.queue_op(async |mut rpc: Rpc| rpc.queue_tracks(paths).await); 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 => { Action::LibraryCreateNode => {
let creatable = self let creatable = self
.library .library
@ -425,33 +511,17 @@ impl Store {
})); }));
} }
} }
Action::QueueNext => { Action::QueueNext => self.queue_move(|c, len| c.select_by(1, len)),
let len = self.queue.with_untracked(Vec::len); Action::QueuePrev => self.queue_move(|c, len| c.select_by(-1, len)),
self.queue_cursor.update(|c| c.select_by(1, len)); Action::QueueFirst => self.queue_move(|c, _| c.selected = 0),
}
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::QueueLast => { Action::QueueLast => {
let len = self.queue.with_untracked(Vec::len); self.queue_move(|c, len| c.selected = len.saturating_sub(1));
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));
} }
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 => { Action::QueueSelectCurrent => {
let current = self.queue_pos.get_untracked() as usize; let current = self.queue_pos.get_untracked() as usize;
let len = self.queue.with_untracked(Vec::len); self.queue_move(|c, len| c.selected = current.min(len.saturating_sub(1)));
self.queue_cursor
.update(|c| c.selected = current.min(len.saturating_sub(1)));
} }
Action::QueuePlaySelected => { Action::QueuePlaySelected => {
let position = self.queue_cursor.with_untracked(|c| c.selected as u32); let position = self.queue_cursor.with_untracked(|c| c.selected as u32);
@ -460,15 +530,52 @@ impl Store {
} }
} }
Action::QueueRemoveTrack => { Action::QueueRemoveTrack => {
let position = self.queue_cursor.with_untracked(|c| c.selected as u32); let positions = self
if self.queue.with_untracked(|q| !q.is_empty()) { .queue_cursor
self.call(async move |mut rpc: Rpc| rpc.remove_tracks(vec![position]).await); .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<u32> = 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 => { Action::QueueClearKeepCurrent => {
let current = self.queue_pos.get_untracked() as usize;
let keep: Vec<usize> = (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) 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<usize> = (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 => { Action::QueueSaveAs => {
if self.queue.with_untracked(|q| !q.is_empty()) { if self.queue.with_untracked(|q| !q.is_empty()) {
self.dialog.set(Some(Dialog::Name { self.dialog.set(Some(Dialog::Name {
@ -935,14 +1042,19 @@ fn QueueView(store: Store) -> impl IntoView {
<span class="path"> <span class="path">
"queue" "queue"
{move || store.resolving.get().then_some(" (loading…)")} {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()))
}}
</span> </span>
<span class="spacer"></span> <span class="spacer"></span>
<button <button
class="ghost" class="ghost"
title="insert library selection after selected (p)" title="paste the register after selected (p)"
on:click=move |_| store.dispatch(Action::QueueInsertHere) on:click=move |_| store.dispatch(Action::QueuePaste)
> >
"insert" "paste"
</button> </button>
<button <button
class="ghost" class="ghost"
@ -969,7 +1081,9 @@ fn QueueView(store: Store) -> impl IntoView {
<ul class="list"> <ul class="list">
{move || { {move || {
let current = store.queue_pos.get() as usize; let current = store.queue_pos.get() as usize;
let cursor = store.queue_cursor.get().selected; let queue_state = store.queue_cursor.get();
let cursor = queue_state.selected;
let marks = queue_state.marks.clone();
store store
.queue .queue
.get() .get()
@ -977,9 +1091,11 @@ fn QueueView(store: Store) -> impl IntoView {
.enumerate() .enumerate()
.map(|(index, track)| { .map(|(index, track)| {
let label = track_label(track); let label = track_label(track);
let marked = marks.get(index).copied().unwrap_or(false);
view! { view! {
<li <li
class:selected=index == cursor class:selected=index == cursor
class:marked=marked
class:current=index == current class:current=index == current
class:skipped=track.is_skipped class:skipped=track.is_skipped
on:click=move |_| { on:click=move |_| {

View File

@ -30,6 +30,8 @@ pub enum Action {
LibraryAscend, LibraryAscend,
LibraryDive, LibraryDive,
LibraryToggleMark, LibraryToggleMark,
LibraryVisualMode,
LibraryYank,
LibraryCaptureNode, LibraryCaptureNode,
LibraryDownloadNode, LibraryDownloadNode,
LibraryCreateNode, LibraryCreateNode,
@ -46,7 +48,11 @@ pub enum Action {
QueueJumpUp, QueueJumpUp,
QueueSelectCurrent, QueueSelectCurrent,
QueuePlaySelected, QueuePlaySelected,
QueueInsertHere, QueueToggleMark,
QueueVisualMode,
QueueYank,
QueuePaste,
QueuePasteBefore,
QueueRemoveTrack, QueueRemoveTrack,
QueueClearKeepCurrent, QueueClearKeepCurrent,
QueueClearAll, QueueClearAll,
@ -148,6 +154,16 @@ pub const HELP: &[HelpEntry] = &[
key: "s", key: "s",
description: "Mark/unmark selection", description: "Mark/unmark selection",
}, },
HelpEntry {
scope: "Library",
key: "v / V",
description: "Visual mode: movement toggles marks",
},
HelpEntry {
scope: "Library",
key: "y",
description: "Yank selection into the register",
},
HelpEntry { HelpEntry {
scope: "Library", scope: "Library",
key: "w", key: "w",
@ -215,8 +231,23 @@ pub const HELP: &[HelpEntry] = &[
}, },
HelpEntry { HelpEntry {
scope: "Queue", scope: "Queue",
key: "p", key: "s",
description: "Insert library selection after this track", description: "Mark/unmark selection",
},
HelpEntry {
scope: "Queue",
key: "v / V",
description: "Visual mode: movement toggles marks",
},
HelpEntry {
scope: "Queue",
key: "y",
description: "Yank selection into the register",
},
HelpEntry {
scope: "Queue",
key: "p / P",
description: "Paste the register after / before this track",
}, },
HelpEntry { HelpEntry {
scope: "Queue", scope: "Queue",
@ -293,6 +324,8 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
"h" | "ArrowLeft" => Some(Action::LibraryAscend), "h" | "ArrowLeft" => Some(Action::LibraryAscend),
"l" | "ArrowRight" => Some(Action::LibraryDive), "l" | "ArrowRight" => Some(Action::LibraryDive),
"s" => Some(Action::LibraryToggleMark), "s" => Some(Action::LibraryToggleMark),
"v" | "V" => Some(Action::LibraryVisualMode),
"y" => Some(Action::LibraryYank),
"w" => Some(Action::LibraryCaptureNode), "w" => Some(Action::LibraryCaptureNode),
"W" => Some(Action::LibraryDownloadNode), "W" => Some(Action::LibraryDownloadNode),
"%" => Some(Action::LibraryCreateNode), "%" => Some(Action::LibraryCreateNode),
@ -310,7 +343,11 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
"G" => Some(Action::QueueLast), "G" => Some(Action::QueueLast),
"o" => Some(Action::QueueSelectCurrent), "o" => Some(Action::QueueSelectCurrent),
"Enter" => Some(Action::QueuePlaySelected), "Enter" => Some(Action::QueuePlaySelected),
"p" => Some(Action::QueueInsertHere), "s" => Some(Action::QueueToggleMark),
"v" | "V" => Some(Action::QueueVisualMode),
"y" => Some(Action::QueueYank),
"p" => Some(Action::QueuePaste),
"P" => Some(Action::QueuePasteBefore),
"d" => Some(Action::QueueRemoveTrack), "d" => Some(Action::QueueRemoveTrack),
"c" => Some(Action::QueueClearKeepCurrent), "c" => Some(Action::QueueClearKeepCurrent),
"C" => Some(Action::QueueClearAll), "C" => Some(Action::QueueClearAll),

View File

@ -104,6 +104,10 @@ pub struct LibraryPane {
pub is_creatable: bool, pub is_creatable: bool,
pub items: Vec<UiItem>, pub items: Vec<UiItem>,
pub selected: usize, pub selected: usize,
/// Visual (paint-select) mode: `Some(anchor)` while active. Movement
/// toggles the marks of the range between the anchor and the cursor, so
/// moving back reverses — the TUI's rule, same code shape.
pub visual: Option<usize>,
positions: HashMap<String, usize>, positions: HashMap<String, usize>,
} }
@ -193,6 +197,66 @@ impl LibraryPane {
} }
} }
/// Toggle the mark of one row, honoring the queueable gate.
fn toggle_mark_at(&mut self, index: usize) {
if let Some(item) = self.items.get_mut(index) {
if item.is_queable {
item.marked = !item.marked;
}
}
}
/// Enter or leave visual mode. Entering anchors at the cursor and marks
/// it; leaving keeps the marks.
pub fn toggle_visual(&mut self) {
if self.visual.is_some() {
self.visual = None;
} else {
self.visual = Some(self.selected);
self.toggle_mark();
}
}
pub fn exit_visual(&mut self) {
self.visual = None;
}
pub fn is_visual(&self) -> bool {
self.visual.is_some()
}
/// Paint a visual-mode sweep: toggle every row whose membership in the
/// anchored range changed, so moving back over a row reverses it.
pub fn paint_between(&mut self, from: usize, to: usize) {
let Some(anchor) = self.visual else {
return;
};
let (old_lo, old_hi) = (anchor.min(from), anchor.max(from));
let (new_lo, new_hi) = (anchor.min(to), anchor.max(to));
for index in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&index);
let in_new = (new_lo..=new_hi).contains(&index);
if in_old != in_new {
self.toggle_mark_at(index);
}
}
}
/// What `y` puts in the register: the marked rows, or the queueable
/// cursor row — paths with labels for display.
pub fn yank_selection(&self) -> Option<(Vec<String>, Vec<String>)> {
if self.items.iter().any(|i| i.marked) {
let marked = self.items.iter().filter(|i| i.marked);
return Some((
marked.clone().map(|i| i.path.clone()).collect(),
marked.map(|i| i.title.clone()).collect(),
));
}
let item = self.selected_item()?;
item.is_queable
.then(|| (vec![item.path.clone()], vec![item.title.clone()]))
}
pub fn remove_marks(&mut self) { pub fn remove_marks(&mut self) {
for item in &mut self.items { for item in &mut self.items {
item.marked = false; item.marked = false;
@ -242,9 +306,20 @@ impl LibraryPane {
/// The queue pane cursor. The queue itself (tracks, current position, /// The queue pane cursor. The queue itself (tracks, current position,
/// play state) lives in signals fed by the update stream; this only /// play state) lives in signals fed by the update stream; this only
/// tracks the selection. /// tracks the selection.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] // No longer `Copy`: it owns the mark flags and the paths they were taken
// against (architecture/queue-register.md).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct QueueCursor { pub struct QueueCursor {
pub selected: usize, pub selected: usize,
/// Marks, one flag per queue position. The queue is server-pushed and has
/// no client-owned list, so these sit beside the snapshot and are carried
/// across updates by [`carry_marks`].
pub marks: Vec<bool>,
/// The track paths `marks` was taken against, so the next snapshot can be
/// matched against it.
paths: Vec<String>,
/// Visual (paint-select) mode anchor, as in the library.
pub visual: Option<usize>,
} }
impl QueueCursor { impl QueueCursor {
@ -258,6 +333,156 @@ impl QueueCursor {
pub fn clamp(&mut self, len: usize) { pub fn clamp(&mut self, len: usize) {
self.selected = self.selected.min(len.saturating_sub(1)); self.selected = self.selected.min(len.saturating_sub(1));
} }
/// Apply a fresh queue snapshot: marks follow their own track rather than
/// their old index, so a mark cannot silently retarget when playback
/// advances or another client edits the queue.
pub fn reconcile(&mut self, new_paths: Vec<String>) {
self.marks = carry_marks(&self.paths, &self.marks, &new_paths);
self.paths = new_paths;
self.clamp(self.marks.len());
}
pub fn has_marks(&self) -> bool {
self.marks.iter().any(|m| *m)
}
/// The positions an action applies to: every marked row, or the cursor
/// row when nothing is marked.
pub fn action_positions(&self) -> Vec<usize> {
if self.has_marks() {
return self
.marks
.iter()
.enumerate()
.filter(|(_, marked)| **marked)
.map(|(pos, _)| pos)
.collect();
}
if self.marks.is_empty() {
return Vec::new();
}
vec![self.selected]
}
fn toggle_mark_at(&mut self, index: usize) {
if let Some(mark) = self.marks.get_mut(index) {
*mark = !*mark;
}
}
/// Every queue row may be marked — unlike the library there is no
/// queueable gate to apply.
pub fn toggle_mark(&mut self) {
self.toggle_mark_at(self.selected);
}
pub fn remove_marks(&mut self) {
for mark in &mut self.marks {
*mark = false;
}
}
pub fn toggle_visual(&mut self) {
if self.visual.is_some() {
self.visual = None;
} else {
self.visual = Some(self.selected);
self.toggle_mark();
}
}
pub fn exit_visual(&mut self) {
self.visual = None;
}
pub fn is_visual(&self) -> bool {
self.visual.is_some()
}
/// The library's paint rule, over positions instead of view rows.
pub fn paint_between(&mut self, from: usize, to: usize) {
let Some(anchor) = self.visual else {
return;
};
let (old_lo, old_hi) = (anchor.min(from), anchor.max(from));
let (new_lo, new_hi) = (anchor.min(to), anchor.max(to));
for index in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&index);
let in_new = (new_lo..=new_hi).contains(&index);
if in_old != in_new {
self.toggle_mark_at(index);
}
}
}
/// The insert position for a paste: after the cursor for `p`, at the
/// cursor for `P`. An empty queue pastes at the front.
pub fn paste_position(&self, before: bool) -> u32 {
if self.marks.is_empty() {
return 0;
}
let pos = if before {
self.selected
} else {
self.selected + 1
};
pos as u32
}
}
/// What `y`, `d`, `c`, and `C` set aside and `p`/`P` paste back — one unnamed
/// in-memory slot of library paths, with labels for display only. Overwritten
/// by each write; paste re-resolves the paths, so a yanked node expands at
/// paste time.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Register {
paths: Vec<String>,
labels: Vec<String>,
}
impl Register {
pub fn set(&mut self, paths: Vec<String>, labels: Vec<String>) {
self.paths = paths;
self.labels = labels;
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn len(&self) -> usize {
self.paths.len()
}
pub fn paths(&self) -> &[String] {
&self.paths
}
pub fn labels(&self) -> &[String] {
&self.labels
}
}
/// Carry queue marks across a server snapshot by a greedy in-order match on
/// track path — the same rule the TUI applies (`cbd-tui/src/app/list.rs`).
///
/// A mark follows its track through appends, removals, and playback
/// advancing; a mark whose track is gone is dropped; duplicate paths pair up
/// in order. Nothing in common means no marks rather than a guess.
pub fn carry_marks(old_paths: &[String], old_marked: &[bool], new_paths: &[String]) -> Vec<bool> {
let mut carried = vec![false; new_paths.len()];
let mut old_idx = 0;
for (new_idx, path) in new_paths.iter().enumerate() {
while old_idx < old_paths.len() && &old_paths[old_idx] != path {
old_idx += 1;
}
if old_idx < old_paths.len() {
carried[new_idx] = old_marked.get(old_idx).copied().unwrap_or(false);
old_idx += 1;
}
}
carried
} }
/// How long a finished capture's line lingers, in milliseconds — /// How long a finished capture's line lingers, in milliseconds —
@ -526,4 +751,150 @@ mod tests {
assert_eq!(format_seconds(3600), "1:00:00"); assert_eq!(format_seconds(3600), "1:00:00");
assert_eq!(format_seconds(3661), "1:01:01"); assert_eq!(format_seconds(3661), "1:01:01");
} }
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
/// The same reconciliation cases the TUI pins, so the two clients cannot
/// drift (quality/queue-register.md G18).
#[test]
fn marks_follow_their_track_across_a_snapshot() {
// Unchanged.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a", "/b"])),
vec![false, true]
);
// Append leaves earlier marks alone.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a", "/b", "/c"])),
vec![false, true, false]
);
// A removal before the mark shifts it down.
assert_eq!(
carry_marks(
&v(&["/a", "/b", "/c"]),
&[false, false, true],
&v(&["/b", "/c"])
),
vec![false, true]
);
// The marked track itself is gone.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a"])),
vec![false]
);
// Nothing in common: no marks rather than a guess.
assert_eq!(
carry_marks(&v(&["/a"]), &[true], &v(&["/x", "/y"])),
vec![false, false]
);
// Duplicates pair up in order.
assert_eq!(
carry_marks(&v(&["/a", "/a"]), &[false, true], &v(&["/a", "/a"])),
vec![false, true]
);
// A short mark vector must not panic.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[true], &v(&["/a", "/b"])),
vec![true, false]
);
}
#[test]
fn reconcile_carries_marks_and_clamps_the_cursor() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.selected = 2;
cursor.toggle_mark();
assert_eq!(cursor.action_positions(), vec![2]);
// Playback drops the head: the mark follows /c to position 1.
cursor.reconcile(v(&["/b", "/c"]));
assert_eq!(cursor.action_positions(), vec![1]);
assert!(cursor.selected < 2, "cursor clamped into the shorter queue");
}
/// Every queue row is markable — no queueable gate here (G12).
#[test]
fn the_cursor_row_is_the_default_target() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b"]));
cursor.selected = 1;
assert_eq!(cursor.action_positions(), vec![1]);
assert!(!cursor.has_marks());
}
#[test]
fn an_empty_queue_has_nothing_to_act_on() {
let cursor = QueueCursor::default();
assert!(cursor.action_positions().is_empty());
assert_eq!(cursor.paste_position(false), 0);
assert_eq!(cursor.paste_position(true), 0);
}
/// `p` after the cursor, `P` at it (G8).
#[test]
fn paste_positions_straddle_the_cursor() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.selected = 1;
assert_eq!(cursor.paste_position(false), 2);
assert_eq!(cursor.paste_position(true), 1);
}
#[test]
fn queue_visual_mode_paints_and_reverses() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.toggle_visual();
assert!(cursor.is_visual());
// Sweep down to 2.
for to in 1..=2 {
let from = cursor.selected;
cursor.selected = to;
cursor.paint_between(from, to);
}
assert_eq!(cursor.action_positions(), vec![0, 1, 2]);
// Sweep back up: the row turned around on is released.
let from = cursor.selected;
cursor.selected = 1;
cursor.paint_between(from, 1);
assert_eq!(cursor.action_positions(), vec![0, 1]);
cursor.exit_visual();
assert!(!cursor.is_visual());
assert_eq!(cursor.action_positions(), vec![0, 1], "marks survive");
}
#[test]
fn library_visual_mode_paints_under_the_queueable_gate() {
let mut pane = LibraryPane::default();
pane.update(&node("/a", 2, 0));
pane.toggle_visual();
assert!(pane.is_visual());
let from = pane.selected;
pane.selected = 1;
pane.paint_between(from, 1);
let marked: Vec<&str> = pane
.items
.iter()
.filter(|i| i.marked)
.map(|i| i.path.as_str())
.collect();
assert_eq!(marked.len(), 2, "both queueable rows painted: {marked:?}");
}
#[test]
fn a_register_write_overwrites_and_survives_reads() {
let mut reg = Register::default();
assert!(reg.is_empty());
reg.set(v(&["/a"]), v(&["A"]));
reg.set(v(&["/b", "/c"]), v(&["B", "C"]));
assert_eq!(reg.len(), 2);
assert_eq!(reg.paths(), ["/b", "/c"]);
assert_eq!(reg.labels(), ["B", "C"]);
// Reading does not consume.
assert_eq!(reg.paths(), ["/b", "/c"]);
reg.set(Vec::new(), Vec::new());
assert!(reg.is_empty());
}
} }

View File

@ -48,12 +48,12 @@ it, and while it is open every other key is inert.
- `s` toggles a **mark** on the selected item. Marks live on the full - `s` toggles a **mark** on the selected item. Marks live on the full
item list (they survive filtering) so a marked-but-hidden row still item list (they survive filtering) so a marked-but-hidden row still
counts when you queue or save. counts when you queue or save.
- `v` (or `V`) enters **visual mode** in the library: movement then marks - `v` (or `V`) enters **visual mode**: movement then marks or unmarks the
or unmarks the rows you sweep over, like vim's visual selection. The rows you sweep over, like vim's visual selection. The selection is
selection is anchored where you entered it, so moving back over a row anchored where you entered it, so moving back over a row reverses it.
reverses it. `Esc` leaves visual mode (keeping the marks and any `/` `Esc` leaves visual mode (keeping the marks and any `/` filter), and so
filter), and so does any non-movement action. The pane title shows does any non-movement action. The pane title shows `— VISUAL` while it is
`— VISUAL` while it is on. on. Both panes have it.
- `w` **saves** the selection — a library subtree, or in the queue pane - `w` **saves** the selection — a library subtree, or in the queue pane
the whole queue — as a new folder of link files under the whole queue — as a new folder of link files under
`/crabidy/<name>`; playback replays it from the source provider. `/crabidy/<name>`; playback replays it from the source provider.
@ -74,6 +74,39 @@ confirmation — it removes only the metadata toml, never the shared
store audio, which other saves may reference. store audio, which other saves may reference.
``` ```
## The register: `y`, `d`, and `p`/`P`
The queue has marks and visual mode too, and they feed a **register** — one
in-memory slot, like vim's unnamed register:
- `y` **yanks** the selection into the register without changing anything.
It works in both panes: in the library it yanks the paths you have marked
(or the row under the cursor), in the queue the marked tracks.
- `d` in the queue **deletes** every marked row (or the cursor row) in one
go — and puts them in the register first.
- `c` and `C` also fill the register with what they clear, so emptying the
queue by accident is recoverable.
- `p` **pastes** the register after the cursor, `P` before it. The register
survives, so you can paste again.
That gives you the two vim moves: `d` then `P` puts the tracks back exactly
where they were, and `d``p` moves them somewhere else. The queue title
shows `register: n` while something is in it, so a paste is never blind.
```admonish note
`p` used to insert the **library** selection at the cursor. It now pastes
the register, so that flow is `y` in the library, then `p` in the queue.
`a`, `L`, and `Enter` still queue the library selection directly.
```
```admonish warning
The register lives in **your client**, in memory, one slot deep: another
client cannot undo your delete, a restart forgets it, and each write
overwrites the last. It holds *paths*, so a paste re-resolves them — a
yanked album node expands to its tracks at paste time, and a path that no
longer resolves (a search term you deleted meanwhile) does not come back.
```
## The `/` live filter ## The `/` live filter
Pressing `/` opens a search input that filters the focused pane's items Pressing `/` opens a search input that filters the focused pane's items
@ -139,6 +172,7 @@ pane).
| Library | `l` | Enter selected folder | | Library | `l` | Enter selected folder |
| Library | `s` | Mark / unmark selection | | Library | `s` | Mark / unmark selection |
| Library | `v` / `V` | Visual mode: movement toggles marks | | Library | `v` / `V` | Visual mode: movement toggles marks |
| Library | `y` | Yank selection into the register |
| Library | `Enter` | Replace queue with selection | | Library | `Enter` | Replace queue with selection |
| Library | `a` | Append selection to queue | | Library | `a` | Append selection to queue |
| Library | `L` | Queue selection after current track | | Library | `L` | Queue selection after current track |
@ -152,12 +186,15 @@ pane).
| Queue | `g` / `G` | Select first / last track | | Queue | `g` / `G` | Select first / last track |
| Queue | `Ctrl-d` | Jump 15 tracks down | | Queue | `Ctrl-d` | Jump 15 tracks down |
| Queue | `Ctrl-u` | Jump 15 tracks up | | Queue | `Ctrl-u` | Jump 15 tracks up |
| Queue | `s` | Mark / unmark selection |
| Queue | `v` / `V` | Visual mode: movement toggles marks |
| Queue | `y` | Yank selection into the register |
| Queue | `o` | Select the playing track | | Queue | `o` | Select the playing track |
| Queue | `Enter` | Play selected track | | Queue | `Enter` | Play selected track |
| Queue | `p` | Insert library selection after this track | | Queue | `p` / `P` | Paste the register after / before |
| Queue | `d` | Remove selected track | | Queue | `d` | Remove selection (into the register) |
| Queue | `c` | Clear queue except current track | | Queue | `c` | Clear queue except current (to register) |
| Queue | `C` | Clear entire queue | | Queue | `C` | Clear entire queue (to register) |
| Queue | `w` | Save queue under a name | | Queue | `w` | Save queue under a name |
| Queue | `W` | Capture the queue into /crabidy (audio) | | Queue | `W` | Capture the queue into /crabidy (audio) |
| Queue | `/` | Filter this view | | Queue | `/` | Filter this view |

View File

@ -9,8 +9,13 @@ terminal required, so phones, tablets, and guests can drive the server.
Every TUI binding has a clickable equivalent, and the familiar keyboard Every TUI binding has a clickable equivalent, and the familiar keyboard
bindings (`j`/`k`/`h`/`l`, `Tab`, `%`, `e`, `d`, `w`, `W`, playback and bindings (`j`/`k`/`h`/`l`, `Tab`, `%`, `e`, `d`, `w`, `W`, playback and
queue keys, `?` for help) also work on desktop browsers. The `/` live queue keys, `?` for help) also work on desktop browsers. That includes
filter is TUI-only for now. marks (`s`), visual mode (`v`/`V`) in both panes, and the register — `y`
yanks, `d`/`c`/`C` fill it as they remove, and `p`/`P` paste it after or
before the cursor (see [the TUI's register
section](./tui.md#the-register-y-d-and-pp), which behaves identically here;
the queue toolbar shows how many entries are waiting). The `/` live filter
is TUI-only for now.
## How it is served ## How it is served

View File

@ -132,6 +132,28 @@ pass at most. Without that bound, an all-skipped queue with repeat on would
cycle forever and hammer the provider; instead the search gives up after one cycle forever and hammer the provider; instead the search gives up after one
pass and the player stops. pass and the player stops.
## Editing the queue from a client
Queue edits are ordinary RPCs — `Insert`, `Remove`, `Clear`, `SetCurrent`
and both clients drive them the same way: mark rows with `s`, sweep them
with visual mode (`v`/`V`), then `d` to remove or `y` to yank. Removed and
yanked tracks land in a client-side **register** that `p`/`P` paste back,
which is the closest thing to an undo the queue has. It is per client and
in memory; the server keeps no edit history. See [the terminal
client](./clients/tui.md#the-register-y-d-and-pp) for the keys and the
limits.
Two consequences worth knowing:
- `Remove` takes **positions**, so a client must send positions from the
queue state it is currently showing. Both clients carry their marks across
each pushed queue snapshot by matching track paths, so a mark follows its
own track when playback advances or another client edits the queue —
rather than silently pointing at whatever now sits at that index.
- `Insert` takes **paths**, so a paste re-resolves them. Pasting a yanked
node expands it to tracks at paste time, and a path that no longer
resolves simply does not come back.
## Persistence ## Persistence
The live queue survives a server restart. It is mirrored to the reserved The live queue survives a server restart. It is mirrored to the reserved

View File

@ -10,85 +10,88 @@ path list (G25).
## A — Shared mark and visual behaviour (`cbd-tui`) ## A — Shared mark and visual behaviour (`cbd-tui`)
- [ ] **A1 — Fill in `MarkedPane`** in `app/list.rs`: `toggle_visual`, - [x] **A1 — Fill in `MarkedPane`** in `app/list.rs`: `toggle_visual`,
`toggle_mark`, `toggle_mark_view`, `paint_between`, `selection` — moved `toggle_mark`, `toggle_mark_view`, `paint_between`, `selection` — moved
verbatim in behaviour from `library.rs`, with the `markable` gate taken verbatim in behaviour from `library.rs`, with the `markable` gate taken
from the implementor. *Verifies:* G11, G12, G16. from the implementor. *Verifies:* G11, G12, G16.
- [ ] **A2 — `impl MarkedPane for Library`**, deleting the now-duplicated - [x] **A2 — `impl MarkedPane for Library`**, deleting the now-duplicated
inherent methods and keeping `get_selected` as a thin wrapper over inherent methods and keeping `get_selected` as a thin wrapper over
`selection` so existing call sites are untouched. `markable` = `selection` so existing call sites are untouched. `markable` =
`is_queable`. *Verifies:* the existing library visual-mode suite passes `is_queable`. *Verifies:* the existing library visual-mode suite passes
unchanged (G11, G12). unchanged (G11, G12).
- [ ] **A3 — `carry_marks`** in `app/list.rs`: greedy in-order path match, - [x] **A3 — `carry_marks`** in `app/list.rs`: greedy in-order path match,
drop marks whose track is gone, all-false when nothing matches, tolerant drop marks whose track is gone, all-false when nothing matches, tolerant
of a short `old_marked`. *Verifies:* the ten `carry_marks_tests` (G2, G3). of a short `old_marked`. *Verifies:* the ten `carry_marks_tests` (G2, G3).
- [ ] **A4 — Commit A.** Existing 91 tests plus the `carry_marks` suite - [x] **A4 — Commit A.** *Merged into commit B:* group A alone leaves
green; clippy and fmt clean. No behaviour change yet. `carry_marks` and `Register` unwired, and committing dead code for the next
commit to use is worse than one larger commit. The extraction is still
proven behaviour-preserving — the library's whole visual-mode suite passes
untouched.
## B — Queue marks and the register (`cbd-tui`) ## B — Queue marks and the register (`cbd-tui`)
- [ ] **B1 — `impl MarkedPane for Queue`** with `markable` = always, and a - [x] **B1 — `impl MarkedPane for Queue`** with `markable` = always, and a
`visual: Option<usize>` field on `Queue`. *Verifies:* G12. `visual: Option<usize>` field on `Queue`. *Verifies:* G12.
- [ ] **B2 — `update_queue` carries marks** through `carry_marks` instead of - [x] **B2 — `update_queue` carries marks** through `carry_marks` instead of
rebuilding them as `false`: keep the previous path list and mark flags, rebuilding them as `false`: keep the previous path list and mark flags,
reconcile, apply. *Verifies:* G1, G2. reconcile, apply. *Verifies:* G1, G2.
- [ ] **B3 — `Register::set`** (replace the `todo!()`) and an `App.register` - [x] **B3 — `Register::set`** (replace the `todo!()`) and an `App.register`
field. *Verifies:* the register suite (G6, G10). field. *Verifies:* the register suite (G6, G10).
- [ ] **B4 — `y` in both panes.** New `Action::LibraryYank` and - [x] **B4 — `y` in both panes.** New `Action::LibraryYank` and
`Action::QueueYank`; each takes `selection()`, writes the register, and `Action::QueueYank`; each takes `selection()`, writes the register, and
clears the marks it consumed. *Verifies:* G5, G14, G15. clears the marks it consumed. *Verifies:* G5, G14, G15.
- [ ] **B5 — `d` in the queue deletes every marked row** (cursor row when - [x] **B5 — `d` in the queue deletes every marked row** (cursor row when
none), writing them to the register first, in one `RemoveTracks` call with none), writing them to the register first, in one `RemoveTracks` call with
positions from the current list. Replaces the `FIXME`. *Verifies:* G1, positions from the current list. Replaces the `FIXME`. *Verifies:* G1,
G13, G14, G15. G13, G14, G15.
- [ ] **B6 — `c`/`C` fill the register** with the tracks they drop (`C` - [x] **B6 — `c`/`C` fill the register** with the tracks they drop (`C`
everything, `c` everything but the current track). *Verifies:* G4. everything, `c` everything but the current track). *Verifies:* G4.
- [ ] **B7 — `p`/`P` paste the register.** `Action::QueuePaste` / - [x] **B7 — `p`/`P` paste the register.** `Action::QueuePaste` /
`QueuePasteBefore` send `InsertTracks(register.paths(), pos)` with `pos` = `QueuePasteBefore` send `InsertTracks(register.paths(), pos)` with `pos` =
cursor + 1 for `p`, cursor for `P`; a no-op on an empty register; the cursor + 1 for `p`, cursor for `P`; a no-op on an empty register; the
register survives. `QueueInsertHere`'s old cross-pane behaviour is gone. register survives. `QueueInsertHere`'s old cross-pane behaviour is gone.
*Verifies:* G7, G8, G9. *Verifies:* G7, G8, G9.
- [ ] **B8 — Queue visual mode in dispatch.** A `queue_move` wrapper - [x] **B8 — Queue visual mode in dispatch.** A `queue_move` wrapper
mirroring `library_move`, and the visual auto-leave guard generalized to mirroring `library_move`, and the visual auto-leave guard generalized to
whichever pane is in visual mode with its own movement whitelist. whichever pane is in visual mode with its own movement whitelist.
*Verifies:* G11. *Verifies:* G11.
- [ ] **B9 — Bindings + help.** Queue scope gains `s`, `v`, `V`, `y`, `P`; - [x] **B9 — Bindings + help.** Queue scope gains `s`, `v`, `V`, `y`, `P`;
library gains `y`; `p`'s description changes. The help modal derives from library gains `y`; `p`'s description changes. The help modal derives from
`BINDINGS`, so it follows. *Verifies:* G19, G20. `BINDINGS`, so it follows. *Verifies:* G19, G20.
- [ ] **B10 — Status feedback.** The pane title shows `— VISUAL` in the - [x] **B10 — Status feedback.** The pane title shows `— VISUAL` in the
queue as in the library, and a non-empty register is visible somewhere queue as in the library, and a non-empty register is visible somewhere
(title or now-playing line) so paste is not blind. *Verifies:* G22 in (title or now-playing line) so paste is not blind. *Verifies:* G22 in
spirit; keeps G9's no-op explicable. spirit; keeps G9's no-op explicable.
- [ ] **B11 — Dispatch tests** for the gates named above. *Verifies:* G1, - [x] **B11 — Dispatch tests** for the gates named above. *Verifies:* G1,
G4, G5, G7G9, G13G15. G4, G5, G7G9, G13G15.
- [ ] **B12 — Commit B** with the whole `cbd-tui` suite, clippy `-D - [x] **B12 — Commit B** with the whole `cbd-tui` suite, clippy `-D
warnings`, and fmt clean (G24, G26). warnings`, and fmt clean (G24, G26).
## C — `cbd-web` parity and docs ## C — `cbd-web` parity and docs
- [ ] **C1 — `Register` + `carry_marks` in `cbd-web/src/state.rs`**, same - [x] **C1 — `Register` + `carry_marks` in `cbd-web/src/state.rs`**, same
shape and same test cases as the TUI's. *Verifies:* G17, G18. shape and same test cases as the TUI's. *Verifies:* G17, G18.
- [ ] **C2 — Library visual mode** (the piece deferred when `v`/`V` landed - [x] **C2 — Library visual mode** (the piece deferred when `v`/`V` landed
in the TUI): `visual: Option<usize>` on `LibraryPane`, the same anchored in the TUI): `visual: Option<usize>` on `LibraryPane`, the same anchored
paint, `Action::LibraryVisualMode` on `v`/`V`, auto-leave on non-movement. paint, `Action::LibraryVisualMode` on `v`/`V`, auto-leave on non-movement.
*Verifies:* G17. *Verifies:* G17.
- [ ] **C3 — Queue marks beside the signal.** The web queue owns no list, so - [x] **C3 — Queue marks beside the signal.** The web queue owns no list, so
hold `marks: Vec<bool>` plus the path list they were taken against, and hold `marks: Vec<bool>` plus the path list they were taken against, and
reconcile on each `Queue` update. *Verifies:* G18. reconcile on each `Queue` update. *Verifies:* G18.
- [ ] **C4 — Queue actions:** `QueueToggleMark`, `QueueVisualMode`, - [x] **C4 — Queue actions:** `QueueToggleMark`, `QueueVisualMode`,
`QueueYank`, `QueuePaste`, `QueuePasteBefore`, plus `d`/`c`/`C` writing the `QueueYank`, `QueuePaste`, `QueuePasteBefore`, plus `d`/`c`/`C` writing the
register — same semantics as the TUI. *Verifies:* G17. register — same semantics as the TUI. *Verifies:* G17.
- [ ] **C5 — Keymap + `HELP` table** rows for all of the above, keeping the - [x] **C5 — Keymap + `HELP` table** rows for all of the above, keeping the
lockstep test green; clickable equivalents where the web client has them. lockstep test green; clickable equivalents where the web client has them.
*Verifies:* G19. *Verifies:* G19.
- [ ] **C6 — Verify the web half properly:** clippy for native **and** - [x] **C6 — Verify the web half properly:** clippy for native **and**
`wasm32-unknown-unknown`, `cargo test -p cbd-web`, and a trunk bundle `wasm32-unknown-unknown`, `cargo test -p cbd-web`, and a trunk bundle
build. *Verifies:* G26. build. *Verifies:* G26.
- [ ] **C7 — Docs.** `docs/src/clients/tui.md` (key table + a register - [x] **C7 — Docs.** `docs/src/clients/tui.md` (key table + a register
section), `docs/src/clients/web.md`, `docs/src/queue.md` (what the register section), `docs/src/clients/web.md`, `docs/src/queue.md` (what the register
is and is not), and the README usage section. State plainly that `p` is and is not), and the README usage section. State plainly that `p`
changed meaning. *Verifies:* G21, G22, G23. changed meaning. *Verifies:* G21, G22, G23.
- [ ] **C8 — `plan/summary.md`** entry, then commit C. - [x] **C8 — `plan/summary.md`** entry, then commit C.
## Deferred (recorded, not dropped) ## Deferred (recorded, not dropped)

View File

@ -1279,3 +1279,66 @@ provider-less and the `fs,opus` binaries under isolated XDG dirs: startup
logs the feature list, writes a default config listing only built-ins, and logs the feature list, writes a default config listing only built-ins, and
warns once per unavailable name. Not exercised: `nix build` (not run here) warns once per unavailable name. Not exercised: `nix build` (not run here)
and playback of an Opus file in an `opus`-less build (no audio device). and playback of an Opus file in an `opus`-less build (no audio device).
## queue register (2026-07-26)
Marks and visual mode for the queue pane, paired with a vim-style register:
`y` yanks, `d`/`c`/`C` fill it as they remove, `p`/`P` paste it back after
or before the cursor. Both clients, in one change. Ran the full dev-flow:
`architecture/queue-register.md` (D1D10), `quality/queue-register.md`
(G1G26), `plan/queue-register.md`.
**No server work at all** — that was the feasibility finding that made this
cheap: `Remove` already accepted many positions and `Insert` already took a
path list, so the register reduces to a `Vec<String>` and the whole feature
is two clients.
**Design decisions taken in discussion with the user** (all four of my
recommendations were accepted):
- **Only `y`/`d`/`c`/`C` write the register.** The user's first proposal had
a library selection fill it implicitly, which keeps `p` backward
compatible — I argued against it because `s` in the library would then
silently clobber a clipboard you were about to paste, and the one thing
that makes vim registers safe is that only explicit yank/delete write
them. The cost lands on one flow only (browse→insert-here is now `y` then
`p`); `a`/`L`/`Enter` are untouched.
- **`p` after the cursor, `P` before**, because after a delete the cursor
sits on the successor, so paste-after lands one slot late — `P` is the
exact restore.
- **`c`/`C` fill the register**, the destructive ops most worth undoing.
- **One unnamed slot**, with `Register` shaped so named registers stay
additive.
**The one hard problem** was that queue marks are positional while the queue
is server-pushed and rebuilt on every change (append, playback advancing,
each streaming-resolve chunk). Naive index remapping silently retargets a
mark, so `d` would delete the wrong tracks. `carry_marks()` carries marks
across a snapshot by a greedy in-order match on track path; irreconcilable
snapshots clear rather than guess, and positions handed to `Remove` are
always read off the newest list. Ten test cases pin it in the TUI and the
same cases again in the web client, so the two cannot drift.
**Structure:** the library's mark/visual code moved into a `MarkedPane`
trait in `cbd-tui/src/app/list.rs` (beside the existing `StatefulList`),
which both panes implement — the library keeps `is_queable` as its mark
gate, the queue allows every row. The web client has no owned queue list
(just a cursor), so its marks live on `QueueCursor` beside the server
signal; same rule, different home, as the architecture doc says.
**Deviations from the plan:** commits A and B were merged — group A leaves
`carry_marks`/`Register` unwired, and committing dead code so the next
commit can use it is worse than one larger commit. Also removed
`Library::queue_insert` (orphaned once `p` stopped pulling from the library)
and rebalanced the help modal's columns: it was already overflowing at 46
rows in a single column and the seven new bindings made it worse. It still
truncates below ~43 rows, now pinned by a test rather than hidden —
scrolling remains the real fix and is still an open question in the
help-modal design.
Verified: cbd-tui 117 tests (was 91), cbd-web 20 (was 12), clippy clean for
cbd-tui and for cbd-web on **both** native and wasm32 (`mod app` only
compiles for wasm, so native clippy alone proves nothing), the trunk bundle
builds, and the book builds. Not exercised: live keypresses in a terminal
and in a browser — the pure state machines are unit-tested and both render
paths compile.