feat: Add new system for multi-selections.

This commit is contained in:
2026-03-14 13:55:55 -04:00
parent f6f1efdf8e
commit 2e29dfc103
13 changed files with 930 additions and 63 deletions

View File

@@ -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<String> {
let template = self.format_template.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;

View File

@@ -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<usize>,
undo_stack: Vec<IndexSet<usize>>,
redo_stack: Vec<IndexSet<usize>>,
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<Item = usize>) {
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<Item = usize>) {
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<usize> {
let mut items: Vec<usize> = self.selected.iter().copied().collect();
items.sort_unstable();
items
}
/// Items in insertion order (the order they were selected).
pub fn insertion_ordered(&self) -> Vec<usize> {
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<M: MutableMenu> {
dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>,
generation: u64,
selection: SelectionState,
visual_anchor: Option<usize>,
}
impl<M: MutableMenu> MenuRunner<M> {
@@ -72,6 +169,8 @@ impl<M: MutableMenu> MenuRunner<M> {
dispatcher: None,
previous_cursor: None,
generation: 0,
selection: SelectionState::new(),
visual_anchor: None,
};
(runner, action_tx)
}
@@ -99,6 +198,17 @@ impl<M: MutableMenu> MenuRunner<M> {
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<M: MutableMenu> MenuRunner<M> {
.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<M: MutableMenu> MenuRunner<M> {
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<M: MutableMenu> MenuRunner<M> {
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<usize> = (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<usize> = (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<M: MutableMenu> MenuRunner<M> {
}
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<M: MutableMenu> MenuRunner<M> {
// 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<Value> =
@@ -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<JsonMenu> {
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);
}
}