diff --git a/README.md b/README.md index 4f2227d..b74d4b4 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,17 @@ live as you type — `Enter` keeps the filter, `Esc` clears it. `s` marks the selected row; `v` (or `V`) enters **visual mode**, where movement marks or unmarks everything you sweep over, vim-style. The sweep is anchored where you entered it, so moving back reverses it. `Esc` (or -any non-movement key) leaves visual mode. +any non-movement key) leaves visual mode. Both panes have marks and visual +mode. + +In the queue this feeds a vim-style **register**: `y` yanks the selection +into it, `d` deletes the marked rows *into* it, and `c`/`C` fill it with +whatever they clear — so an accidental clear is recoverable. `p` pastes it +after the cursor and `P` before it, which makes `d` then `P` an exact undo +and `d` … `p` a move. The register is per client, in memory, one slot, and +holds paths — so a paste re-resolves (a yanked album expands to its +tracks). Note `p` no longer inserts the library selection: that flow is now +`y` on the left, then `p` on the right. - `w` saves the selection (a library subtree, or in the queue pane the queue) as a new folder under `/crabidy/` of **link** files — diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index b2f6b5e..342f6e0 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -720,7 +720,8 @@ impl App { .constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref()) .split(main[1]); - self.queue.render(f, right_side[0], queue_focused); + self.queue + .render(f, right_side[0], queue_focused, self.register.len()); self.now_playing.render(f, right_side[1]); // The node-creation/rename input: one line inside the bottom of the diff --git a/cbd-tui/src/app/queue.rs b/cbd-tui/src/app/queue.rs index 1730a9c..e310d49 100644 --- a/cbd-tui/src/app/queue.rs +++ b/cbd-tui/src/app/queue.rs @@ -212,7 +212,10 @@ impl Queue { self.update_selection(); } - pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { + /// Draws the pane. `register_len` is the number of entries `p`/`P` would + /// paste; a non-zero count is shown in the title so a paste is never + /// blind. + pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool, register_len: usize) { let selected = self.list_state.selected(); // Render only the visible rows; `view` is the rendered index the // selection bar keys off, `real` the queue position (which drives @@ -280,9 +283,10 @@ impl Queue { // Visual (paint-select) mode: movement toggles marks. "Queue — VISUAL".to_string() } else { - match self.filter.query() { - Some(query) => format!("Queue — /{query}▏"), - None => "Queue".to_string(), + match (self.filter.query(), register_len) { + (Some(query), _) => format!("Queue — /{query}▏"), + (None, 0) => "Queue".to_string(), + (None, n) => format!("Queue — register: {n}"), } }), ) @@ -369,7 +373,7 @@ mod tests { let backend = TestBackend::new(40, 8); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal - .draw(|f| queue.render(f, f.area(), true)) + .draw(|f| queue.render(f, f.area(), true, 0)) .expect("draw"); let buffer = terminal.backend().buffer().clone(); (0..buffer.area.height) @@ -455,7 +459,7 @@ mod tests { let backend = TestBackend::new(40, 8); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal - .draw(|f| queue.render(f, f.area(), true)) + .draw(|f| queue.render(f, f.area(), true, 0)) .expect("draw"); let buffer = terminal.backend().buffer().clone(); for y in 0..buffer.area.height { diff --git a/cbd-web/src/app.rs b/cbd-web/src/app.rs index 0bad675..034eded 100644 --- a/cbd-web/src/app.rs +++ b/cbd-web/src/app.rs @@ -16,7 +16,7 @@ use crate::keymap::{self, Action}; use crate::rpc::Rpc; use crate::state::{ format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane, - NamePurpose, QueueCursor, UiItemKind, + NamePurpose, QueueCursor, Register, UiItemKind, }; const VOLUME_STEP: f32 = 0.1; @@ -119,6 +119,9 @@ struct Store { queue_cursor: RwSignal, focus: RwSignal, dialog: RwSignal>, + /// What `y`/`d`/`c`/`C` set aside and `p`/`P` paste back + /// (architecture/queue-register.md). Per client, one slot, in memory. + register: RwSignal, toast: RwSignal>, /// The transport; local because the wasm client is not `Send`. rpc: StoredValue, LocalStorage>, @@ -150,6 +153,7 @@ impl Store { queue_cursor: RwSignal::new(QueueCursor::default()), focus: RwSignal::new(Focus::Library), dialog: RwSignal::new(None), + register: RwSignal::new(Register::default()), toast: RwSignal::new(None), rpc: StoredValue::new_local(None), cache: StoredValue::new_local(HashMap::new()), @@ -191,9 +195,11 @@ impl Store { StreamUpdate::Queue(queue) => { self.queue_pos.set(queue.current_position); self.resolving.set(queue.resolving); + // Marks follow their own track across the snapshot rather + // than their index (architecture/queue-register.md D5). + let paths: Vec = queue.tracks.iter().map(|t| t.path.clone()).collect(); self.queue.set(queue.tracks); - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.clamp(len)); + self.queue_cursor.update(|c| c.reconcile(paths)); } StreamUpdate::Mods(mods) => self.mods.set(mods), StreamUpdate::QueueTrack(queue_track) => { @@ -286,7 +292,98 @@ impl Store { } /// Executes one keymap action — the web twin of the TUI dispatch. + /// Move the library cursor, painting the swept range when visual mode is + /// active — the web mirror of the TUI's `library_move`. + fn library_move(&self, mv: impl FnOnce(&mut LibraryPane)) { + self.library.update(|pane| { + let old = pane.selected; + mv(pane); + pane.paint_between(old, pane.selected); + }); + } + + /// Move the queue cursor, painting the swept range when visual mode is + /// active — the web mirror of the TUI's `queue_move`. + fn queue_move(&self, mv: impl FnOnce(&mut QueueCursor, usize)) { + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor.update(|cursor| { + let old = cursor.selected; + mv(cursor, len); + let new = cursor.selected; + cursor.paint_between(old, new); + }); + } + + /// Put the tracks at `positions` in the register. Returns whether + /// anything was stored, so callers can decide about clearing marks. + fn yank_positions(&self, positions: &[usize]) -> bool { + let (paths, labels) = self.queue.with_untracked(|queue| { + let mut paths = Vec::new(); + let mut labels = Vec::new(); + for pos in positions { + if let Some(track) = queue.get(*pos) { + paths.push(track.path.clone()); + labels.push(track_label(track)); + } + } + (paths, labels) + }); + if paths.is_empty() { + return false; + } + self.register.update(|r| r.set(paths, labels)); + true + } + + /// `p`/`P`: insert the register at the cursor. An empty register is a + /// no-op, and the register survives so it can be pasted again. + fn paste(&self, before: bool) { + let paths = self.register.with_untracked(|r| r.paths().to_vec()); + if paths.is_empty() { + return; + } + let position = self + .queue_cursor + .with_untracked(|c| c.paste_position(before)); + self.queue_op(async move |mut rpc: Rpc| rpc.insert_tracks(position, paths).await); + } + + /// Whether `action` keeps a pane in visual mode. Everything else leaves + /// it before running, exactly as in the TUI. + fn keeps_visual(action: Action) -> bool { + matches!( + action, + Action::LibraryFirst + | Action::LibraryLast + | Action::LibraryNext + | Action::LibraryPrev + | Action::LibraryJumpDown + | Action::LibraryJumpUp + | Action::LibraryVisualMode + | Action::QueueFirst + | Action::QueueLast + | Action::QueueNext + | Action::QueuePrev + | Action::QueueJumpDown + | Action::QueueJumpUp + | Action::QueueSelectCurrent + | Action::QueueVisualMode + ) + } + fn dispatch(&self, action: Action) { + if !Self::keeps_visual(action) { + if self.library.with_untracked(LibraryPane::is_visual) { + self.library.update(LibraryPane::exit_visual); + } + if self.queue_cursor.with_untracked(QueueCursor::is_visual) { + self.queue_cursor.update(QueueCursor::exit_visual); + } + } + self.dispatch_inner(action) + } + + fn dispatch_inner(&self, action: Action) { match action { Action::OpenHelp => self.dialog.set(Some(Dialog::Help)), Action::CloseHelp => self.dialog.set(None), @@ -309,12 +406,12 @@ impl Store { Action::ToggleMute => self.call(async |mut rpc: Rpc| rpc.toggle_mute().await), Action::ToggleShuffle => self.call(async |mut rpc: Rpc| rpc.toggle_shuffle().await), Action::ToggleRepeat => self.call(async |mut rpc: Rpc| rpc.toggle_repeat().await), - Action::LibraryNext => self.library.update(|p| p.select_by(1)), - Action::LibraryPrev => self.library.update(|p| p.select_by(-1)), - Action::LibraryFirst => self.library.update(LibraryPane::select_first), - Action::LibraryLast => self.library.update(LibraryPane::select_last), - Action::LibraryJumpDown => self.library.update(|p| p.select_by(JUMP)), - Action::LibraryJumpUp => self.library.update(|p| p.select_by(-JUMP)), + Action::LibraryNext => self.library_move(|p| p.select_by(1)), + Action::LibraryPrev => self.library_move(|p| p.select_by(-1)), + Action::LibraryFirst => self.library_move(LibraryPane::select_first), + Action::LibraryLast => self.library_move(LibraryPane::select_last), + Action::LibraryJumpDown => self.library_move(|p| p.select_by(JUMP)), + Action::LibraryJumpUp => self.library_move(|p| p.select_by(-JUMP)), Action::LibraryAscend => { if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) { self.open_library_node(parent); @@ -355,17 +452,6 @@ impl Store { self.queue_op(async |mut rpc: Rpc| rpc.queue_tracks(paths).await); } } - Action::QueueInsertHere => { - let position = self.queue_cursor.with_untracked(|c| c.selected as u32); - if let Some(paths) = self - .library - .with_untracked(LibraryPane::queueable_selection) - { - self.queue_op(async move |mut rpc: Rpc| { - rpc.insert_tracks(position, paths).await - }); - } - } Action::LibraryCreateNode => { let creatable = self .library @@ -425,33 +511,17 @@ impl Store { })); } } - Action::QueueNext => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.select_by(1, len)); - } - Action::QueuePrev => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.select_by(-1, len)); - } - Action::QueueFirst => self.queue_cursor.update(|c| c.selected = 0), + Action::QueueNext => self.queue_move(|c, len| c.select_by(1, len)), + Action::QueuePrev => self.queue_move(|c, len| c.select_by(-1, len)), + Action::QueueFirst => self.queue_move(|c, _| c.selected = 0), Action::QueueLast => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor - .update(|c| c.selected = len.saturating_sub(1)); - } - Action::QueueJumpDown => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.select_by(JUMP, len)); - } - Action::QueueJumpUp => { - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor.update(|c| c.select_by(-JUMP, len)); + self.queue_move(|c, len| c.selected = len.saturating_sub(1)); } + Action::QueueJumpDown => self.queue_move(|c, len| c.select_by(JUMP, len)), + Action::QueueJumpUp => self.queue_move(|c, len| c.select_by(-JUMP, len)), Action::QueueSelectCurrent => { let current = self.queue_pos.get_untracked() as usize; - let len = self.queue.with_untracked(Vec::len); - self.queue_cursor - .update(|c| c.selected = current.min(len.saturating_sub(1))); + self.queue_move(|c, len| c.selected = current.min(len.saturating_sub(1))); } Action::QueuePlaySelected => { let position = self.queue_cursor.with_untracked(|c| c.selected as u32); @@ -460,15 +530,52 @@ impl Store { } } Action::QueueRemoveTrack => { - let position = self.queue_cursor.with_untracked(|c| c.selected as u32); - if self.queue.with_untracked(|q| !q.is_empty()) { - self.call(async move |mut rpc: Rpc| rpc.remove_tracks(vec![position]).await); + let positions = self + .queue_cursor + .with_untracked(QueueCursor::action_positions); + if positions.is_empty() { + return; + } + // Hand them to the register first, so `p`/`P` brings them back. + self.yank_positions(&positions); + self.queue_cursor.update(QueueCursor::remove_marks); + let wire: Vec = positions.iter().map(|p| *p as u32).collect(); + self.call(async move |mut rpc: Rpc| rpc.remove_tracks(wire).await); + } + Action::QueueToggleMark => self.queue_cursor.update(QueueCursor::toggle_mark), + Action::QueueVisualMode => self.queue_cursor.update(QueueCursor::toggle_visual), + Action::QueueYank => { + let positions = self + .queue_cursor + .with_untracked(QueueCursor::action_positions); + if self.yank_positions(&positions) { + self.queue_cursor.update(QueueCursor::remove_marks); + } + } + Action::QueuePaste => self.paste(false), + Action::QueuePasteBefore => self.paste(true), + Action::LibraryVisualMode => self.library.update(LibraryPane::toggle_visual), + Action::LibraryYank => { + if let Some((paths, labels)) = + self.library.with_untracked(LibraryPane::yank_selection) + { + self.register.update(|r| r.set(paths, labels)); + self.library.update(LibraryPane::remove_marks); } } Action::QueueClearKeepCurrent => { + let current = self.queue_pos.get_untracked() as usize; + let keep: Vec = (0..self.queue.with_untracked(Vec::len)) + .filter(|pos| *pos != current) + .collect(); + self.yank_positions(&keep); self.call(async |mut rpc: Rpc| rpc.clear_queue(true).await) } - Action::QueueClearAll => self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await), + Action::QueueClearAll => { + let all: Vec = (0..self.queue.with_untracked(Vec::len)).collect(); + self.yank_positions(&all); + self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await) + } Action::QueueSaveAs => { if self.queue.with_untracked(|q| !q.is_empty()) { self.dialog.set(Some(Dialog::Name { @@ -935,14 +1042,19 @@ fn QueueView(store: Store) -> impl IntoView { "queue" {move || store.resolving.get().then_some(" (loading…)")} + {move || { + // A paste is blind unless the register is visible. + let reg = store.register.get(); + (!reg.is_empty()).then(|| format!(" — register: {}", reg.len())) + }}