1891 lines
65 KiB
Rust
1891 lines
65 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,
|
|
}
|
|
|
|
/// Maximum number of undo snapshots to retain. Keeps memory
|
|
/// bounded when users toggle hundreds of selections.
|
|
const UNDO_STACK_LIMIT: usize = 50;
|
|
|
|
/// 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) {
|
|
if self.undo_stack.len() >= UNDO_STACK_LIMIT {
|
|
self.undo_stack.remove(0);
|
|
}
|
|
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>,
|
|
streaming: bool,
|
|
}
|
|
|
|
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,
|
|
streaming: false,
|
|
};
|
|
(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()
|
|
}
|
|
|
|
/// Get a clone of the event broadcast sender. Useful for
|
|
/// IPC servers that need to create their own subscribers.
|
|
pub fn event_sender(&self) -> broadcast::Sender<MenuEvent> {
|
|
self.event_tx.clone()
|
|
}
|
|
|
|
/// 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,
|
|
streaming: self.streaming,
|
|
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,
|
|
Action::StreamingDone => {
|
|
debug!("streaming done");
|
|
self.streaming = false;
|
|
ActionOutcome::Broadcast
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// Mark the menu as actively streaming items from stdin.
|
|
pub fn set_streaming(&mut self, v: bool) {
|
|
self.streaming = v;
|
|
}
|
|
|
|
/// 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,
|
|
filter_text: self.filter_text.to_string(),
|
|
});
|
|
}
|
|
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,
|
|
filter_text: self.filter_text.to_string(),
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
|
|
// -- Streaming tests --
|
|
|
|
#[test]
|
|
fn streaming_done_sets_streaming_false() {
|
|
let (mut m, _tx) = test_menu();
|
|
m.set_streaming(true);
|
|
m.run_filter();
|
|
assert!(m.build_view_state().streaming);
|
|
let outcome = m.apply_action(Action::StreamingDone);
|
|
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
|
assert!(!m.build_view_state().streaming);
|
|
}
|
|
|
|
#[test]
|
|
fn add_items_on_empty_streaming_menu() {
|
|
let (mut m, _tx) = MenuRunner::new(JsonMenu::new(vec![], "label".to_string()));
|
|
m.set_streaming(true);
|
|
m.run_filter();
|
|
m.apply_action(Action::Resize { height: 20 });
|
|
|
|
assert_eq!(m.menu.total(), 0);
|
|
assert!(m.build_view_state().streaming);
|
|
|
|
m.apply_action(Action::AddItems(vec![
|
|
Value::String("one".into()),
|
|
Value::String("two".into()),
|
|
]));
|
|
assert_eq!(m.menu.total(), 2);
|
|
assert_eq!(m.menu.filtered_count(), 2);
|
|
|
|
// Confirm on non-empty streaming menu works
|
|
let outcome = m.apply_action(Action::Confirm);
|
|
assert!(matches!(outcome, ActionOutcome::Selected { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn confirm_on_empty_streaming_menu_is_noop() {
|
|
let (mut m, _tx) = MenuRunner::new(JsonMenu::new(vec![], "label".to_string()));
|
|
m.set_streaming(true);
|
|
m.run_filter();
|
|
let outcome = m.apply_action(Action::Confirm);
|
|
assert!(matches!(outcome, ActionOutcome::NoOp));
|
|
}
|
|
|
|
#[test]
|
|
fn view_state_streaming_defaults_false() {
|
|
let mut m = ready_menu();
|
|
assert!(!m.build_view_state().streaming);
|
|
}
|
|
}
|