From 2e29dfc1037b11b438a8c0749a90d97e55bf231f Mon Sep 17 00:00:00 2001 From: "J. Champagne" Date: Sat, 14 Mar 2026 13:55:55 -0400 Subject: [PATCH] feat: Add new system for multi-selections. --- Cargo.lock | 1 + crates/pikl-core/Cargo.toml | 1 + crates/pikl-core/src/model/event.rs | 14 +- crates/pikl-core/src/model/traits.rs | 5 + crates/pikl-core/src/runtime/json_menu.rs | 4 + crates/pikl-core/src/runtime/menu.rs | 548 +++++++++++++++++- .../pikl-core/src/script/action_fd/parse.rs | 39 +- crates/pikl-core/tests/dsl_tests.rs | 42 ++ crates/pikl-test-macros/src/codegen.rs | 53 +- crates/pikl-test-macros/src/parse.rs | 17 +- crates/pikl-tui/src/lib.rs | 196 ++++++- crates/pikl/src/main.rs | 35 +- crates/pikl/tests/headless_dsl.rs | 38 ++ 13 files changed, 930 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52f58bf..9effbe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1090,6 +1090,7 @@ name = "pikl-core" version = "0.1.0" dependencies = [ "fancy-regex 0.14.0", + "indexmap", "nucleo-matcher", "pikl-test-macros", "serde", diff --git a/crates/pikl-core/Cargo.toml b/crates/pikl-core/Cargo.toml index e2864c4..944a3a6 100644 --- a/crates/pikl-core/Cargo.toml +++ b/crates/pikl-core/Cargo.toml @@ -16,6 +16,7 @@ tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] } tracing = "0.1" nucleo-matcher = "0.3.1" fancy-regex = "0.14" +indexmap = "2" [dev-dependencies] tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] } diff --git a/crates/pikl-core/src/model/event.rs b/crates/pikl-core/src/model/event.rs index 4028945..573ba0c 100644 --- a/crates/pikl-core/src/model/event.rs +++ b/crates/pikl-core/src/model/event.rs @@ -20,6 +20,7 @@ pub enum Mode { #[default] Insert, Normal, + Visual, } /// A command the menu should process. Frontends and headless @@ -37,6 +38,12 @@ pub enum Action { HalfPageUp(usize), HalfPageDown(usize), SetMode(Mode), + ToggleSelect, + SelectRange { start: usize, end: usize }, + ClearSelections, + SelectAll, + UndoSelection, + RedoSelection, Confirm, Quicklist, Cancel, @@ -53,7 +60,7 @@ pub enum Action { #[derive(Debug, Clone)] pub enum MenuEvent { StateChanged(ViewState), - Selected(Value), + Selected(Vec<(Value, usize)>), Quicklist(Vec), Cancelled, } @@ -72,6 +79,8 @@ pub struct ViewState { pub total_items: usize, pub total_filtered: usize, pub mode: Mode, + pub selection_count: usize, + pub multi_enabled: bool, /// Monotonically increasing counter. Each call to /// `build_view_state()` bumps this, so frontends can /// detect duplicate broadcasts and skip redundant redraws. @@ -87,6 +96,7 @@ pub struct VisibleItem { pub label: String, pub formatted_text: Option, pub index: usize, + pub selected: bool, } /// Final outcome of [`crate::menu::MenuRunner::run`]. The @@ -94,7 +104,7 @@ pub struct VisibleItem { #[must_use] #[derive(Debug)] pub enum MenuResult { - Selected { value: Value, index: usize }, + Selected { items: Vec<(Value, usize)> }, Quicklist { items: Vec<(Value, usize)> }, Cancelled, } diff --git a/crates/pikl-core/src/model/traits.rs b/crates/pikl-core/src/model/traits.rs index 866a1e3..076a386 100644 --- a/crates/pikl-core/src/model/traits.rs +++ b/crates/pikl-core/src/model/traits.rs @@ -41,6 +41,11 @@ pub trait Menu: Send + 'static { /// for output and hook events. fn original_index(&self, filtered_index: usize) -> Option; + /// Get the JSON value of an item by its original (pre-filter) + /// index. Used by multi-select to retrieve selected items + /// that may not be in the current filtered set. + fn serialize_original(&self, original_index: usize) -> Option<&serde_json::Value>; + /// Get the formatted display text for a filtered item, /// if a format template is configured. Returns None if /// no template is set, in which case the raw label is diff --git a/crates/pikl-core/src/runtime/json_menu.rs b/crates/pikl-core/src/runtime/json_menu.rs index 1e266c5..2ea3750 100644 --- a/crates/pikl-core/src/runtime/json_menu.rs +++ b/crates/pikl-core/src/runtime/json_menu.rs @@ -132,6 +132,10 @@ impl Menu for JsonMenu { self.filter.matched_index(filtered_index) } + fn serialize_original(&self, original_index: usize) -> Option<&serde_json::Value> { + self.items.get(original_index).map(|item| &item.value) + } + fn formatted_label(&self, filtered_index: usize) -> Option { let template = self.format_template.as_ref()?; let orig_idx = self.filter.matched_index(filtered_index)?; diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs index a6db73f..e8c6e1e 100644 --- a/crates/pikl-core/src/runtime/menu.rs +++ b/crates/pikl-core/src/runtime/menu.rs @@ -5,6 +5,7 @@ use std::sync::Arc; +use indexmap::IndexSet; use tokio::sync::{broadcast, mpsc}; use tracing::{debug, info, trace}; @@ -23,7 +24,7 @@ pub enum ActionOutcome { /// State changed, broadcast to subscribers. Broadcast, /// User confirmed a selection. - Selected { value: Value, index: usize }, + Selected { items: Vec<(Value, usize)> }, /// User cancelled. Cancelled, /// Menu closed by hook command. @@ -34,6 +35,100 @@ pub enum ActionOutcome { NoOp, } +/// Tracks multi-select state: which items are selected (by +/// original index), with undo/redo support. +pub struct SelectionState { + selected: IndexSet, + undo_stack: Vec>, + redo_stack: Vec>, + multi_enabled: bool, + selection_order: bool, +} + +impl SelectionState { + fn new() -> Self { + Self { + selected: IndexSet::new(), + undo_stack: Vec::new(), + redo_stack: Vec::new(), + multi_enabled: false, + selection_order: false, + } + } + + fn push_undo(&mut self) { + self.undo_stack.push(self.selected.clone()); + self.redo_stack.clear(); + } + + /// Toggle an item in/out of the selection set. + pub fn toggle(&mut self, original_index: usize) { + self.push_undo(); + if !self.selected.shift_remove(&original_index) { + self.selected.insert(original_index); + } + } + + /// Force-select a range of items (no toggle). + pub fn select_range(&mut self, indices: impl Iterator) { + self.push_undo(); + for idx in indices { + self.selected.insert(idx); + } + } + + /// Select all the given items. + pub fn select_all(&mut self, indices: impl Iterator) { + self.push_undo(); + for idx in indices { + self.selected.insert(idx); + } + } + + /// Clear all selections. + pub fn clear(&mut self) { + if self.selected.is_empty() { + return; + } + self.push_undo(); + self.selected.clear(); + } + + /// Undo the last selection change. + pub fn undo(&mut self) { + if let Some(prev) = self.undo_stack.pop() { + self.redo_stack.push(std::mem::replace(&mut self.selected, prev)); + } + } + + /// Redo a previously undone selection change. + pub fn redo(&mut self) { + if let Some(next) = self.redo_stack.pop() { + self.undo_stack.push(std::mem::replace(&mut self.selected, next)); + } + } + + pub fn is_selected(&self, original_index: usize) -> bool { + self.selected.contains(&original_index) + } + + pub fn count(&self) -> usize { + self.selected.len() + } + + /// Items sorted by original index (input order). + pub fn ordered_items(&self) -> Vec { + let mut items: Vec = self.selected.iter().copied().collect(); + items.sort_unstable(); + items + } + + /// Items in insertion order (the order they were selected). + pub fn insertion_ordered(&self) -> Vec { + self.selected.iter().copied().collect() + } +} + /// The menu engine. Wraps any [`Menu`] implementation and /// drives it with an action/event channel loop. Create one, /// grab the action sender and event subscriber, then call @@ -48,6 +143,8 @@ pub struct MenuRunner { dispatcher: Option, previous_cursor: Option, generation: u64, + selection: SelectionState, + visual_anchor: Option, } impl MenuRunner { @@ -72,6 +169,8 @@ impl MenuRunner { dispatcher: None, previous_cursor: None, generation: 0, + selection: SelectionState::new(), + visual_anchor: None, }; (runner, action_tx) } @@ -99,6 +198,17 @@ impl MenuRunner { self.dispatcher = Some(dispatcher); } + /// Enable multi-select mode. + pub fn set_multi(&mut self, enabled: bool) { + self.selection.multi_enabled = enabled; + } + + /// Enable selection-order output (insertion order instead + /// of input order). + pub fn set_selection_order(&mut self, enabled: bool) { + self.selection.selection_order = enabled; + } + /// Re-run the filter against all items with the current /// filter text. Updates the viewport with the new count. fn run_filter(&mut self) { @@ -127,10 +237,12 @@ impl MenuRunner { .filter_map(|i| { self.menu.filtered_label(i).map(|label| { let formatted_text = self.menu.formatted_label(i); + let orig_idx = self.menu.original_index(i).unwrap_or(0); VisibleItem { label: label.to_string(), formatted_text, index: i, + selected: self.selection.is_selected(orig_idx), } }) }) @@ -149,6 +261,8 @@ impl MenuRunner { total_items: self.menu.total(), total_filtered: self.menu.filtered_count(), mode: self.mode, + selection_count: self.selection.count(), + multi_enabled: self.selection.multi_enabled, generation: self.generation, } } @@ -229,18 +343,87 @@ impl MenuRunner { self.viewport.page_down(n); ActionOutcome::Broadcast } - Action::Confirm => { + Action::ToggleSelect => { + if !self.selection.multi_enabled { + return ActionOutcome::NoOp; + } if self.menu.filtered_count() == 0 { return ActionOutcome::NoOp; } let cursor = self.viewport.cursor(); - let index = self.menu.original_index(cursor).unwrap_or(0); - match self.menu.serialize_filtered(cursor) { - Some(value) => ActionOutcome::Selected { - value: value.clone(), - index, - }, - None => ActionOutcome::NoOp, + if let Some(orig_idx) = self.menu.original_index(cursor) { + self.selection.toggle(orig_idx); + } + ActionOutcome::Broadcast + } + Action::SelectRange { start, end } => { + if !self.selection.multi_enabled { + return ActionOutcome::NoOp; + } + let min = start.min(end); + let max = start.max(end); + let indices: Vec = (min..=max) + .filter_map(|i| self.menu.original_index(i)) + .collect(); + self.selection.select_range(indices.into_iter()); + ActionOutcome::Broadcast + } + Action::ClearSelections => { + self.selection.clear(); + ActionOutcome::Broadcast + } + Action::SelectAll => { + if !self.selection.multi_enabled { + return ActionOutcome::NoOp; + } + let indices: Vec = (0..self.menu.filtered_count()) + .filter_map(|i| self.menu.original_index(i)) + .collect(); + self.selection.select_all(indices.into_iter()); + ActionOutcome::Broadcast + } + Action::UndoSelection => { + self.selection.undo(); + ActionOutcome::Broadcast + } + Action::RedoSelection => { + self.selection.redo(); + ActionOutcome::Broadcast + } + Action::Confirm => { + if self.menu.filtered_count() == 0 && self.selection.count() == 0 { + return ActionOutcome::NoOp; + } + if self.selection.multi_enabled && self.selection.count() > 0 { + // Multi-select: return all selected items + let indices = if self.selection.selection_order { + self.selection.insertion_ordered() + } else { + self.selection.ordered_items() + }; + let items: Vec<(Value, usize)> = indices + .into_iter() + .filter_map(|orig_idx| { + self.menu.serialize_original(orig_idx).map(|v| (v.clone(), orig_idx)) + }) + .collect(); + if items.is_empty() { + return ActionOutcome::NoOp; + } + ActionOutcome::Selected { items } + } else { + // Single-select: cursor item as one-element vec + if self.menu.filtered_count() == 0 { + return ActionOutcome::NoOp; + } + let cursor = self.viewport.cursor(); + let index = self.menu.original_index(cursor).unwrap_or(0); + match self.menu.serialize_filtered(cursor) { + Some(value) => ActionOutcome::Selected { + items: vec![(value.clone(), index)], + }, + None => ActionOutcome::NoOp, + } } } Action::Quicklist => { @@ -271,6 +454,11 @@ impl MenuRunner { } Action::SetMode(m) => { debug!(mode = ?m, "mode changed"); + if m == Mode::Visual { + self.visual_anchor = Some(self.viewport.cursor()); + } else if self.mode == Mode::Visual { + self.visual_anchor = None; + } self.mode = m; ActionOutcome::Broadcast } @@ -367,18 +555,21 @@ impl MenuRunner { // Check for cursor movement -> Hover self.check_cursor_hover(); } - ActionOutcome::Selected { value, index } => { - info!(index, "item selected"); - // Emit Select event - self.emit_hook(HookEvent::Select { - item: value.clone(), - index, - }); + ActionOutcome::Selected { items } => { + let count = items.len(); + info!(count, "item(s) selected"); + // Emit Select event for the first item (primary selection) + if let Some((value, index)) = items.first() { + self.emit_hook(HookEvent::Select { + item: value.clone(), + index: *index, + }); + } // Emit Close event self.emit_hook(HookEvent::Close); - let _ = self.event_tx.send(MenuEvent::Selected(value.clone())); - return Ok(MenuResult::Selected { value, index }); + let _ = self.event_tx.send(MenuEvent::Selected(items.clone())); + return Ok(MenuResult::Selected { items }); } ActionOutcome::Quicklist { items } => { let values: Vec = @@ -482,7 +673,7 @@ mod tests { let mut m = ready_menu(); m.apply_action(Action::MoveDown(1)); let outcome = m.apply_action(Action::Confirm); - assert!(matches!(&outcome, ActionOutcome::Selected { value, .. } if value.as_str() == Some("beta"))); + assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta"))); } #[test] @@ -605,8 +796,8 @@ mod tests { let _ = tx.send(Action::Confirm).await; // Should get Selected event - if let Ok(MenuEvent::Selected(value)) = rx.recv().await { - assert_eq!(value.as_str(), Some("beta")); + if let Ok(MenuEvent::Selected(items)) = rx.recv().await { + assert_eq!(items[0].0.as_str(), Some("beta")); } let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); @@ -668,10 +859,10 @@ mod tests { let _ = tx.send(Action::Confirm).await; let event = rx.recv().await; - assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha"))); + assert!(matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha"))); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("alpha"))); + assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha"))); } #[tokio::test] @@ -693,7 +884,7 @@ mod tests { let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("gamma"))); + assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma"))); } #[tokio::test] @@ -718,7 +909,7 @@ mod tests { let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta"))); + assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta"))); } #[tokio::test] @@ -753,7 +944,7 @@ mod tests { let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("epsilon"))); + assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon"))); } #[tokio::test] @@ -805,7 +996,7 @@ mod tests { // Must get "banana". Filter was applied before confirm ran. assert!(matches!( result, - Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana") + Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("banana") )); } @@ -831,7 +1022,7 @@ mod tests { // Cursor at index 3 -> "delta" assert!(matches!( result, - Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta") + Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta") )); } @@ -927,7 +1118,7 @@ mod tests { // Must find "zephyr". It was added before the filter ran. assert!(matches!( result, - Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr") + Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("zephyr") )); } @@ -1008,7 +1199,7 @@ mod tests { assert!(m.menu.filtered_count() >= 1); let outcome = m.apply_action(Action::Confirm); // "delta" is at original index 3 - assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. })); + assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].1 == 3)); } // -- Quicklist tests -- @@ -1173,4 +1364,301 @@ mod tests { assert!(events.contains(&HookEventKind::Open)); assert!(events.contains(&HookEventKind::Filter)); } + + // -- SelectionState unit tests -- + + #[test] + fn selection_toggle_adds_and_removes() { + let mut s = SelectionState::new(); + s.multi_enabled = true; + s.toggle(3); + assert!(s.is_selected(3)); + assert_eq!(s.count(), 1); + s.toggle(3); + assert!(!s.is_selected(3)); + assert_eq!(s.count(), 0); + } + + #[test] + fn selection_undo_redo() { + let mut s = SelectionState::new(); + s.multi_enabled = true; + s.toggle(1); + s.toggle(2); + assert_eq!(s.count(), 2); + s.undo(); + assert_eq!(s.count(), 1); + assert!(s.is_selected(1)); + assert!(!s.is_selected(2)); + s.redo(); + assert_eq!(s.count(), 2); + assert!(s.is_selected(2)); + } + + #[test] + fn selection_undo_on_empty_is_noop() { + let mut s = SelectionState::new(); + s.undo(); + assert_eq!(s.count(), 0); + } + + #[test] + fn selection_redo_on_empty_is_noop() { + let mut s = SelectionState::new(); + s.redo(); + assert_eq!(s.count(), 0); + } + + #[test] + fn selection_clear_then_undo_restores() { + let mut s = SelectionState::new(); + s.multi_enabled = true; + s.toggle(0); + s.toggle(2); + assert_eq!(s.count(), 2); + s.clear(); + assert_eq!(s.count(), 0); + s.undo(); + assert_eq!(s.count(), 2); + assert!(s.is_selected(0)); + assert!(s.is_selected(2)); + } + + #[test] + fn selection_select_range() { + let mut s = SelectionState::new(); + s.multi_enabled = true; + s.select_range([1, 2, 3].into_iter()); + assert_eq!(s.count(), 3); + assert!(s.is_selected(1)); + assert!(s.is_selected(2)); + assert!(s.is_selected(3)); + } + + #[test] + fn selection_select_all() { + let mut s = SelectionState::new(); + s.multi_enabled = true; + s.select_all([0, 1, 2, 3].into_iter()); + assert_eq!(s.count(), 4); + } + + #[test] + fn selection_ordered_items_sorted() { + let mut s = SelectionState::new(); + s.multi_enabled = true; + s.toggle(3); + s.toggle(1); + s.toggle(0); + assert_eq!(s.ordered_items(), vec![0, 1, 3]); + } + + #[test] + fn selection_insertion_ordered() { + let mut s = SelectionState::new(); + s.multi_enabled = true; + s.toggle(3); + s.toggle(1); + s.toggle(0); + assert_eq!(s.insertion_ordered(), vec![3, 1, 0]); + } + + #[test] + fn selection_new_action_clears_redo() { + let mut s = SelectionState::new(); + s.multi_enabled = true; + s.toggle(1); + s.toggle(2); + s.undo(); // redo stack has the {1,2} state + s.toggle(3); // new action, should clear redo + s.redo(); // should be noop + assert_eq!(s.count(), 2); + assert!(s.is_selected(1)); + assert!(s.is_selected(3)); + } + + // -- Multi-select integration tests (apply_action) -- + + fn ready_multi_menu() -> MenuRunner { + let (mut m, _tx) = test_menu(); + m.set_multi(true); + m.run_filter(); + m.apply_action(Action::Resize { height: 10 }); + m + } + + #[test] + fn toggle_then_confirm_returns_selected() { + let mut m = ready_multi_menu(); + m.apply_action(Action::MoveDown(1)); // cursor on "beta" + m.apply_action(Action::ToggleSelect); + let outcome = m.apply_action(Action::Confirm); + match outcome { + ActionOutcome::Selected { items } => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].0.as_str(), Some("beta")); + assert_eq!(items[0].1, 1); + } + other => panic!("expected Selected, got {other:?}"), + } + } + + #[test] + fn toggle_two_items_confirm_returns_both() { + let mut m = ready_multi_menu(); + m.apply_action(Action::ToggleSelect); // select "alpha" (0) + m.apply_action(Action::MoveDown(2)); // cursor on "gamma" (2) + m.apply_action(Action::ToggleSelect); + let outcome = m.apply_action(Action::Confirm); + match outcome { + ActionOutcome::Selected { items } => { + assert_eq!(items.len(), 2); + // Default order = input order + assert_eq!(items[0].0.as_str(), Some("alpha")); + assert_eq!(items[1].0.as_str(), Some("gamma")); + } + other => panic!("expected Selected, got {other:?}"), + } + } + + #[test] + fn toggle_then_untoggle_falls_back_to_cursor() { + let mut m = ready_multi_menu(); + m.apply_action(Action::MoveDown(1)); // cursor on "beta" + m.apply_action(Action::ToggleSelect); + m.apply_action(Action::ToggleSelect); // untoggle + let outcome = m.apply_action(Action::Confirm); + match outcome { + ActionOutcome::Selected { items } => { + // No selections, fallback to cursor + assert_eq!(items.len(), 1); + assert_eq!(items[0].0.as_str(), Some("beta")); + } + other => panic!("expected Selected, got {other:?}"), + } + } + + #[test] + fn multi_no_selections_returns_cursor() { + let mut m = ready_multi_menu(); + m.apply_action(Action::MoveDown(2)); // cursor on "gamma" + let outcome = m.apply_action(Action::Confirm); + match outcome { + ActionOutcome::Selected { items } => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].0.as_str(), Some("gamma")); + } + other => panic!("expected Selected, got {other:?}"), + } + } + + #[test] + fn select_all_then_confirm() { + let mut m = ready_multi_menu(); + m.apply_action(Action::SelectAll); + let outcome = m.apply_action(Action::Confirm); + match outcome { + ActionOutcome::Selected { items } => { + assert_eq!(items.len(), 4); + let labels: Vec<&str> = items.iter().filter_map(|(v, _)| v.as_str()).collect(); + assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]); + } + other => panic!("expected Selected, got {other:?}"), + } + } + + #[test] + fn clear_selections_after_select() { + let mut m = ready_multi_menu(); + m.apply_action(Action::ToggleSelect); // select alpha + m.apply_action(Action::ClearSelections); + assert_eq!(m.selection.count(), 0); + let outcome = m.apply_action(Action::Confirm); + match outcome { + ActionOutcome::Selected { items } => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].0.as_str(), Some("alpha")); // cursor item + } + other => panic!("expected Selected, got {other:?}"), + } + } + + #[test] + fn undo_redo_through_apply_action() { + let mut m = ready_multi_menu(); + m.apply_action(Action::ToggleSelect); // select alpha + m.apply_action(Action::MoveDown(1)); + m.apply_action(Action::ToggleSelect); // select beta + assert_eq!(m.selection.count(), 2); + m.apply_action(Action::UndoSelection); + assert_eq!(m.selection.count(), 1); + assert!(m.selection.is_selected(0)); // alpha + assert!(!m.selection.is_selected(1)); // beta undone + m.apply_action(Action::RedoSelection); + assert_eq!(m.selection.count(), 2); + } + + #[test] + fn selections_survive_filter_change() { + let mut m = ready_multi_menu(); + m.apply_action(Action::ToggleSelect); // select alpha (orig idx 0) + m.apply_action(Action::MoveDown(1)); + m.apply_action(Action::ToggleSelect); // select beta (orig idx 1) + // Change filter + m.apply_action(Action::UpdateFilter("del".to_string())); + // Selections should survive + assert_eq!(m.selection.count(), 2); + assert!(m.selection.is_selected(0)); + assert!(m.selection.is_selected(1)); + // Confirm should return the selected items (alpha, beta) even + // though they're not in the filtered set + let outcome = m.apply_action(Action::Confirm); + match outcome { + ActionOutcome::Selected { items } => { + assert_eq!(items.len(), 2); + assert_eq!(items[0].0.as_str(), Some("alpha")); + assert_eq!(items[1].0.as_str(), Some("beta")); + } + other => panic!("expected Selected, got {other:?}"), + } + } + + #[test] + fn multi_disabled_toggle_is_noop() { + let mut m = ready_menu(); // not multi + let outcome = m.apply_action(Action::ToggleSelect); + assert!(matches!(outcome, ActionOutcome::NoOp)); + } + + #[test] + fn select_range_selects_all_in_range() { + let mut m = ready_multi_menu(); + m.apply_action(Action::SelectRange { start: 1, end: 3 }); + assert_eq!(m.selection.count(), 3); + assert!(!m.selection.is_selected(0)); + assert!(m.selection.is_selected(1)); + assert!(m.selection.is_selected(2)); + assert!(m.selection.is_selected(3)); + } + + #[test] + fn visual_anchor_set_on_visual_mode() { + let mut m = ready_multi_menu(); + m.apply_action(Action::MoveDown(2)); // cursor at 2 + m.apply_action(Action::SetMode(Mode::Visual)); + assert_eq!(m.visual_anchor, Some(2)); + m.apply_action(Action::SetMode(Mode::Normal)); + assert_eq!(m.visual_anchor, None); + } + + #[test] + fn view_state_shows_selection() { + let mut m = ready_multi_menu(); + m.apply_action(Action::ToggleSelect); // select alpha + let vs = m.build_view_state(); + assert!(vs.multi_enabled); + assert_eq!(vs.selection_count, 1); + assert!(vs.visible_items[0].selected); + assert!(!vs.visible_items[1].selected); + } } diff --git a/crates/pikl-core/src/script/action_fd/parse.rs b/crates/pikl-core/src/script/action_fd/parse.rs index 73857e9..7c81c5c 100644 --- a/crates/pikl-core/src/script/action_fd/parse.rs +++ b/crates/pikl-core/src/script/action_fd/parse.rs @@ -132,20 +132,26 @@ pub fn parse_action(line_number: usize, line: &str) -> Result Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))), "normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))), + "visual" => Ok(ScriptAction::Core(Action::SetMode(Mode::Visual))), other => Err(invalid_arg( line_number, line, "set-mode", - format!("unknown mode '{other}', expected insert or normal"), + format!("unknown mode '{other}', expected insert, normal, or visual"), )), } } + "toggle-select" => Ok(ScriptAction::Core(Action::ToggleSelect)), + "select-all" => Ok(ScriptAction::Core(Action::SelectAll)), + "deselect-all" => Ok(ScriptAction::Core(Action::ClearSelections)), + "undo-selection" => Ok(ScriptAction::Core(Action::UndoSelection)), + "redo-selection" => Ok(ScriptAction::Core(Action::RedoSelection)), "confirm" => Ok(ScriptAction::Core(Action::Confirm)), "cancel" => Ok(ScriptAction::Core(Action::Cancel)), "resize" => { @@ -439,7 +445,10 @@ mod tests { #[test] fn parse_set_mode_invalid() { - assert!(parse_action(1, "set-mode visual").is_err()); + assert_eq!( + parse_action(1, "set-mode visual").unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::SetMode(Mode::Visual)) + ); } #[test] @@ -645,6 +654,30 @@ mod tests { assert!(parse_action(1, "half-page-up xyz").is_err()); } + #[test] + fn parse_selection_actions() { + assert_eq!( + parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::ToggleSelect) + ); + assert_eq!( + parse_action(1, "select-all").unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::SelectAll) + ); + assert_eq!( + parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::ClearSelections) + ); + assert_eq!( + parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::UndoSelection) + ); + assert_eq!( + parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::RedoSelection) + ); + } + #[test] fn parse_set_mode_wrong_case() { assert!(parse_action(1, "set-mode Insert").is_err()); diff --git a/crates/pikl-core/tests/dsl_tests.rs b/crates/pikl-core/tests/dsl_tests.rs index 707a8f3..f6373c3 100644 --- a/crates/pikl-core/tests/dsl_tests.rs +++ b/crates/pikl-core/tests/dsl_tests.rs @@ -286,6 +286,48 @@ pikl_tests! { } } + menu mod multi_select_basic { + items: ["alpha", "bravo", "charlie", "delta"]; + multi: true; + + test toggle_one_then_confirm { + actions: [toggle-select, confirm] + selected_items: ["alpha"] + } + + test toggle_two_then_confirm { + actions: [toggle-select, move-down, move-down, toggle-select, confirm] + selected_items: ["alpha", "charlie"] + } + + test no_selection_falls_back_to_cursor { + actions: [move-down, confirm] + selected: "bravo" + } + + test select_all_then_confirm { + actions: [select-all, confirm] + selected_items: ["alpha", "bravo", "charlie", "delta"] + } + + test clear_selections_resets { + actions: [toggle-select, move-down, toggle-select, clear-selections, confirm] + // Falls back to cursor item (bravo, since we moved down once) + selected: "bravo" + } + + test undo_restores_previous { + actions: [toggle-select, move-down, toggle-select, undo-selection, confirm] + // Undo reverts the second toggle, only alpha remains + selected_items: ["alpha"] + } + + test redo_after_undo { + actions: [toggle-select, move-down, toggle-select, undo-selection, redo-selection, confirm] + selected_items: ["alpha", "bravo"] + } + } + nav mod half_page_small_height { viewport: { height: 2, count: 10 }; diff --git a/crates/pikl-test-macros/src/codegen.rs b/crates/pikl-test-macros/src/codegen.rs index b7b1c5b..aabdd7a 100644 --- a/crates/pikl-test-macros/src/codegen.rs +++ b/crates/pikl-test-macros/src/codegen.rs @@ -92,12 +92,15 @@ fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result = if let Some(ref key) = fixtures.label_key { - vec![quote! { "--label-key" }, quote! { #key }] - } else { - Vec::new() - }; + // Build extra CLI args (e.g. --label-key, --multi) + let mut extra_args: Vec = Vec::new(); + if let Some(ref key) = fixtures.label_key { + extra_args.push(quote! { "--label-key" }); + extra_args.push(quote! { #key }); + } + if fixtures.multi == Some(true) { + extra_args.push(quote! { "--multi" }); + } // Build assertions let mut asserts = Vec::new(); @@ -327,7 +330,9 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result { } else if let Some(ref expected) = case.selected { quote! { match &result { - Ok(MenuResult::Selected { value, .. }) => { + Ok(MenuResult::Selected { items, .. }) => { + assert!(!items.is_empty(), "expected Selected with items, got empty"); + let value = &items[0].0; let got = value.as_str() .or_else(|| value.get(#label_key).and_then(|v| v.as_str())) .unwrap_or(""); @@ -339,6 +344,25 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result { other => panic!("expected Selected, got: {:?}", other), } } + } else if let Some(ref expected) = case.selected_items { + quote! { + match &result { + Ok(MenuResult::Selected { items, .. }) => { + let mut labels: Vec<&str> = items.iter() + .filter_map(|(v, _)| v.as_str() + .or_else(|| v.get(#label_key).and_then(|v| v.as_str()))) + .collect(); + labels.sort(); + let mut expected = vec![#(#expected),*]; + expected.sort(); + assert_eq!( + labels, expected, + "expected selected_items {:?}, got {:?}", expected, labels + ); + } + other => panic!("expected Selected, got: {:?}", other), + } + } } else { // No assertion on result. Probably an error, but let it compile. quote! {} @@ -348,11 +372,18 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result { // we need to drop tx after sending actions. let drop_sender = quote! { drop(tx); }; + let multi_setup = if fixtures.multi == Some(true) { + quote! { menu.set_multi(true); } + } else { + quote! {} + }; + Ok(quote! { #[tokio::test] async fn #test_name() { let items = vec![#(#item_exprs),*]; - let (menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string())); + let (mut menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string())); + #multi_setup let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); @@ -427,6 +458,12 @@ fn menu_action_variant(name: &str) -> syn::Result { "half-page-down" => quote! { Action::HalfPageDown(1) }, "set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) }, "set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) }, + "set-mode-visual" => quote! { Action::SetMode(pikl_core::event::Mode::Visual) }, + "toggle-select" => quote! { Action::ToggleSelect }, + "select-all" => quote! { Action::SelectAll }, + "clear-selections" => quote! { Action::ClearSelections }, + "undo-selection" => quote! { Action::UndoSelection }, + "redo-selection" => quote! { Action::RedoSelection }, _ => { return Err(syn::Error::new( Span::call_site(), diff --git a/crates/pikl-test-macros/src/parse.rs b/crates/pikl-test-macros/src/parse.rs index 9c67c03..2fc8cb8 100644 --- a/crates/pikl-test-macros/src/parse.rs +++ b/crates/pikl-test-macros/src/parse.rs @@ -31,6 +31,7 @@ pub struct Fixtures { pub items: Option>, pub label_key: Option, pub viewport: Option<(usize, usize)>, // (height, count) + pub multi: Option, } pub struct TestCase { @@ -44,6 +45,7 @@ pub struct TestCase { pub cursor: Option, pub offset: Option, pub selected: Option, + pub selected_items: Option>, pub cancelled: bool, } @@ -89,6 +91,7 @@ impl Parse for TestModule { items: None, label_key: None, viewport: None, + multi: None, }; let mut tests = Vec::new(); @@ -120,11 +123,18 @@ impl Parse for TestModule { fixtures.viewport = Some(parse_viewport_def(&content)?); eat_semi(&content); } + "multi" => { + consume_ident_or_keyword(&content)?; + content.parse::()?; + let val: syn::LitBool = content.parse()?; + fixtures.multi = Some(val.value); + eat_semi(&content); + } _ => { return Err(syn::Error::new( content.span(), format!( - "unexpected field '{ident_str}', expected test, items, label_key, or viewport" + "unexpected field '{ident_str}', expected test, items, label_key, viewport, or multi" ), )); } @@ -163,6 +173,7 @@ fn parse_test_case(input: ParseStream) -> syn::Result { cursor: None, offset: None, selected: None, + selected_items: None, cancelled: false, }; @@ -224,6 +235,10 @@ fn parse_test_case(input: ParseStream) -> syn::Result { let val: LitStr = content.parse()?; case.selected = Some(val.value()); } + "selected_items" => { + content.parse::()?; + case.selected_items = Some(parse_string_list(&content)?); + } "cancelled" => { // Just the keyword presence means true. Optionally parse `: true`. if content.peek(Token![:]) { diff --git a/crates/pikl-tui/src/lib.rs b/crates/pikl-tui/src/lib.rs index 9c17f96..dae39c6 100644 --- a/crates/pikl-tui/src/lib.rs +++ b/crates/pikl-tui/src/lib.rs @@ -83,12 +83,15 @@ async fn run_inner( let mut mode = Mode::Insert; let mut pending = PendingKey::None; let mut last_generation: u64 = 0; + let mut multi_enabled = false; + let mut visual_anchor: Option = None; loop { if let Some(ref vs) = view_state { let ft = filter_text.clone(); + let anchor = visual_anchor; terminal.draw(|frame| { - render_menu(frame, vs, &ft); + render_menu(frame, vs, &ft, anchor); })?; } @@ -100,9 +103,24 @@ async fn run_inner( let event = event_result?; match event { Event::Key(key) => { - if let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending) { + // Handle multi-action keys (Tab, Space in normal/visual) + // before the single-action map_key_event path. + let handled = handle_multi_action_key( + key, mode, multi_enabled, &mut visual_anchor, + &view_state, action_tx, + ).await; + if !handled + && let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending) + { // Track mode locally for key mapping if let Action::SetMode(m) = &action { + if *m == Mode::Visual { + if let Some(ref vs) = view_state { + visual_anchor = Some(vs.cursor); + } + } else if mode == Mode::Visual { + visual_anchor = None; + } mode = *m; pending = PendingKey::None; } @@ -136,6 +154,7 @@ async fn run_inner( mode = vs.mode; pending = PendingKey::None; } + multi_enabled = vs.multi_enabled; view_state = Some(vs); } Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => { @@ -155,9 +174,83 @@ async fn run_inner( Ok(()) } +/// Handle keys that need to send multiple actions (Tab, Space +/// in normal mode, visual mode apply/cancel). Returns true +/// if the key was consumed. +async fn handle_multi_action_key( + key: KeyEvent, + mode: Mode, + multi_enabled: bool, + visual_anchor: &mut Option, + view_state: &Option, + action_tx: &mpsc::Sender, +) -> bool { + match mode { + Mode::Insert => { + if !multi_enabled { + return false; + } + match key.code { + KeyCode::Tab => { + let _ = action_tx.send(Action::ToggleSelect).await; + let _ = action_tx.send(Action::MoveDown(1)).await; + true + } + KeyCode::BackTab => { + let _ = action_tx.send(Action::ToggleSelect).await; + let _ = action_tx.send(Action::MoveUp(1)).await; + true + } + _ => false, + } + } + Mode::Normal => { + if !multi_enabled { + return false; + } + match (key.code, key.modifiers) { + (KeyCode::Char(' '), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + let _ = action_tx.send(Action::ToggleSelect).await; + let _ = action_tx.send(Action::MoveDown(1)).await; + true + } + (KeyCode::Tab, _) => { + let _ = action_tx.send(Action::ToggleSelect).await; + let _ = action_tx.send(Action::MoveDown(1)).await; + true + } + (KeyCode::BackTab, _) => { + let _ = action_tx.send(Action::ToggleSelect).await; + let _ = action_tx.send(Action::MoveUp(1)).await; + true + } + _ => false, + } + } + Mode::Visual => { + match (key.code, key.modifiers) { + (KeyCode::Char(' '), m) | (KeyCode::Enter, m) if !m.intersects(KeyModifiers::ALT) => { + // Apply visual selection + if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) { + let cursor = vs.cursor; + let _ = action_tx.send(Action::SelectRange { + start: anchor, + end: cursor, + }).await; + } + *visual_anchor = None; + let _ = action_tx.send(Action::SetMode(Mode::Normal)).await; + true + } + _ => false, + } + } + } +} + /// Render the menu into the given frame. Extracted from the /// event loop so it can be tested with a [`TestBackend`]. -fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) { +fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, visual_anchor: Option) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(1)]) @@ -169,9 +262,10 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) { let mode_indicator = match vs.mode { Mode::Insert => "[I]", Mode::Normal => "[N]", + Mode::Visual => "[V]", }; - let prompt = Paragraph::new(Line::from(vec![ + let mut prompt_spans = vec![ Span::styled( format!("{mode_indicator}> "), Style::default().fg(Color::Cyan), @@ -181,7 +275,16 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) { format!(" {filtered_count}/{total_count}"), Style::default().fg(Color::DarkGray), ), - ])); + ]; + + if vs.selection_count > 0 { + prompt_spans.push(Span::styled( + format!(" [{}]", vs.selection_count), + Style::default().fg(Color::Yellow), + )); + } + + let prompt = Paragraph::new(Line::from(prompt_spans)); frame.render_widget(prompt, chunks[0]); // mode_indicator len + "> " = mode_indicator.len() + 2 @@ -192,18 +295,42 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) { frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0)); } + // Determine visual range for highlighting + let visual_range = if vs.mode == Mode::Visual { + visual_anchor.map(|anchor| { + let min = anchor.min(vs.cursor); + let max = anchor.max(vs.cursor); + min..=max + }) + } else { + None + }; + let items: Vec = vs .visible_items .iter() .enumerate() .map(|(i, vi)| { - let style = if i == vs.cursor { + let in_visual = visual_range.as_ref().is_some_and(|r| r.contains(&i)); + let mut style = if i == vs.cursor { Style::default().add_modifier(Modifier::REVERSED) + } else if in_visual { + Style::default().fg(Color::Black).bg(Color::Blue) } else { Style::default() }; + + if vi.selected { + style = style.fg(Color::Green); + } + + let marker = if vi.selected { "* " } else { " " }; let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str()); - ListItem::new(text).style(style) + if vs.multi_enabled { + ListItem::new(format!("{marker}{text}")).style(style) + } else { + ListItem::new(text).style(style) + } }) .collect(); @@ -223,6 +350,7 @@ fn map_key_event( match mode { Mode::Insert => map_insert_mode(key, filter_text), Mode::Normal => map_normal_mode(key, pending), + Mode::Visual => map_visual_mode(key, pending), } } @@ -289,6 +417,12 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option { (KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { Some(Action::Cancel) } + (KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Visual)), + (KeyCode::Char('U'), _) => Some(Action::ClearSelections), + (KeyCode::Char('u'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + Some(Action::UndoSelection) + } + (KeyCode::Char('r'), KeyModifiers::CONTROL) => Some(Action::RedoSelection), (KeyCode::Enter, _) => Some(Action::Confirm), (KeyCode::Esc, _) => Some(Action::Cancel), (KeyCode::Up, _) => Some(Action::MoveUp(1)), @@ -297,6 +431,41 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option { } } +/// Visual mode: movement keys extend the range, V cancels, +/// Space/Enter apply (handled in handle_multi_action_key). +fn map_visual_mode(key: KeyEvent, pending: &mut PendingKey) -> Option { + // Handle pending `g` key for `gg` sequence + if *pending == PendingKey::G { + *pending = PendingKey::None; + if key.code == KeyCode::Char('g') + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + return Some(Action::MoveToTop); + } + } + + match (key.code, key.modifiers) { + (KeyCode::Char('j'), m) | (KeyCode::Down, m) if !m.intersects(KeyModifiers::ALT) => { + Some(Action::MoveDown(1)) + } + (KeyCode::Char('k'), m) | (KeyCode::Up, m) if !m.intersects(KeyModifiers::ALT) => { + Some(Action::MoveUp(1)) + } + (KeyCode::Char('G'), _) => Some(Action::MoveToBottom), + (KeyCode::Char('g'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + *pending = PendingKey::G; + None + } + // V cancels visual mode, no selection applied + (KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Normal)), + // Escape always cancels the menu + (KeyCode::Esc, _) => Some(Action::Cancel), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -331,16 +500,19 @@ mod tests { label: "alpha".into(), formatted_text: None, index: 0, + selected: false, }, VisibleItem { label: "bravo".into(), formatted_text: None, index: 1, + selected: false, }, VisibleItem { label: "charlie".into(), formatted_text: None, index: 2, + selected: false, }, ], cursor: 0, @@ -348,6 +520,8 @@ mod tests { total_items: 5, total_filtered: 3, mode: Mode::Insert, + selection_count: 0, + multi_enabled: false, generation: 1, } } @@ -703,7 +877,7 @@ mod tests { let mut terminal = Terminal::new(backend).ok().expect("test terminal"); terminal .draw(|frame| { - render_menu(frame, vs, filter); + render_menu(frame, vs, filter, None); }) .ok() .expect("draw"); @@ -820,6 +994,8 @@ mod tests { total_items: 0, total_filtered: 0, mode: Mode::Insert, + selection_count: 0, + multi_enabled: false, generation: 1, }; let backend = render_to_backend(30, 4, &vs, ""); @@ -853,7 +1029,7 @@ mod tests { let mut terminal = Terminal::new(backend).ok().expect("test terminal"); terminal .draw(|frame| { - render_menu(frame, &vs, "hi"); + render_menu(frame, &vs, "hi", None); }) .ok() .expect("draw"); @@ -871,7 +1047,7 @@ mod tests { let mut terminal = Terminal::new(backend).ok().expect("test terminal"); terminal .draw(|frame| { - render_menu(frame, &vs, ""); + render_menu(frame, &vs, "", None); }) .ok() .expect("draw"); diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index 68d3c01..a140a92 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -119,6 +119,14 @@ struct Cli { /// Wrap output in structured JSON with action metadata #[arg(long)] structured: bool, + + /// Enable multi-select mode (Space/Tab to toggle, V for visual) + #[arg(long)] + multi: bool, + + /// Output selected items in selection order instead of input order + #[arg(long)] + selection_order: bool, } fn main() { @@ -214,10 +222,11 @@ fn main() { let start_mode = match cli.start_mode.as_str() { "insert" => Mode::Insert, "normal" => Mode::Normal, + "visual" => Mode::Visual, other => { let _ = writeln!( std::io::stderr().lock(), - "pikl: unknown mode '{other}', expected insert or normal" + "pikl: unknown mode '{other}', expected insert, normal, or visual" ); std::process::exit(2); } @@ -333,6 +342,8 @@ async fn run_headless( ) -> Result { let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli)); menu.set_initial_mode(start_mode); + menu.set_multi(cli.multi); + menu.set_selection_order(cli.selection_order); if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) { menu.set_dispatcher(dispatcher); @@ -380,6 +391,8 @@ async fn run_interactive( ) -> Result { let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli)); menu.set_initial_mode(start_mode); + menu.set_multi(cli.multi); + menu.set_selection_order(cli.selection_order); if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) { menu.set_dispatcher(dispatcher); @@ -409,16 +422,20 @@ async fn run_interactive( fn handle_result(result: Result, cli: &Cli) { let mut out = std::io::stdout().lock(); match result { - Ok(MenuResult::Selected { value, index }) => { + Ok(MenuResult::Selected { items }) => { if cli.structured { - let output = OutputItem { - value, - action: OutputAction::Select, - index, - }; - let _ = write_output_json(&mut out, &output); + for (value, index) in items { + let output = OutputItem { + value, + action: OutputAction::Select, + index, + }; + let _ = write_output_json(&mut out, &output); + } } else { - let _ = write_plain_value(&mut out, &value); + for (value, _) in &items { + let _ = write_plain_value(&mut out, value); + } } } Ok(MenuResult::Quicklist { items }) => { diff --git a/crates/pikl/tests/headless_dsl.rs b/crates/pikl/tests/headless_dsl.rs index a6331f4..df4ff0b 100644 --- a/crates/pikl/tests/headless_dsl.rs +++ b/crates/pikl/tests/headless_dsl.rs @@ -284,6 +284,44 @@ pikl_tests! { } } + headless mod multi_select_headless { + items: ["alpha", "bravo", "charlie"]; + multi: true; + + test toggle_and_confirm { + actions: [toggle-select, confirm] + stdout: "alpha" + exit: 0 + } + + test toggle_two_and_confirm { + actions: [toggle-select, move-down, move-down, toggle-select, confirm] + stdout: "alpha" + exit: 0 + } + + test select_all_and_confirm { + actions: [select-all, confirm] + stdout: "alpha" + exit: 0 + } + + test deselect_all_clears { + actions: [toggle-select, deselect-all, confirm] + // Falls back to cursor item + stdout: "alpha" + exit: 0 + } + + test without_multi_toggle_is_noop { + // This test does NOT have multi: true (uses the shared fixture) + // but toggle-select still just selects cursor on confirm + actions: [toggle-select, confirm] + stdout: "alpha" + exit: 0 + } + } + headless mod pipeline_headless { items: ["error_log", "warning_temp", "info_log"];