diff --git a/crates/pikl-core/src/model/event.rs b/crates/pikl-core/src/model/event.rs index a883590..3fcf2c2 100644 --- a/crates/pikl-core/src/model/event.rs +++ b/crates/pikl-core/src/model/event.rs @@ -38,6 +38,7 @@ pub enum Action { HalfPageDown(usize), SetMode(Mode), Confirm, + Quicklist, Cancel, Resize { height: u16 }, AddItems(Vec), @@ -53,6 +54,7 @@ pub enum Action { pub enum MenuEvent { StateChanged(ViewState), Selected(Value), + Quicklist(Vec), Cancelled, } @@ -89,5 +91,6 @@ pub struct VisibleItem { #[derive(Debug)] pub enum MenuResult { Selected { value: Value, index: usize }, + Quicklist { items: Vec<(Value, usize)> }, Cancelled, } diff --git a/crates/pikl-core/src/model/output.rs b/crates/pikl-core/src/model/output.rs index b1f12ef..e06d39d 100644 --- a/crates/pikl-core/src/model/output.rs +++ b/crates/pikl-core/src/model/output.rs @@ -11,6 +11,7 @@ use serde_json::Value; #[serde(rename_all = "snake_case")] pub enum OutputAction { Select, + Quicklist, Cancel, } @@ -94,6 +95,18 @@ mod tests { assert_eq!(json["action"], "cancel"); } + #[test] + fn output_item_quicklist_action() { + let item = OutputItem { + value: json!("test"), + action: OutputAction::Quicklist, + index: 2, + }; + let json = serde_json::to_value(&item).unwrap_or_default(); + assert_eq!(json["action"], "quicklist"); + assert_eq!(json["index"], 2); + } + #[test] fn output_item_string_contains_value_text() { let item = OutputItem { diff --git a/crates/pikl-core/src/model/traits.rs b/crates/pikl-core/src/model/traits.rs index cc22478..f8a7f53 100644 --- a/crates/pikl-core/src/model/traits.rs +++ b/crates/pikl-core/src/model/traits.rs @@ -59,4 +59,17 @@ pub trait Menu: Send + 'static { fn formatted_label(&self, _filtered_index: usize) -> Option { None } + + /// Collect all filtered items as (value, original_index) pairs. + /// Default implementation iterates filtered results using + /// existing trait methods. + fn collect_filtered(&self) -> Vec<(&serde_json::Value, usize)> { + (0..self.filtered_count()) + .filter_map(|i| { + let value = self.serialize_filtered(i)?; + let orig = self.original_index(i)?; + Some((value, orig)) + }) + .collect() + } } diff --git a/crates/pikl-core/src/runtime/hook.rs b/crates/pikl-core/src/runtime/hook.rs index 95d85dd..7ad4007 100644 --- a/crates/pikl-core/src/runtime/hook.rs +++ b/crates/pikl-core/src/runtime/hook.rs @@ -20,6 +20,7 @@ pub enum HookEvent { Select { item: Value, index: usize }, Cancel, Filter { text: String }, + Quicklist { items: Vec, count: usize }, } /// Discriminant for [`HookEvent`], used as a key for @@ -32,6 +33,7 @@ pub enum HookEventKind { Select, Cancel, Filter, + Quicklist, } impl HookEvent { @@ -44,6 +46,7 @@ impl HookEvent { HookEvent::Select { .. } => HookEventKind::Select, HookEvent::Cancel => HookEventKind::Cancel, HookEvent::Filter { .. } => HookEventKind::Filter, + HookEvent::Quicklist { .. } => HookEventKind::Quicklist, } } } @@ -286,6 +289,27 @@ mod tests { assert!(resp.is_none()); } + #[test] + fn event_quicklist_serializes() { + let event = HookEvent::Quicklist { + items: vec![json!("alpha"), json!("beta")], + count: 2, + }; + let json = serde_json::to_value(&event).unwrap_or_default(); + assert_eq!(json["event"], "quicklist"); + assert_eq!(json["count"], 2); + assert_eq!(json["items"].as_array().map(|a| a.len()), Some(2)); + } + + #[test] + fn event_kind_quicklist() { + let event = HookEvent::Quicklist { + items: vec![], + count: 0, + }; + assert_eq!(event.kind(), HookEventKind::Quicklist); + } + // -- Roundtrip: HookEvent serialize -> check shape -- #[test] diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs index 80246c5..e27058b 100644 --- a/crates/pikl-core/src/runtime/menu.rs +++ b/crates/pikl-core/src/runtime/menu.rs @@ -27,6 +27,8 @@ pub enum ActionOutcome { 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, } @@ -218,6 +220,18 @@ impl MenuRunner { 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 } => { self.viewport.set_height(height as usize); @@ -333,6 +347,21 @@ impl MenuRunner { let _ = self.event_tx.send(MenuEvent::Selected(value.clone())); return Ok(MenuResult::Selected { value, index }); } + ActionOutcome::Quicklist { items } => { + let values: Vec = + items.iter().map(|(v, _)| v.clone()).collect(); + let count = values.len(); + self.emit_hook(HookEvent::Quicklist { + items: values.clone(), + count, + }); + self.emit_hook(HookEvent::Close); + + let _ = self + .event_tx + .send(MenuEvent::Quicklist(values)); + return Ok(MenuResult::Quicklist { items }); + } ActionOutcome::Cancelled => { self.emit_hook(HookEvent::Cancel); self.emit_hook(HookEvent::Close); @@ -945,6 +974,127 @@ mod tests { assert!(matches!(outcome, ActionOutcome::Selected { index: 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] diff --git a/crates/pikl-tui/src/lib.rs b/crates/pikl-tui/src/lib.rs index 85d1f21..8c2fe8b 100644 --- a/crates/pikl-tui/src/lib.rs +++ b/crates/pikl-tui/src/lib.rs @@ -132,7 +132,7 @@ async fn run_inner( } view_state = Some(vs); } - Ok(MenuEvent::Selected(_) | MenuEvent::Cancelled) => { + Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => { break; } Err(broadcast::error::RecvError::Lagged(_)) => {} @@ -233,6 +233,7 @@ fn map_insert_mode(key: KeyEvent, filter_text: &mut String) -> Option { filter_text.pop(); Some(Action::UpdateFilter(filter_text.clone())) } + (KeyCode::Char('q'), KeyModifiers::CONTROL) => Some(Action::Quicklist), (KeyCode::Char(c), mods) if !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { filter_text.push(c); Some(Action::UpdateFilter(filter_text.clone())) @@ -276,6 +277,7 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option { (KeyCode::Char('/'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { Some(Action::SetMode(Mode::Insert)) } + (KeyCode::Char('q'), KeyModifiers::CONTROL) => Some(Action::Quicklist), (KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { Some(Action::Cancel) } @@ -663,6 +665,28 @@ mod tests { assert_eq!(ft, ""); } + #[test] + fn ctrl_q_maps_to_quicklist_insert() { + let mut ft = String::new(); + let mut pending = PendingKey::None; + let k = key_with_mods(KeyCode::Char('q'), KeyModifiers::CONTROL); + assert_eq!( + map_key_event(k, &mut ft, Mode::Insert, &mut pending), + Some(Action::Quicklist) + ); + } + + #[test] + fn ctrl_q_maps_to_quicklist_normal() { + let mut ft = String::new(); + let mut pending = PendingKey::None; + let k = key_with_mods(KeyCode::Char('q'), KeyModifiers::CONTROL); + assert_eq!( + map_key_event(k, &mut ft, Mode::Normal, &mut pending), + Some(Action::Quicklist) + ); + } + // -- Rendering tests (TestBackend) -- fn render_to_backend(width: u16, height: u16, vs: &ViewState, filter: &str) -> TestBackend { diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index 9ff1250..2f324a0 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -18,6 +18,7 @@ use pikl_core::json_menu::JsonMenu; use pikl_core::menu::MenuRunner; use pikl_core::output::{OutputAction, OutputItem}; use pikl_core::script::action_fd::{self, ScriptAction, ShowAction}; +use serde_json::Value; use handler::ShellHandlerHook; use hook::ShellExecHandler; @@ -114,6 +115,10 @@ struct Cli { /// Format template for display text (e.g. "{label} - {sublabel}") #[arg(long, value_name = "TEMPLATE")] format: Option, + + /// Wrap output in structured JSON with action metadata + #[arg(long)] + structured: bool, } fn main() { @@ -397,19 +402,48 @@ async fn run_interactive( result } -/// Process the menu result: print selected item JSON to -/// stdout, run hooks, or exit with the appropriate code. -fn handle_result(result: Result, _cli: &Cli) { +/// Process the menu result: print output to stdout and +/// exit with the appropriate code. +fn handle_result(result: Result, cli: &Cli) { + let mut out = std::io::stdout().lock(); match result { Ok(MenuResult::Selected { value, index }) => { - let output = OutputItem { - value: value.clone(), - action: OutputAction::Select, - index, - }; - let _ = write_output_json(&mut std::io::stdout().lock(), &output); + if cli.structured { + let output = OutputItem { + value, + action: OutputAction::Select, + index, + }; + let _ = write_output_json(&mut out, &output); + } else { + let _ = write_plain_value(&mut out, &value); + } + } + Ok(MenuResult::Quicklist { items }) => { + if cli.structured { + for (value, index) in items { + let output = OutputItem { + value, + action: OutputAction::Quicklist, + index, + }; + let _ = write_output_json(&mut out, &output); + } + } else { + for (value, _) in &items { + let _ = write_plain_value(&mut out, value); + } + } } Ok(MenuResult::Cancelled) => { + if cli.structured { + let output = OutputItem { + value: Value::Null, + action: OutputAction::Cancel, + index: 0, + }; + let _ = write_output_json(&mut out, &output); + } std::process::exit(1); } Err(e) => { @@ -425,6 +459,18 @@ fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), writeln!(writer, "{json}") } +/// Write a plain value: strings without quotes, everything +/// else as JSON. +fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::io::Error> { + match value { + Value::String(s) => writeln!(writer, "{s}"), + _ => { + let json = serde_json::to_string(value).unwrap_or_default(); + writeln!(writer, "{json}") + } + } +} + /// Read items from stdin. If `timeout_secs` is non-zero, /// spawn a thread and bail if it doesn't finish in time. /// A timeout of 0 means no timeout (blocking read).