Files
pikl/crates/pikl-core/src/runtime/menu.rs

1809 lines
63 KiB
Rust

//! The main event loop. [`MenuRunner`] wraps any [`Menu`]
//! implementation, drives the action/event channel loop,
//! and broadcasts state changes. Frontends never mutate
//! state directly. They send actions and react to events.
use std::sync::Arc;
use indexmap::IndexSet;
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, info, trace};
use crate::debounce::{DebouncedDispatcher, hook_response_to_action};
use crate::error::PiklError;
use crate::event::{Action, ColumnHeader, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
use crate::hook::{HookEvent, HookHandler};
use crate::model::traits::MutableMenu;
use crate::navigation::Viewport;
use serde_json::Value;
/// Result of applying a single action to the menu state.
/// The run loop uses this to decide what to broadcast.
#[derive(Debug)]
pub enum ActionOutcome {
/// State changed, broadcast to subscribers.
Broadcast,
/// User confirmed a selection.
Selected { items: Vec<(Value, usize)> },
/// User cancelled.
Cancelled,
/// Menu closed by hook command.
Closed,
/// User quicklisted the filtered items.
Quicklist { items: Vec<(Value, usize)> },
/// Nothing happened (e.g. confirm on empty list).
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
/// [`MenuRunner::run`] to start the event loop.
pub struct MenuRunner<M: MutableMenu> {
menu: M,
viewport: Viewport,
filter_text: Arc<str>,
mode: Mode,
action_rx: mpsc::Receiver<Action>,
event_tx: broadcast::Sender<MenuEvent>,
dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>,
generation: u64,
selection: SelectionState,
visual_anchor: Option<usize>,
}
impl<M: MutableMenu> MenuRunner<M> {
/// Create a menu runner wrapping the given menu backend.
/// Returns the runner and an action sender. Call
/// [`subscribe`](Self::subscribe) to get an event handle,
/// then [`run`](Self::run) to start the event loop.
pub fn new(menu: M) -> (Self, mpsc::Sender<Action>) {
let (action_tx, action_rx) = mpsc::channel(256);
// 1024 slots: large enough that a burst of rapid state changes
// (e.g. streaming AddItems + filter updates) won't cause lag for
// subscribers. If a subscriber does fall behind, it gets a Lagged
// error and can catch up from the next StateChanged.
let (event_tx, _) = broadcast::channel(1024);
let runner = Self {
menu,
viewport: Viewport::new(),
filter_text: Arc::from(""),
mode: Mode::default(),
action_rx,
event_tx,
dispatcher: None,
previous_cursor: None,
generation: 0,
selection: SelectionState::new(),
visual_anchor: None,
};
(runner, action_tx)
}
/// Subscribe to menu events. Returns a broadcast receiver
/// that gets state changes, selections, and cancellations.
pub fn subscribe(&self) -> broadcast::Receiver<MenuEvent> {
self.event_tx.subscribe()
}
/// Set a hook handler. Wraps it in a DebouncedDispatcher
/// with no debounce (all events fire immediately). Use
/// [`set_dispatcher`] for custom debounce settings.
pub fn set_hook_handler(
&mut self,
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) {
let dispatcher = DebouncedDispatcher::new(handler, action_tx);
self.dispatcher = Some(dispatcher);
}
/// Set a hook handler with a pre-configured dispatcher.
pub fn set_dispatcher(&mut self, dispatcher: DebouncedDispatcher) {
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) {
let start = std::time::Instant::now();
self.menu.apply_filter(&self.filter_text);
let matched = self.menu.filtered_count();
let total = self.menu.total();
let duration_us = start.elapsed().as_micros() as u64;
debug!(
filter = %self.filter_text,
matched,
total,
duration_us,
"filter applied"
);
self.viewport.set_filtered_count(matched);
}
/// Build a [`ViewState`] snapshot from the current filter
/// results and viewport position.
fn build_view_state(&mut self) -> ViewState {
self.generation += 1;
let range = self.viewport.visible_range();
let has_columns = self.menu.column_config().is_some();
let visible_items: Vec<VisibleItem> = range
.clone()
.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);
let column_values = if has_columns {
self.menu.column_values(i)
} else {
None
};
VisibleItem {
label: label.to_string(),
formatted_text,
index: i,
selected: self.selection.is_selected(orig_idx),
column_values,
}
})
})
.collect();
// Compute column headers with widths if table mode is active
let columns = self.menu.column_config().map(|config| {
config
.columns
.iter()
.enumerate()
.map(|(col_idx, col_def)| {
let header_len = col_def.display_name.len();
let max_cell = visible_items
.iter()
.filter_map(|vi| {
vi.column_values
.as_ref()
.and_then(|cv| cv.get(col_idx))
.map(|s| s.len())
})
.max()
.unwrap_or(0);
let width = header_len.max(max_cell).min(60) as u16;
ColumnHeader {
display_name: col_def.display_name.clone(),
width,
}
})
.collect()
});
let cursor = if self.menu.filtered_count() == 0 {
0
} else {
self.viewport.cursor() - range.start
};
ViewState {
visible_items,
cursor,
filter_text: Arc::clone(&self.filter_text),
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,
columns,
generation: self.generation,
}
}
/// Send the current view state to all subscribers.
fn broadcast_state(&mut self) {
let vs = self.build_view_state();
trace!(
generation = vs.generation,
filtered = vs.total_filtered,
"state broadcast"
);
let _ = self.event_tx.send(MenuEvent::StateChanged(vs));
}
/// Emit a hook event through the dispatcher, if one is set.
fn emit_hook(&mut self, event: HookEvent) {
if let Some(dispatcher) = &mut self.dispatcher {
debug!(hook_event = ?event.kind(), "hook dispatched");
dispatcher.dispatch(event);
}
}
/// Check if the cursor moved to a different item and
/// emit a Hover event if so.
fn check_cursor_hover(&mut self) {
if self.menu.filtered_count() == 0 {
self.previous_cursor = None;
return;
}
let current = self.viewport.cursor();
let current_orig = self.menu.original_index(current);
if current_orig != self.previous_cursor {
self.previous_cursor = current_orig;
if let Some(value) = self.menu.serialize_filtered(current).cloned()
&& let Some(orig_idx) = current_orig
{
trace!(cursor = current, original_index = orig_idx, "hover changed");
self.emit_hook(HookEvent::Hover {
item: value,
index: orig_idx,
});
}
}
}
/// Apply a single action to the menu state. Pure state
/// transition: no channels, no async. Testable in isolation.
pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
match action {
Action::UpdateFilter(text) => {
debug!(filter = %text, "filter updated");
self.filter_text = Arc::from(text);
self.run_filter();
ActionOutcome::Broadcast
}
Action::MoveUp(n) => {
self.viewport.move_up(n);
ActionOutcome::Broadcast
}
Action::MoveDown(n) => {
self.viewport.move_down(n);
ActionOutcome::Broadcast
}
Action::MoveToTop => {
self.viewport.move_to_top();
ActionOutcome::Broadcast
}
Action::MoveToBottom => {
self.viewport.move_to_bottom();
ActionOutcome::Broadcast
}
Action::PageUp(n) => {
self.viewport.page_up(n);
ActionOutcome::Broadcast
}
Action::PageDown(n) => {
self.viewport.page_down(n);
ActionOutcome::Broadcast
}
Action::ToggleSelect => {
if !self.selection.multi_enabled {
return ActionOutcome::NoOp;
}
if self.menu.filtered_count() == 0 {
return ActionOutcome::NoOp;
}
let cursor = self.viewport.cursor();
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 => {
if self.menu.filtered_count() == 0 {
return ActionOutcome::NoOp;
}
let items: Vec<(Value, usize)> = self
.menu
.collect_filtered()
.into_iter()
.map(|(v, idx)| (v.clone(), idx))
.collect();
ActionOutcome::Quicklist { items }
}
Action::Cancel => ActionOutcome::Cancelled,
Action::Resize { height } => {
trace!(height, "viewport resized");
self.viewport.set_height(height as usize);
ActionOutcome::Broadcast
}
Action::HalfPageUp(n) => {
self.viewport.half_page_up(n);
ActionOutcome::Broadcast
}
Action::HalfPageDown(n) => {
self.viewport.half_page_down(n);
ActionOutcome::Broadcast
}
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
}
Action::AddItems(values) => {
debug!(count = values.len(), "items added");
self.menu.add_raw(values);
self.run_filter();
ActionOutcome::Broadcast
}
Action::ReplaceItems(values) => {
debug!(count = values.len(), "items replaced");
// Smart cursor: try to keep selection on the same original item.
let cursor = self.viewport.cursor();
let old_value = self.menu.serialize_filtered(cursor).cloned();
self.menu.replace_all(values);
self.run_filter();
// Try to find the old item in the new set
if let Some(ref old_val) = old_value {
let mut found = false;
for i in 0..self.menu.filtered_count() {
if self.menu.serialize_filtered(i) == Some(old_val) {
self.viewport.set_cursor(i);
found = true;
break;
}
}
if !found {
self.viewport.clamp();
}
} else {
self.viewport.clamp();
}
ActionOutcome::Broadcast
}
Action::RemoveItems(indices) => {
debug!(count = indices.len(), "items removed");
self.menu.remove_by_indices(indices);
self.run_filter();
self.viewport.clamp();
ActionOutcome::Broadcast
}
Action::ProcessHookResponse(resp) => {
let action = hook_response_to_action(resp);
self.apply_action(action)
}
Action::CloseMenu => ActionOutcome::Closed,
}
}
/// Set the initial mode before running the event loop.
/// Used by `--start-mode` CLI flag.
pub fn set_initial_mode(&mut self, mode: Mode) {
self.mode = mode;
}
/// Run the menu event loop. Consumes actions and
/// broadcasts events.
///
/// **Ordering guarantee:** Actions are processed
/// sequentially in the order received. Each action's
/// state change is fully applied before the next action
/// begins. A `Confirm` sent right after an
/// `UpdateFilter` will always select from the filtered
/// results, never stale pre-filter state.
///
/// This holds regardless of how actions are sent (TUI
/// keypresses, headless scripts, programmatic sends).
/// It's enforced by the single `recv()` loop below and
/// must be preserved by any future refactors.
pub async fn run(mut self) -> Result<MenuResult, PiklError> {
self.run_filter();
self.broadcast_state();
let total_items = self.menu.total();
let filtered = self.menu.filtered_count();
info!(total_items, filtered, "menu opened");
// Emit Open event
self.emit_hook(HookEvent::Open);
while let Some(action) = self.action_rx.recv().await {
let is_filter_update = matches!(&action, Action::UpdateFilter(_));
match self.apply_action(action) {
ActionOutcome::Broadcast => {
self.broadcast_state();
// Emit Filter event if the filter changed
if is_filter_update {
let text = self.filter_text.to_string();
self.emit_hook(HookEvent::Filter { text });
}
// Check for cursor movement -> Hover
self.check_cursor_hover();
}
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(items.clone()));
return Ok(MenuResult::Selected { items });
}
ActionOutcome::Quicklist { items } => {
let values: Vec<Value> = items.iter().map(|(v, _)| v.clone()).collect();
let count = values.len();
info!(count, "quicklist returned");
self.emit_hook(HookEvent::Quicklist {
items: values.clone(),
count,
});
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Quicklist(values));
return Ok(MenuResult::Quicklist { items });
}
ActionOutcome::Cancelled => {
info!("menu cancelled");
self.emit_hook(HookEvent::Cancel);
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
}
ActionOutcome::Closed => {
info!("menu closed by hook");
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
}
ActionOutcome::NoOp => {}
}
}
// Sender dropped
self.emit_hook(HookEvent::Close);
Ok(MenuResult::Cancelled)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::MenuEvent;
use crate::item::Item;
use crate::model::traits::Menu;
use crate::runtime::json_menu::JsonMenu;
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) {
let items = vec![
Item::from_plain_text("alpha"),
Item::from_plain_text("beta"),
Item::from_plain_text("gamma"),
Item::from_plain_text("delta"),
];
MenuRunner::new(JsonMenu::new(items, "label".to_string()))
}
/// Set up a menu runner with filter applied and viewport sized.
/// Ready for sync apply_action tests.
fn ready_menu() -> MenuRunner<JsonMenu> {
let (mut m, _tx) = test_menu();
m.run_filter();
m.apply_action(Action::Resize { height: 10 });
m
}
// -- Sync apply_action tests --
#[test]
fn apply_move_down_updates_viewport() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::MoveDown(1));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.viewport.cursor(), 1);
}
#[test]
fn apply_move_up_updates_viewport() {
let mut m = ready_menu();
m.apply_action(Action::MoveDown(2));
let outcome = m.apply_action(Action::MoveUp(1));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.viewport.cursor(), 1);
}
#[test]
fn apply_filter_changes_results() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::UpdateFilter("al".to_string()));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(&*m.filter_text, "al");
// alpha matches "al"
assert!(m.menu.filtered_count() >= 1);
}
#[test]
fn apply_confirm_returns_selected() {
let mut m = ready_menu();
m.apply_action(Action::MoveDown(1));
let outcome = m.apply_action(Action::Confirm);
assert!(
matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta"))
);
}
#[test]
fn apply_confirm_on_empty_is_noop() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("zzzzz".to_string()));
assert_eq!(m.menu.filtered_count(), 0);
let outcome = m.apply_action(Action::Confirm);
assert!(matches!(outcome, ActionOutcome::NoOp));
}
#[test]
fn apply_cancel_returns_cancelled() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::Cancel);
assert!(matches!(outcome, ActionOutcome::Cancelled));
}
#[test]
fn apply_resize_updates_viewport() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::Resize { height: 3 });
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.viewport.visible_range(), 0..3);
}
#[test]
fn apply_add_items_runs_filter() {
let mut m = ready_menu();
assert_eq!(m.menu.total(), 4);
let outcome = m.apply_action(Action::AddItems(vec![serde_json::Value::String(
"epsilon".to_string(),
)]));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 5);
assert_eq!(m.menu.filtered_count(), 5);
}
#[test]
fn apply_move_to_top_and_bottom() {
let mut m = ready_menu();
m.apply_action(Action::MoveToBottom);
assert_eq!(m.viewport.cursor(), 3);
m.apply_action(Action::MoveToTop);
assert_eq!(m.viewport.cursor(), 0);
}
#[test]
fn apply_page_movement() {
let (mut m, _tx) = test_menu();
m.run_filter();
m.apply_action(Action::Resize { height: 2 });
m.apply_action(Action::PageDown(1));
assert_eq!(m.viewport.cursor(), 2);
m.apply_action(Action::PageUp(1));
assert_eq!(m.viewport.cursor(), 0);
}
#[tokio::test]
async fn initial_state_broadcast() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
// Should receive initial state
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_items, 4);
assert_eq!(vs.total_filtered, 4);
assert_eq!(vs.cursor, 0);
assert!(vs.filter_text.is_empty());
}
// Cancel to exit
let _ = tx.send(Action::Cancel).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Cancelled)));
}
#[tokio::test]
async fn filter_updates() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
// Skip initial state
let _ = rx.recv().await;
// Filter to "al"
let _ = tx.send(Action::UpdateFilter("al".to_string())).await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_items, 4);
// alpha should match "al"
assert!(vs.total_filtered >= 1);
assert_eq!(&*vs.filter_text, "al");
}
let _ = tx.send(Action::Cancel).await;
let _ = handle.await;
}
#[tokio::test]
async fn confirm_selection() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
// Skip initial state
let _ = rx.recv().await;
// Need to send resize first so viewport has height
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Move down and confirm
let _ = tx.send(Action::MoveDown(1)).await;
let _ = rx.recv().await;
let _ = tx.send(Action::Confirm).await;
// Should get Selected event
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));
assert!(matches!(result, Ok(MenuResult::Selected { .. })));
}
#[tokio::test]
async fn confirm_on_empty_is_noop() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
// Filter to something that matches nothing
let _ = tx.send(Action::UpdateFilter("zzzzz".to_string())).await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_filtered, 0);
}
// Confirm should be no-op
let _ = tx.send(Action::Confirm).await;
// Cancel to exit (should still work)
let _ = tx.send(Action::Cancel).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Cancelled)));
}
#[tokio::test]
async fn sender_drop_cancels() {
let (menu, tx) = test_menu();
let _rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
// Drop the only sender.
drop(tx);
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Cancelled)));
}
// -- End-to-end output correctness --
#[tokio::test]
async fn confirm_first_item_value() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial state
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Confirm at cursor 0, should get "alpha"
let _ = tx.send(Action::Confirm).await;
let event = rx.recv().await;
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 items, .. }) if items[0].0.as_str() == Some("alpha"))
);
}
#[tokio::test]
async fn confirm_third_item_value() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Move down twice -> cursor at index 2 -> "gamma"
let _ = tx.send(Action::MoveDown(1)).await;
let _ = rx.recv().await;
let _ = tx.send(Action::MoveDown(1)).await;
let _ = rx.recv().await;
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma"))
);
}
#[tokio::test]
async fn filter_then_confirm_correct_item() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Filter to "del", should match "delta"
let _ = tx.send(Action::UpdateFilter("del".to_string())).await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert!(vs.total_filtered >= 1);
assert_eq!(vs.visible_items[0].label, "delta");
}
// Confirm, should select "delta"
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta"))
);
}
#[tokio::test]
async fn add_items_then_confirm_new_item() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Add a new item
let _ = tx
.send(Action::AddItems(vec![serde_json::Value::String(
"epsilon".to_string(),
)]))
.await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_items, 5);
assert_eq!(vs.total_filtered, 5);
}
// Filter to "eps", only epsilon should match
let _ = tx.send(Action::UpdateFilter("eps".to_string())).await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert!(vs.total_filtered >= 1);
assert_eq!(vs.visible_items[0].label, "epsilon");
}
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon"))
);
}
#[tokio::test]
async fn cancel_returns_no_item() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Cancel).await;
// Should get Cancelled event
assert!(matches!(rx.recv().await, Ok(MenuEvent::Cancelled)));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Cancelled)));
}
// -- Ordering invariant tests --
// These tests verify the sequential processing guarantee documented on
// MenuRunner::run(). Actions sent back-to-back without waiting for
// intermediate events must still be processed in order, each fully
// applied before the next begins.
#[tokio::test]
async fn actions_process_in_order_filter_then_confirm() {
// Send filter + confirm back-to-back with no recv between them.
// Confirm must act on the filtered results, not the pre-filter state.
let items = vec![
Item::from_plain_text("alpha"),
Item::from_plain_text("beta"),
Item::from_plain_text("banana"),
];
let (menu, tx) = MenuRunner::new(JsonMenu::new(items, "label".to_string()));
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 50 }).await;
let _ = rx.recv().await;
// Back-to-back, no waiting between these
let _ = tx.send(Action::UpdateFilter("ban".to_string())).await;
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
// Must get "banana". Filter was applied before confirm ran.
assert!(matches!(
result,
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("banana")
));
}
#[tokio::test]
async fn actions_process_in_order_move_then_confirm() {
// Send multiple moves + confirm back-to-back.
// Confirm must reflect the final cursor position.
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 50 }).await;
let _ = rx.recv().await;
// Three moves down back-to-back, then confirm
let _ = tx.send(Action::MoveDown(1)).await;
let _ = tx.send(Action::MoveDown(1)).await;
let _ = tx.send(Action::MoveDown(1)).await;
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
// Cursor at index 3 -> "delta"
assert!(matches!(
result,
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")
));
}
// -- New action variant tests --
#[test]
fn apply_half_page_down() {
let (mut m, _tx) = test_menu();
m.run_filter();
m.apply_action(Action::Resize { height: 4 });
let outcome = m.apply_action(Action::HalfPageDown(1));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.viewport.cursor(), 2); // 4/2 = 2
}
#[test]
fn apply_half_page_up() {
let (mut m, _tx) = test_menu();
m.run_filter();
m.apply_action(Action::Resize { height: 4 });
m.apply_action(Action::HalfPageDown(1));
let outcome = m.apply_action(Action::HalfPageUp(1));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.viewport.cursor(), 0);
}
#[test]
fn apply_set_mode_normal() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::SetMode(Mode::Normal));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.build_view_state().mode, Mode::Normal);
}
#[test]
fn apply_set_mode_insert() {
let mut m = ready_menu();
m.apply_action(Action::SetMode(Mode::Normal));
let outcome = m.apply_action(Action::SetMode(Mode::Insert));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.build_view_state().mode, Mode::Insert);
}
#[test]
fn apply_set_mode_preserves_filter() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("al".to_string()));
let count_before = m.menu.filtered_count();
let filter_before = m.filter_text.clone();
m.apply_action(Action::SetMode(Mode::Normal));
assert_eq!(&*m.filter_text, &*filter_before);
assert_eq!(m.menu.filtered_count(), count_before);
}
#[test]
fn set_initial_mode_works() {
let (mut m, _tx) = test_menu();
m.set_initial_mode(Mode::Normal);
m.run_filter();
assert_eq!(m.build_view_state().mode, Mode::Normal);
}
#[test]
fn view_state_mode_after_switch() {
let mut m = ready_menu();
m.apply_action(Action::SetMode(Mode::Normal));
let vs = m.build_view_state();
assert_eq!(vs.mode, Mode::Normal);
}
#[tokio::test]
async fn actions_process_in_order_add_items_then_filter_then_confirm() {
// AddItems + filter + confirm, all back-to-back.
// The filter must see the newly added items.
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 50 }).await;
let _ = rx.recv().await;
// All back-to-back
let _ = tx
.send(Action::AddItems(vec![serde_json::Value::String(
"zephyr".to_string(),
)]))
.await;
let _ = tx.send(Action::UpdateFilter("zep".to_string())).await;
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
// Must find "zephyr". It was added before the filter ran.
assert!(matches!(
result,
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("zephyr")
));
}
// -- Replace/Remove/Close action tests --
#[test]
fn apply_replace_items() {
let mut m = ready_menu();
assert_eq!(m.menu.total(), 4);
let outcome = m.apply_action(Action::ReplaceItems(vec![
serde_json::Value::String("x".to_string()),
serde_json::Value::String("y".to_string()),
]));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 2);
assert_eq!(m.menu.filtered_count(), 2);
}
#[test]
fn apply_replace_items_preserves_cursor() {
let mut m = ready_menu();
// Move to "beta" (index 1)
m.apply_action(Action::MoveDown(1));
// Replace items, keeping "beta" in the new set
m.apply_action(Action::ReplaceItems(vec![
serde_json::Value::String("alpha".to_string()),
serde_json::Value::String("beta".to_string()),
serde_json::Value::String("zeta".to_string()),
]));
// Cursor should still be on "beta"
let vs = m.build_view_state();
assert_eq!(vs.visible_items[vs.cursor].label, "beta");
}
#[test]
fn apply_remove_items() {
let mut m = ready_menu();
assert_eq!(m.menu.total(), 4);
let outcome = m.apply_action(Action::RemoveItems(vec![1, 3]));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 2);
// alpha and gamma should remain
assert_eq!(m.menu.filtered_label(0), Some("alpha"));
assert_eq!(m.menu.filtered_label(1), Some("gamma"));
}
#[test]
fn apply_close_menu() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::CloseMenu);
assert!(matches!(outcome, ActionOutcome::Closed));
}
#[test]
fn apply_hook_response_close() {
use crate::hook::HookResponse;
let mut m = ready_menu();
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::Close));
assert!(matches!(outcome, ActionOutcome::Closed));
}
#[test]
fn apply_hook_response_add_items() {
use crate::hook::HookResponse;
let mut m = ready_menu();
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::AddItems {
items: vec![serde_json::json!("new")],
}));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 5);
}
#[test]
fn confirm_returns_original_index() {
let mut m = ready_menu();
// Filter to narrow results, then confirm
m.apply_action(Action::UpdateFilter("del".to_string()));
assert!(m.menu.filtered_count() >= 1);
let outcome = m.apply_action(Action::Confirm);
// "delta" is at original index 3
assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].1 == 3));
}
// -- Quicklist tests --
#[test]
fn apply_quicklist_returns_all_filtered() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { 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 Quicklist, got {other:?}"),
}
}
#[test]
fn apply_quicklist_respects_filter() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("a".to_string()));
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
// "alpha", "gamma", "delta" all contain 'a'
assert!(items.len() >= 2);
for (v, _) in &items {
let label = v.as_str().unwrap_or("");
assert!(
label.contains('a'),
"filtered item should contain 'a': {label}"
);
}
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[test]
fn apply_quicklist_on_empty_is_noop() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("zzzzz".to_string()));
assert_eq!(m.menu.filtered_count(), 0);
let outcome = m.apply_action(Action::Quicklist);
assert!(matches!(outcome, ActionOutcome::NoOp));
}
#[test]
fn apply_quicklist_preserves_original_indices() {
let mut m = ready_menu();
// Filter to "del" -> only "delta" at original index 3
m.apply_action(Action::UpdateFilter("del".to_string()));
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].0.as_str(), Some("delta"));
assert_eq!(items[0].1, 3);
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[tokio::test]
async fn quicklist_event_broadcast() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
// Skip initial state
let _ = rx.recv().await;
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
let _ = tx.send(Action::Quicklist).await;
if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await {
assert_eq!(values.len(), 4);
} else {
panic!("expected Quicklist event");
}
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Quicklist { .. })));
}
#[tokio::test]
async fn quicklist_after_filter() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Filter then quicklist back-to-back
let _ = tx.send(Action::UpdateFilter("al".to_string())).await;
let _ = tx.send(Action::Quicklist).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
match result {
Ok(MenuResult::Quicklist { items }) => {
// "alpha" matches "al"
assert!(!items.is_empty());
for (v, _) in &items {
let label = v.as_str().unwrap_or("");
assert!(
label.contains("al"),
"quicklist item should match filter: {label}"
);
}
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
// -- Hook event tests --
#[tokio::test]
async fn hook_events_fire_on_lifecycle() {
use crate::hook::{HookEvent, HookEventKind, HookHandler};
use std::sync::Mutex;
struct Recorder(Mutex<Vec<HookEventKind>>);
impl HookHandler for Recorder {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
if let Ok(mut v) = self.0.lock() {
v.push(event.kind());
}
Ok(())
}
}
let recorder = Arc::new(Recorder(Mutex::new(Vec::new())));
let (mut m, action_tx) = test_menu();
m.set_hook_handler(Arc::clone(&recorder) as Arc<dyn HookHandler>, action_tx);
m.run_filter();
m.apply_action(Action::Resize { height: 10 });
// Simulate lifecycle: the Open event is emitted in run(),
// but we can test Filter/Hover/Cancel manually
m.emit_hook(HookEvent::Open);
m.apply_action(Action::UpdateFilter("al".to_string()));
m.emit_hook(HookEvent::Filter {
text: "al".to_string(),
});
m.apply_action(Action::MoveDown(1));
m.check_cursor_hover();
// Give spawned tasks a chance to complete
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let events = recorder.0.lock().map(|v| v.clone()).unwrap_or_default();
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);
}
// -- Column / table mode tests --
use crate::model::column::ColumnConfig;
fn table_menu() -> MenuRunner<JsonMenu> {
let items = vec![
Item::new(
serde_json::json!({"label": "alice", "meta": {"age": "30", "city": "toronto"}}),
"label",
),
Item::new(
serde_json::json!({"label": "bob", "meta": {"age": "25", "city": "vancouver"}}),
"label",
),
Item::new(
serde_json::json!({"label": "charlie", "meta": {"age": "35", "city": "montreal"}}),
"label",
),
];
let mut json_menu = JsonMenu::new(items, "label".to_string());
json_menu.set_column_config(ColumnConfig::parse("label,meta.age:Age,meta.city:City"));
let (runner, _tx) = MenuRunner::new(json_menu);
runner
}
fn ready_table_menu() -> MenuRunner<JsonMenu> {
let mut m = table_menu();
m.run_filter();
m.apply_action(Action::Resize { height: 10 });
m
}
#[test]
fn view_state_has_columns_when_config_set() {
let mut m = ready_table_menu();
let vs = m.build_view_state();
assert!(vs.columns.is_some(), "columns should be Some");
let cols = vs.columns.as_ref().unwrap();
assert_eq!(cols.len(), 3);
assert_eq!(cols[0].display_name, "label");
assert_eq!(cols[1].display_name, "Age");
assert_eq!(cols[2].display_name, "City");
}
#[test]
fn view_state_column_widths_computed() {
let mut m = ready_table_menu();
let vs = m.build_view_state();
let cols = vs.columns.as_ref().unwrap();
// "label" header is 5 chars, "charlie" is 7 chars: width should be 7
assert_eq!(cols[0].width, 7);
// "Age" header is 3 chars, max cell "35" is 2 chars: width should be 3
assert_eq!(cols[1].width, 3);
// "City" header is 4 chars, "vancouver" is 9 chars: width should be 9
assert_eq!(cols[2].width, 9);
}
#[test]
fn view_state_items_have_column_values() {
let mut m = ready_table_menu();
let vs = m.build_view_state();
let item = &vs.visible_items[0];
assert!(item.column_values.is_some());
let vals = item.column_values.as_ref().unwrap();
assert_eq!(vals, &["alice", "30", "toronto"]);
}
#[test]
fn view_state_no_columns_without_config() {
let mut m = ready_menu();
let vs = m.build_view_state();
assert!(vs.columns.is_none());
assert!(vs.visible_items[0].column_values.is_none());
}
#[test]
fn table_mode_filter_still_works() {
let mut m = ready_table_menu();
m.apply_action(Action::UpdateFilter("bob".to_string()));
let vs = m.build_view_state();
assert_eq!(vs.total_filtered, 1);
assert_eq!(vs.visible_items[0].label, "bob");
// Column values should still be present
let vals = vs.visible_items[0].column_values.as_ref().unwrap();
assert_eq!(vals, &["bob", "25", "vancouver"]);
}
#[test]
fn table_mode_column_widths_change_with_filter() {
let mut m = ready_table_menu();
m.apply_action(Action::UpdateFilter("alice".to_string()));
let vs = m.build_view_state();
let cols = vs.columns.as_ref().unwrap();
// Only "alice" visible now, so label width = max(5, 5) = 5
assert_eq!(cols[0].width, 5);
// City "toronto" (7) vs header "City" (4): width = 7
assert_eq!(cols[2].width, 7);
}
}