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:
parent
ad19b6352e
commit
95700bf31f
12
README.md
12
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
|
`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 —
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 |_| {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, G7–G9, G13–G15.
|
G4, G5, G7–G9, G13–G15.
|
||||||
- [ ] **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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` (D1–D10), `quality/queue-register.md`
|
||||||
|
(G1–G26), `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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue