feat: Add new system for multi-selections.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user