feat(core): Add runtime engine, input parsing, and hook trait.
This commit is contained in:
15
crates/pikl-core/src/runtime/hook.rs
Normal file
15
crates/pikl-core/src/runtime/hook.rs
Normal 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>;
|
||||||
|
}
|
||||||
117
crates/pikl-core/src/runtime/input.rs
Normal file
117
crates/pikl-core/src/runtime/input.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/pikl-core/src/runtime/json_menu.rs
Normal file
67
crates/pikl-core/src/runtime/json_menu.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
648
crates/pikl-core/src/runtime/menu.rs
Normal file
648
crates/pikl-core/src/runtime/menu.rs
Normal 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")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/pikl-core/src/runtime/mod.rs
Normal file
4
crates/pikl-core/src/runtime/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod hook;
|
||||||
|
pub mod input;
|
||||||
|
pub mod json_menu;
|
||||||
|
pub mod menu;
|
||||||
Reference in New Issue
Block a user