feat(core): Add runtime engine, input parsing, and hook trait.

This commit is contained in:
2026-03-13 21:56:42 -04:00
parent d62b136a64
commit c74e4ea9fb
5 changed files with 851 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
//! Hook trait for lifecycle events. The core library defines
//! the interface; concrete implementations (shell hooks, IPC
//! hooks, etc.) live in frontend crates.
use serde_json::Value;
use crate::error::PiklError;
/// A lifecycle hook that fires on menu events. Implementations
/// live outside pikl-core (e.g. in the CLI binary) so the core
/// library stays free of process/libc deps.
#[allow(async_fn_in_trait)]
pub trait Hook: Send + Sync {
async fn run(&self, value: &Value) -> Result<(), PiklError>;
}

View File

@@ -0,0 +1,117 @@
//! Input parsing. Reads lines from stdin (or any reader)
//! and turns them into [`Item`]s. Each line is tried as
//! JSON first. If that doesn't parse, it's treated as plain
//! text. Empty lines are skipped.
use tokio::io::AsyncBufReadExt;
use crate::error::PiklError;
use crate::item::Item;
/// Try to parse a line as JSON. Falls back to wrapping
/// it as a plain-text string. The `label_key` controls
/// which JSON key is used as the display label for object
/// items.
fn parse_line(line: &str, label_key: &str) -> Item {
match serde_json::from_str::<serde_json::Value>(line) {
Ok(value) => Item::new(value, label_key),
Err(_) => Item::from_plain_text(line),
}
}
/// Read items from a synchronous reader. Use this for stdin so tokio
/// never registers the pipe fd. Avoids conflicts with crossterm's
/// event polling on fd 0 after dup2.
pub fn read_items_sync(
reader: impl std::io::BufRead,
label_key: &str,
) -> Result<Vec<Item>, PiklError> {
let mut items = Vec::new();
for line in reader.lines() {
let line = line?;
if line.is_empty() {
continue;
}
items.push(parse_line(&line, label_key));
}
Ok(items)
}
/// Async version of [`read_items_sync`]. For streaming
/// input sources where items arrive over time.
pub async fn read_items(
reader: impl tokio::io::AsyncBufRead + Unpin,
label_key: &str,
) -> Result<Vec<Item>, PiklError> {
let mut items = Vec::new();
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
if line.is_empty() {
continue;
}
items.push(parse_line(&line, label_key));
}
Ok(items)
}
#[cfg(test)]
mod tests {
use super::*;
async fn parse(input: &str) -> Vec<Item> {
let reader = tokio::io::BufReader::new(input.as_bytes());
read_items(reader, "label").await.unwrap_or_default()
}
#[tokio::test]
async fn plain_text_lines() {
let items = parse("foo\nbar\nbaz\n").await;
assert_eq!(items.len(), 3);
assert_eq!(items[0].label(), "foo");
assert_eq!(items[1].label(), "bar");
assert_eq!(items[2].label(), "baz");
}
#[tokio::test]
async fn json_objects() {
let items = parse("{\"label\": \"one\"}\n{\"label\": \"two\"}\n").await;
assert_eq!(items.len(), 2);
assert_eq!(items[0].label(), "one");
assert_eq!(items[1].label(), "two");
}
#[tokio::test]
async fn mixed_input() {
let items = parse("plain line\n{\"label\": \"json\"}\nanother plain\n").await;
assert_eq!(items.len(), 3);
assert_eq!(items[0].label(), "plain line");
assert_eq!(items[1].label(), "json");
assert_eq!(items[2].label(), "another plain");
}
#[tokio::test]
async fn skips_empty_lines() {
let items = parse("foo\n\n\nbar\n").await;
assert_eq!(items.len(), 2);
}
#[tokio::test]
async fn invalid_json_treated_as_text() {
let items = parse("{not valid json}\n").await;
assert_eq!(items.len(), 1);
assert_eq!(items[0].label(), "{not valid json}");
}
#[tokio::test]
async fn empty_input() {
let items = parse("").await;
assert!(items.is_empty());
}
#[tokio::test]
async fn json_string_values() {
let items = parse("\"quoted string\"\n").await;
assert_eq!(items.len(), 1);
assert_eq!(items[0].label(), "quoted string");
}
}

View File

@@ -0,0 +1,67 @@
//! JSON-backed menu implementation. Wraps `Vec<Item>` with
//! fuzzy filtering via nucleo. This is the default backend
//! for `ls | pikl` style usage.
use crate::filter::{Filter, FuzzyFilter};
use crate::item::Item;
use crate::model::traits::Menu;
/// A menu backed by a flat list of JSON items. Handles
/// filtering internally using the [`Filter`] trait (fuzzy
/// by default). The `label_key` controls which JSON key
/// is used for display labels on object items.
pub struct JsonMenu {
items: Vec<Item>,
label_key: String,
filter: Box<dyn Filter>,
}
impl JsonMenu {
/// Create a new JSON menu with the given items and label key.
pub fn new(items: Vec<Item>, label_key: String) -> Self {
let mut filter = Box::new(FuzzyFilter::new());
for (i, item) in items.iter().enumerate() {
filter.push(i, item.label());
}
Self {
items,
label_key,
filter,
}
}
}
impl Menu for JsonMenu {
fn total(&self) -> usize {
self.items.len()
}
fn apply_filter(&mut self, query: &str) {
self.filter.set_query(query);
}
fn filtered_count(&self) -> usize {
self.filter.matched_count()
}
fn filtered_label(&self, filtered_index: usize) -> Option<&str> {
self.filter
.matched_index(filtered_index)
.map(|idx| self.items[idx].label())
}
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
for value in values {
let idx = self.items.len();
let item = Item::new(value, &self.label_key);
self.filter.push(idx, item.label());
self.items.push(item);
}
}
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value> {
self.filter
.matched_index(filtered_index)
.map(|idx| &self.items[idx].value)
}
}

View File

@@ -0,0 +1,648 @@
//! 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 tokio::sync::{broadcast, mpsc};
use crate::error::PiklError;
use crate::event::{Action, MenuEvent, MenuResult, ViewState, VisibleItem};
use crate::model::traits::Menu;
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(Value),
/// User cancelled.
Cancelled,
/// Nothing happened (e.g. confirm on empty list).
NoOp,
}
/// 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: Menu> {
menu: M,
viewport: Viewport,
filter_text: Arc<str>,
action_rx: mpsc::Receiver<Action>,
event_tx: broadcast::Sender<MenuEvent>,
}
impl<M: Menu> 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(""),
action_rx,
event_tx,
};
(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()
}
/// Re-run the filter against all items with the current
/// filter text. Updates the viewport with the new count.
fn run_filter(&mut self) {
self.menu.apply_filter(&self.filter_text);
self.viewport.set_filtered_count(self.menu.filtered_count());
}
/// Build a [`ViewState`] snapshot from the current filter
/// results and viewport position.
fn build_view_state(&self) -> ViewState {
let range = self.viewport.visible_range();
let visible_items: Vec<VisibleItem> = range
.clone()
.filter_map(|i| {
self.menu.filtered_label(i).map(|label| VisibleItem {
label: label.to_string(),
index: i,
})
})
.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(),
}
}
/// Send the current view state to all subscribers.
fn broadcast_state(&self) {
let _ = self
.event_tx
.send(MenuEvent::StateChanged(self.build_view_state()));
}
/// 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) => {
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::Confirm => {
if self.menu.filtered_count() == 0 {
return ActionOutcome::NoOp;
}
let cursor = self.viewport.cursor();
match self.menu.serialize_filtered(cursor) {
Some(value) => ActionOutcome::Selected(value.clone()),
None => ActionOutcome::NoOp,
}
}
Action::Cancel => ActionOutcome::Cancelled,
Action::Resize { height } => {
self.viewport.set_height(height as usize);
ActionOutcome::Broadcast
}
Action::AddItems(values) => {
self.menu.add_raw(values);
self.run_filter();
ActionOutcome::Broadcast
}
}
}
/// 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();
while let Some(action) = self.action_rx.recv().await {
match self.apply_action(action) {
ActionOutcome::Broadcast => self.broadcast_state(),
ActionOutcome::Selected(value) => {
let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
return Ok(MenuResult::Selected(value));
}
ActionOutcome::Cancelled => {
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
}
ActionOutcome::NoOp => {}
}
}
// Sender dropped
Ok(MenuResult::Cancelled)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::MenuEvent;
use crate::item::Item;
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(v) if v.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(value)) = rx.recv().await {
assert_eq!(value.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(v)) if v.as_str() == Some("alpha")));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.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 v)) if v.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 v)) if v.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 v)) if v.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 v)) if v.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 v)) if v.as_str() == Some("delta")
));
}
#[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 v)) if v.as_str() == Some("zephyr")
));
}
}

View File

@@ -0,0 +1,4 @@
pub mod hook;
pub mod input;
pub mod json_menu;
pub mod menu;