266 lines
11 KiB
Markdown
266 lines
11 KiB
Markdown
# Queue selection, visual mode, and the register
|
|
|
|
Marks and visual mode in the queue pane, paired with a vim-style
|
|
**register**: deleting or yanking queue entries puts them in the register,
|
|
pasting brings them back. One model, implemented in both clients.
|
|
|
|
## Context and problem statement
|
|
|
|
The library pane has marks (`s`) and visual mode (`v`/`V`); the queue pane
|
|
has neither — `d` removes exactly the row under the cursor, one at a time,
|
|
and `queue.rs` carries the standing note *"FIXME: mark multiple tracks on
|
|
queue and remove them"*. Clearing 200 tracks with `c`/`C`, or deleting the
|
|
wrong row, is unrecoverable: there is no undo anywhere in the system.
|
|
|
|
Meanwhile `p` in the queue means "insert the **library** selection after
|
|
this track" — a cross-pane action with no name for what it is inserting.
|
|
|
|
The ask is to close both gaps with one concept borrowed from vim: a client
|
|
register that deletes and yanks write to, and paste reads from. That turns
|
|
multi-delete into a safe operation (it is recoverable), gives "move these
|
|
three tracks down" a natural spelling (`d` then `P`), and gives `p` a single
|
|
meaning.
|
|
|
|
## Assumptions (confirmed)
|
|
|
|
- **Only explicit commands write the register**: `y` (both panes), and `d`,
|
|
`c`, `C` (queue). Marking or moving the cursor never writes it. This was
|
|
the one point of disagreement — an implicit fill from the library
|
|
selection would have kept `p` backward-compatible, but it means `s` in
|
|
the library silently clobbers a clipboard you are about to paste.
|
|
- **Both `p` (after cursor) and `P` (before cursor)**, so `d` then `P`
|
|
restores exactly what you deleted, and `d` … `p` is a move.
|
|
- **`c`/`C` fill the register too** — the destructive ops most worth
|
|
undoing.
|
|
- **One unnamed register**, but the type is shaped so named registers
|
|
(`"a`) are additive rather than a rewrite.
|
|
- **Both clients, now.** `cbd-web` gets queue marks, queue visual mode, and
|
|
the register — *plus* the library visual mode that was deferred when
|
|
`v`/`V` landed in the TUI, so the two clients do not drift further.
|
|
- The register is **per-client, in-memory, one level deep**. It is not a
|
|
server-side undo log; another client cannot undo your delete, and a
|
|
restart forgets it. Same expectations as vim.
|
|
|
|
## What the protocol already gives us
|
|
|
|
No `.proto` change, no server change. The two RPCs this feature needs are
|
|
already shaped for it:
|
|
|
|
- `Remove { positions: repeated uint32 }` — already takes **many**
|
|
positions, so multi-delete is a client-side gather.
|
|
- `Insert { position, paths: repeated string }` — takes a **path list**, so
|
|
a register of paths pastes with the existing call.
|
|
|
|
That is what makes this a client-only feature.
|
|
|
|
## Options considered
|
|
|
|
### What the register holds
|
|
|
|
1. **Resolved tracks** (`Track` messages, as deleted). Exact: what you
|
|
deleted is what comes back, and the UI can show real titles. But it
|
|
cannot express "the album node I yanked in the library", and pasting
|
|
still has to send paths, so the extra fidelity buys nothing at the wire.
|
|
2. **Paths, with titles alongside for display** (chosen). It is what both
|
|
RPCs speak; a yanked library *node* expands at paste time, which is a
|
|
feature (`y` an album, `p` it into the queue); and the register is one
|
|
`Vec<String>` plus labels.
|
|
|
|
The cost is honest and worth stating: paste **re-resolves**. A yanked
|
|
child of a search term whose term has since been deleted may not come
|
|
back, and a node's track count is unknown until paste, so the UI can
|
|
only say "3 entries", not "12 tracks".
|
|
|
|
### Keeping marks alive in a moving queue
|
|
|
|
This is the only genuinely new problem. The library never had it: a listing
|
|
is a snapshot you re-fetch deliberately. The queue is server-pushed and
|
|
rebuilt on **every** change — another client appending, playback advancing,
|
|
and each chunk of a streaming resolve.
|
|
|
|
1. **Remap marks by index.** Cheapest, and silently wrong: if playback
|
|
advanced and the queue shifted by one, `d` deletes the wrong tracks.
|
|
Rejected — a data-losing failure mode.
|
|
2. **Clear marks on every snapshot.** Never wrong, but a resolve streaming
|
|
in or another client's append wipes a selection mid-flow, which makes
|
|
the feature feel broken exactly when the queue is busy.
|
|
3. **Greedy in-order match on track path** (chosen). Walk the old and new
|
|
path lists together and carry a mark to the new position of the same
|
|
path; drop marks whose track is gone. Survives appends, removals, and
|
|
playback advancing. Duplicate paths (the same track queued twice) are
|
|
ambiguous by nature — in-order matching degrades sanely, keeping the
|
|
*n*-th occurrence marked. If the lists diverge past recognition (no
|
|
common prefix worth speaking of), clear rather than guess.
|
|
|
|
The safety rule that makes any of this sound: **positions handed to
|
|
`Remove` are always read off the newest snapshot**, never a remembered
|
|
index.
|
|
|
|
### Where marks live
|
|
|
|
The two clients are shaped differently and this is not worth papering over:
|
|
|
|
- `cbd-tui` owns a `Vec<UiItem>` per pane and already has `marked` on it
|
|
(the queue builds every row `marked: false` today).
|
|
- `cbd-web` owns **no** queue list at all — just `QueueCursor { selected }`,
|
|
with rows rendered straight from the server's `Queue` signal.
|
|
|
|
So the shared thing is the *rule*, not the struct: marks are a set of
|
|
positions plus the path list they were taken against, reconciled on each
|
|
snapshot. The TUI keeps them in its `UiItem`s (it rebuilds that list
|
|
anyway); the web client keeps a position set beside the signal. Both
|
|
implement the same reconciliation, and both unit-test it against the same
|
|
cases.
|
|
|
|
## Decisions
|
|
|
|
**D1 — `Register` holds paths plus display labels.** One unnamed slot:
|
|
|
|
```rust
|
|
struct Register {
|
|
/// Library paths, in the order they were yanked or deleted. Empty means
|
|
/// nothing to paste.
|
|
paths: Vec<String>,
|
|
/// Row labels for the status line only — never sent anywhere.
|
|
labels: Vec<String>,
|
|
}
|
|
```
|
|
|
|
Named registers stay additive: the owner becomes a small map keyed by a
|
|
register name, with `None` meaning the unnamed one. Not built now.
|
|
|
|
**D2 — Only `y`, `d`, `c`, `C` write the register.** Marks, visual mode,
|
|
and cursor movement never do. A write **overwrites**: there is no history
|
|
and no numbered registers.
|
|
|
|
**D3 — `p` pastes after the cursor, `P` before it.** Both are queue-only
|
|
(there is nothing to paste into a library listing). Paste sends
|
|
`Insert { position, paths }` and leaves the register intact, so you can
|
|
paste twice. `d` then `P` is an exact restore; `d` … `p` is a move.
|
|
|
|
**D4 — `y` works in both panes.** In the library it yanks the marked items,
|
|
or the cursor item, under the same `is_queable` gate that `a`/`Enter` use —
|
|
so yanking cannot put an unqueueable folder in the register. In the queue it
|
|
yanks the marked rows, or the cursor row. `y` clears the marks it consumed,
|
|
exactly as queueing does today.
|
|
|
|
**D5 — Queue marks reconcile per snapshot** by the greedy in-order path
|
|
match above, clearing on divergence. `Remove`/`Insert` positions always come
|
|
from the newest snapshot.
|
|
|
|
**D6 — `d` in the queue deletes every marked row** (or the cursor row when
|
|
nothing is marked), in one `Remove` call, and writes them to the register
|
|
first. `c`/`C` write the tracks they are about to drop.
|
|
|
|
**D7 — Queue visual mode mirrors the library's** anchored paint: `v` (and
|
|
`V`) enters, movement toggles the marks of the swept range against the
|
|
anchor so moving back reverses, `Esc` or any non-movement action leaves it.
|
|
The mark+visual logic is extracted so both panes share one implementation
|
|
per client, rather than a second copy in the queue.
|
|
|
|
One concrete wrinkle: queue rows are built `is_queable: false,
|
|
is_deletable: false`, so the library's mark gate would refuse all of them.
|
|
The extracted code takes the gate as a parameter — the queue's is "always
|
|
allowed".
|
|
|
|
**D8 — `cbd-web` reaches parity in the same change**, including the library
|
|
visual mode it does not have yet. Its queue marks live beside the server
|
|
signal (D-above); its keymap and help overlay gain the same rows as the
|
|
TUI's.
|
|
|
|
**D9 — Keys.** The queue gains `s`, `v`, `V`, `y`, `P`; the library gains
|
|
`y`. No chord collides with an existing one in either scope.
|
|
|
|
**D10 — `p` changes meaning, and that is a breaking change** to muscle
|
|
memory: it pastes the register instead of the library selection. The
|
|
browse→queue flows that do not go through `p` (`a` append, `L` queue-next,
|
|
`Enter` replace) are untouched, so the cost is confined to "insert what I
|
|
picked on the left at this exact position", which becomes `y` then `p`.
|
|
Documented in the key tables and in the book.
|
|
|
|
## Structure
|
|
|
|
```d2
|
|
direction: right
|
|
|
|
lib: Library pane {
|
|
libsel: "marked rows,\nelse cursor row\n(is_queable gate)"
|
|
}
|
|
|
|
q: Queue pane {
|
|
qsel: "marked rows,\nelse cursor row"
|
|
}
|
|
|
|
reg: "Register (per client, one slot)\npaths + labels" {
|
|
shape: cylinder
|
|
}
|
|
|
|
rpc: Server (unchanged) {
|
|
ins: "Insert { position, paths }"
|
|
rem: "Remove { positions }"
|
|
}
|
|
|
|
lib.libsel -> reg: "y"
|
|
q.qsel -> reg: "y · d · c · C"
|
|
q.qsel -> rpc.rem: "d · c · C\n(positions from the\nnewest snapshot)"
|
|
reg -> rpc.ins: "p (after cursor)\nP (before cursor)"
|
|
rpc -> q: "Queue snapshot\n(marks reconciled)"
|
|
```
|
|
|
|
Mark reconciliation, on every queue snapshot:
|
|
|
|
```d2
|
|
direction: down
|
|
|
|
snap: "Queue snapshot arrives"
|
|
cmp: "Walk old paths and new paths\nin order"
|
|
carry: "Carry each mark to the new\nposition of the same path"
|
|
drop: "Drop marks whose track is gone"
|
|
clear: "Clear all marks"
|
|
act: "d / y / c / C read positions\nfrom this snapshot only"
|
|
|
|
snap -> cmp
|
|
cmp -> carry: recognizable
|
|
cmp -> clear: "diverged past\nrecognition"
|
|
carry -> drop
|
|
drop -> act
|
|
clear -> act
|
|
```
|
|
|
|
## Boundaries and interfaces
|
|
|
|
- **Register** — owned by each client's app state, written only by the four
|
|
commands, read only by paste. No I/O, no server involvement; a pure value
|
|
that is trivially unit-testable.
|
|
- **Mark reconciliation** — one function per client, `(old_paths,
|
|
new_paths, marks) -> marks`, tested against: append, remove-before,
|
|
remove-marked, playback advance, streaming resolve, duplicate paths, and
|
|
wholesale replacement.
|
|
- **Pane selection** — both panes expose "the rows this action applies to"
|
|
the way the library's `get_selected` already does; `y`/`d` consume it.
|
|
- **Server** — untouched. This whole feature is two clients.
|
|
|
|
## Risks
|
|
|
|
- **Mark drift** deleting the wrong tracks. The reconciliation rule and the
|
|
newest-snapshot rule exist for this; it needs the strongest tests in the
|
|
feature.
|
|
- **`p`'s changed meaning** surprising existing users. Mitigated by leaving
|
|
`a`/`L`/`Enter` alone and documenting the change; not avoidable if `p` is
|
|
to have one meaning.
|
|
- **Silent paste shortfall** when a path no longer resolves — you paste 5
|
|
and get 4. The server already skips unresolvable paths in a resolve; the
|
|
clients should say what they pasted rather than claim success blindly.
|
|
- **Two implementations drifting** (the very thing D8 is fixing for visual
|
|
mode). Same rule, same test cases, both landed together.
|
|
- **Duplicate-path ambiguity** in reconciliation is inherent, not solvable
|
|
without a per-entry queue id in the proto. In-order matching is the
|
|
honest approximation; a queue id is the escape hatch if it ever bites.
|
|
|
|
## Open questions
|
|
|
|
None blocking. Deferred by choice: named registers (D1 leaves room), a
|
|
numbered/history register stack, `y` in the queue putting entries somewhere
|
|
persistent (that is what `w` and saved queues are for), and a per-entry
|
|
queue id in the proto to make reconciliation exact.
|