From 47437f536b691122b1b5c6ed027a06b79587e408 Mon Sep 17 00:00:00 2001 From: "J. Champagne" Date: Sun, 15 Mar 2026 11:51:17 -0400 Subject: [PATCH] feat: Add support for action modifier keys like ctrl,shift,alt. --- crates/pikl-core/src/model/event.rs | 141 +++++++- crates/pikl-core/src/model/output.rs | 76 ++++- crates/pikl-core/src/runtime/debounce.rs | 12 +- crates/pikl-core/src/runtime/hook.rs | 97 +++++- crates/pikl-core/src/runtime/menu.rs | 306 +++++++++++++----- crates/pikl-core/src/script/action_fd/mod.rs | 26 +- .../pikl-core/src/script/action_fd/parse.rs | 259 +++++++++++---- crates/pikl-gui/src/lib.rs | 55 ++-- crates/pikl-test-macros/src/codegen.rs | 20 +- crates/pikl-tui/src/lib.rs | 61 ++-- crates/pikl/src/handler.rs | 8 +- crates/pikl/src/ipc/server.rs | 10 +- crates/pikl/src/main.rs | 31 +- crates/pikl/tests/headless.rs | 90 ++++++ crates/pikl/tests/headless_dsl.rs | 25 ++ docs/DESIGN.md | 55 ++++ docs/DEVPLAN.md | 12 +- examples/demo.sh | 15 + 18 files changed, 1055 insertions(+), 244 deletions(-) diff --git a/crates/pikl-core/src/model/event.rs b/crates/pikl-core/src/model/event.rs index b5690ee..6718d7a 100644 --- a/crates/pikl-core/src/model/event.rs +++ b/crates/pikl-core/src/model/event.rs @@ -9,6 +9,8 @@ use std::sync::Arc; +use serde::ser::SerializeSeq; +use serde::Serialize; use serde_json::Value; use crate::hook::HookResponse; @@ -56,6 +58,64 @@ pub enum Action { StreamingDone, } +/// Modifier keys held during an action. Serializes as an +/// array of strings (e.g. `["shift", "ctrl"]`). Empty +/// modifiers serialize as `[]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Modifiers { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, +} + +impl Modifiers { + /// True when no modifiers are held. + pub fn is_empty(&self) -> bool { + !self.shift && !self.ctrl && !self.alt + } +} + +impl Serialize for Modifiers { + fn serialize(&self, serializer: S) -> Result { + let count = self.shift as usize + self.ctrl as usize + self.alt as usize; + let mut seq = serializer.serialize_seq(Some(count))?; + if self.shift { + seq.serialize_element("shift")?; + } + if self.ctrl { + seq.serialize_element("ctrl")?; + } + if self.alt { + seq.serialize_element("alt")?; + } + seq.end() + } +} + +/// An [`Action`] paired with the modifier keys that were +/// held when it was triggered. Frontends produce these, +/// the menu engine consumes them. +#[derive(Debug, Clone, PartialEq)] +pub struct ModifiedAction { + pub action: Action, + pub modifiers: Modifiers, +} + +impl ModifiedAction { + /// Wrap an action with default (empty) modifiers. + pub fn new(action: Action) -> Self { + Self { + action, + modifiers: Modifiers::default(), + } + } + + /// Wrap an action with specific modifiers. + pub fn with_modifiers(action: Action, modifiers: Modifiers) -> Self { + Self { action, modifiers } + } +} + /// Broadcast from the menu loop to all subscribers /// (frontends, tests). #[derive(Debug, Clone)] @@ -125,10 +185,89 @@ pub enum MenuResult { Selected { items: Vec<(Value, usize)>, filter_text: String, + modifiers: Modifiers, }, Quicklist { items: Vec<(Value, usize)>, filter_text: String, + modifiers: Modifiers, + }, + Cancelled { + modifiers: Modifiers, }, - Cancelled, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn modifiers_empty_serializes_as_empty_array() { + let mods = Modifiers::default(); + let json = serde_json::to_value(&mods).unwrap_or_default(); + assert_eq!(json, json!([])); + } + + #[test] + fn modifiers_single_serializes() { + let mods = Modifiers { + shift: true, + ctrl: false, + alt: false, + }; + let json = serde_json::to_value(&mods).unwrap_or_default(); + assert_eq!(json, json!(["shift"])); + } + + #[test] + fn modifiers_multiple_serializes() { + let mods = Modifiers { + shift: true, + ctrl: true, + alt: false, + }; + let json = serde_json::to_value(&mods).unwrap_or_default(); + assert_eq!(json, json!(["shift", "ctrl"])); + } + + #[test] + fn modifiers_all_serializes() { + let mods = Modifiers { + shift: true, + ctrl: true, + alt: true, + }; + let json = serde_json::to_value(&mods).unwrap_or_default(); + assert_eq!(json, json!(["shift", "ctrl", "alt"])); + } + + #[test] + fn modifiers_is_empty() { + assert!(Modifiers::default().is_empty()); + assert!(!Modifiers { + shift: true, + ctrl: false, + alt: false + } + .is_empty()); + } + + #[test] + fn modified_action_new_has_empty_modifiers() { + let ma = ModifiedAction::new(Action::Confirm); + assert!(ma.modifiers.is_empty()); + } + + #[test] + fn modified_action_with_modifiers() { + let mods = Modifiers { + shift: true, + ctrl: false, + alt: false, + }; + let ma = ModifiedAction::with_modifiers(Action::Confirm, mods); + assert!(ma.modifiers.shift); + assert!(!ma.modifiers.ctrl); + } } diff --git a/crates/pikl-core/src/model/output.rs b/crates/pikl-core/src/model/output.rs index 09dad29..cc6926f 100644 --- a/crates/pikl-core/src/model/output.rs +++ b/crates/pikl-core/src/model/output.rs @@ -6,6 +6,8 @@ use serde::Serialize; use serde::ser::SerializeMap; use serde_json::Value; +use crate::event::Modifiers; + /// What the user did to produce this output. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] @@ -25,27 +27,35 @@ pub struct OutputItem { pub value: Value, pub action: OutputAction, pub index: usize, + pub modifiers: Modifiers, } impl Serialize for OutputItem { fn serialize(&self, serializer: S) -> Result { + let has_mods = !self.modifiers.is_empty(); match &self.value { Value::Object(map) => { // Flatten: merge object fields with action/index - let mut s = serializer.serialize_map(Some(map.len() + 2))?; + let mut s = serializer.serialize_map(None)?; for (k, v) in map { s.serialize_entry(k, v)?; } s.serialize_entry("action", &self.action)?; s.serialize_entry("index", &self.index)?; + if has_mods { + s.serialize_entry("modifiers", &self.modifiers)?; + } s.end() } _ => { // Non-object: put value in a "value" field - let mut s = serializer.serialize_map(Some(3))?; + let mut s = serializer.serialize_map(None)?; s.serialize_entry("value", &self.value)?; s.serialize_entry("action", &self.action)?; s.serialize_entry("index", &self.index)?; + if has_mods { + s.serialize_entry("modifiers", &self.modifiers)?; + } s.end() } } @@ -63,6 +73,7 @@ mod tests { value: json!({"label": "Firefox", "url": "https://firefox.com"}), action: OutputAction::Select, index: 3, + modifiers: Modifiers::default(), }; let json = serde_json::to_value(&item).unwrap_or_default(); assert_eq!(json["label"], "Firefox"); @@ -77,6 +88,7 @@ mod tests { value: json!("hello"), action: OutputAction::Select, index: 0, + modifiers: Modifiers::default(), }; let json = serde_json::to_value(&item).unwrap_or_default(); assert_eq!(json["value"], "hello"); @@ -90,6 +102,7 @@ mod tests { value: json!(null), action: OutputAction::Cancel, index: 0, + modifiers: Modifiers::default(), }; let json = serde_json::to_value(&item).unwrap_or_default(); assert_eq!(json["action"], "cancel"); @@ -101,6 +114,7 @@ mod tests { value: json!("test"), action: OutputAction::Quicklist, index: 2, + modifiers: Modifiers::default(), }; let json = serde_json::to_value(&item).unwrap_or_default(); assert_eq!(json["action"], "quicklist"); @@ -113,6 +127,7 @@ mod tests { value: json!("alpha"), action: OutputAction::Select, index: 0, + modifiers: Modifiers::default(), }; let serialized = serde_json::to_string(&item).unwrap_or_default(); assert!( @@ -120,4 +135,61 @@ mod tests { "output should contain the value text: {serialized}" ); } + + #[test] + fn output_item_omits_empty_modifiers() { + let item = OutputItem { + value: json!("test"), + action: OutputAction::Select, + index: 0, + modifiers: Modifiers::default(), + }; + let json = serde_json::to_value(&item).unwrap_or_default(); + assert!(json.get("modifiers").is_none(), "empty modifiers should be omitted"); + } + + #[test] + fn output_item_includes_modifiers_when_present() { + let item = OutputItem { + value: json!("test"), + action: OutputAction::Select, + index: 0, + modifiers: Modifiers { + shift: true, + ctrl: false, + alt: false, + }, + }; + let json = serde_json::to_value(&item).unwrap_or_default(); + assert_eq!(json["modifiers"], json!(["shift"])); + } + + #[test] + fn output_item_object_with_modifiers() { + let item = OutputItem { + value: json!({"label": "Firefox"}), + action: OutputAction::Select, + index: 0, + modifiers: Modifiers { + shift: true, + ctrl: true, + alt: false, + }, + }; + let json = serde_json::to_value(&item).unwrap_or_default(); + assert_eq!(json["label"], "Firefox"); + assert_eq!(json["modifiers"], json!(["shift", "ctrl"])); + } + + #[test] + fn output_item_object_omits_empty_modifiers() { + let item = OutputItem { + value: json!({"label": "Firefox"}), + action: OutputAction::Select, + index: 0, + modifiers: Modifiers::default(), + }; + let json = serde_json::to_value(&item).unwrap_or_default(); + assert!(json.get("modifiers").is_none()); + } } diff --git a/crates/pikl-core/src/runtime/debounce.rs b/crates/pikl-core/src/runtime/debounce.rs index 412ac26..31d0b51 100644 --- a/crates/pikl-core/src/runtime/debounce.rs +++ b/crates/pikl-core/src/runtime/debounce.rs @@ -10,7 +10,7 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use tracing::Instrument; -use crate::event::Action; +use crate::event::{Action, ModifiedAction}; use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse}; /// Debounce mode for a hook event kind. @@ -30,13 +30,13 @@ pub enum DebounceMode { /// behavior. Each event kind can have its own mode. pub struct DebouncedDispatcher { handler: Arc, - _action_tx: mpsc::Sender, + _action_tx: mpsc::Sender, modes: HashMap, in_flight: HashMap>, } impl DebouncedDispatcher { - pub fn new(handler: Arc, action_tx: mpsc::Sender) -> Self { + pub fn new(handler: Arc, action_tx: mpsc::Sender) -> Self { Self { handler, _action_tx: action_tx, @@ -149,6 +149,7 @@ pub fn hook_response_to_action(resp: HookResponse) -> Action { mod tests { use super::*; use crate::error::PiklError; + use crate::event::Modifiers; use serde_json::json; use std::sync::Mutex; @@ -217,12 +218,15 @@ mod tests { // Rapid-fire filter events dispatcher.dispatch(HookEvent::Filter { text: "a".to_string(), + modifiers: Modifiers::default(), }); dispatcher.dispatch(HookEvent::Filter { text: "ab".to_string(), + modifiers: Modifiers::default(), }); dispatcher.dispatch(HookEvent::Filter { text: "abc".to_string(), + modifiers: Modifiers::default(), }); // Advance past debounce window. sleep(0) processes @@ -251,12 +255,14 @@ mod tests { dispatcher.dispatch(HookEvent::Hover { item: json!("a"), index: 0, + modifiers: Modifiers::default(), }); // Wait a bit, then send second hover which cancels first tokio::time::sleep(Duration::from_millis(50)).await; dispatcher.dispatch(HookEvent::Hover { item: json!("b"), index: 1, + modifiers: Modifiers::default(), }); // Advance past debounce for the second event diff --git a/crates/pikl-core/src/runtime/hook.rs b/crates/pikl-core/src/runtime/hook.rs index ad348a9..f4c5a0e 100644 --- a/crates/pikl-core/src/runtime/hook.rs +++ b/crates/pikl-core/src/runtime/hook.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::error::PiklError; +use crate::event::Modifiers; /// A lifecycle event emitted by the menu engine. Handler /// hooks receive these as JSON lines on stdin. The `event` @@ -16,11 +17,33 @@ use crate::error::PiklError; pub enum HookEvent { Open, Close, - Hover { item: Value, index: usize }, - Select { item: Value, index: usize }, - Cancel, - Filter { text: String }, - Quicklist { items: Vec, count: usize }, + Hover { + item: Value, + index: usize, + #[serde(skip_serializing_if = "Modifiers::is_empty")] + modifiers: Modifiers, + }, + Select { + item: Value, + index: usize, + #[serde(skip_serializing_if = "Modifiers::is_empty")] + modifiers: Modifiers, + }, + Cancel { + #[serde(skip_serializing_if = "Modifiers::is_empty")] + modifiers: Modifiers, + }, + Filter { + text: String, + #[serde(skip_serializing_if = "Modifiers::is_empty")] + modifiers: Modifiers, + }, + Quicklist { + items: Vec, + count: usize, + #[serde(skip_serializing_if = "Modifiers::is_empty")] + modifiers: Modifiers, + }, } /// Discriminant for [`HookEvent`], used as a key for @@ -44,7 +67,7 @@ impl HookEvent { HookEvent::Close => HookEventKind::Close, HookEvent::Hover { .. } => HookEventKind::Hover, HookEvent::Select { .. } => HookEventKind::Select, - HookEvent::Cancel => HookEventKind::Cancel, + HookEvent::Cancel { .. } => HookEventKind::Cancel, HookEvent::Filter { .. } => HookEventKind::Filter, HookEvent::Quicklist { .. } => HookEventKind::Quicklist, } @@ -116,11 +139,14 @@ mod tests { let event = HookEvent::Hover { item: json!({"label": "test"}), index: 5, + modifiers: Modifiers::default(), }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "hover"); assert_eq!(json["item"]["label"], "test"); assert_eq!(json["index"], 5); + // Empty modifiers should be omitted + assert!(json.get("modifiers").is_none()); } #[test] @@ -128,6 +154,7 @@ mod tests { let event = HookEvent::Select { item: json!("hello"), index: 0, + modifiers: Modifiers::default(), }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "select"); @@ -137,14 +164,19 @@ mod tests { #[test] fn event_cancel_serializes() { - let json = serde_json::to_value(&HookEvent::Cancel).unwrap_or_default(); + let json = serde_json::to_value(&HookEvent::Cancel { + modifiers: Modifiers::default(), + }) + .unwrap_or_default(); assert_eq!(json["event"], "cancel"); + assert!(json.get("modifiers").is_none()); } #[test] fn event_filter_serializes() { let event = HookEvent::Filter { text: "foo".to_string(), + modifiers: Modifiers::default(), }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "filter"); @@ -160,7 +192,8 @@ mod tests { assert_eq!( HookEvent::Hover { item: json!(null), - index: 0 + index: 0, + modifiers: Modifiers::default(), } .kind(), HookEventKind::Hover @@ -168,15 +201,23 @@ mod tests { assert_eq!( HookEvent::Select { item: json!(null), - index: 0 + index: 0, + modifiers: Modifiers::default(), } .kind(), HookEventKind::Select ); - assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel); + assert_eq!( + HookEvent::Cancel { + modifiers: Modifiers::default() + } + .kind(), + HookEventKind::Cancel + ); assert_eq!( HookEvent::Filter { - text: String::new() + text: String::new(), + modifiers: Modifiers::default(), } .kind(), HookEventKind::Filter @@ -291,6 +332,7 @@ mod tests { let event = HookEvent::Quicklist { items: vec![json!("alpha"), json!("beta")], count: 2, + modifiers: Modifiers::default(), }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "quicklist"); @@ -303,6 +345,7 @@ mod tests { let event = HookEvent::Quicklist { items: vec![], count: 0, + modifiers: Modifiers::default(), }; assert_eq!(event.kind(), HookEventKind::Quicklist); } @@ -314,6 +357,7 @@ mod tests { let event = HookEvent::Hover { item: json!({"label": "Firefox", "url": "https://firefox.com"}), index: 2, + modifiers: Modifiers::default(), }; let serialized = serde_json::to_string(&event).unwrap_or_default(); let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default(); @@ -321,4 +365,35 @@ mod tests { assert_eq!(parsed["item"]["label"], "Firefox"); assert_eq!(parsed["index"], 2); } + + // -- Modifier serialization in hook events -- + + #[test] + fn event_select_with_modifiers() { + let event = HookEvent::Select { + item: json!("test"), + index: 0, + modifiers: Modifiers { + shift: true, + ctrl: false, + alt: false, + }, + }; + let json = serde_json::to_value(&event).unwrap_or_default(); + assert_eq!(json["modifiers"], json!(["shift"])); + } + + #[test] + fn event_cancel_with_modifiers() { + let event = HookEvent::Cancel { + modifiers: Modifiers { + shift: true, + ctrl: true, + alt: false, + }, + }; + let json = serde_json::to_value(&event).unwrap_or_default(); + assert_eq!(json["event"], "cancel"); + assert_eq!(json["modifiers"], json!(["shift", "ctrl"])); + } } diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs index f316ae6..445dc8b 100644 --- a/crates/pikl-core/src/runtime/menu.rs +++ b/crates/pikl-core/src/runtime/menu.rs @@ -11,7 +11,10 @@ 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::event::{ + Action, ColumnHeader, MenuEvent, MenuResult, ModifiedAction, Modifiers, Mode, ViewState, + VisibleItem, +}; use crate::hook::{HookEvent, HookHandler}; use crate::model::traits::MutableMenu; use crate::navigation::Viewport; @@ -147,7 +150,7 @@ pub struct MenuRunner { viewport: Viewport, filter_text: Arc, mode: Mode, - action_rx: mpsc::Receiver, + action_rx: mpsc::Receiver, event_tx: broadcast::Sender, dispatcher: Option, previous_cursor: Option, @@ -155,6 +158,7 @@ pub struct MenuRunner { selection: SelectionState, visual_anchor: Option, streaming: bool, + last_modifiers: Modifiers, } impl MenuRunner { @@ -162,7 +166,7 @@ impl MenuRunner { /// 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) { + pub fn new(menu: M) -> (Self, mpsc::Sender) { 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 @@ -182,6 +186,7 @@ impl MenuRunner { selection: SelectionState::new(), visual_anchor: None, streaming: false, + last_modifiers: Modifiers::default(), }; (runner, action_tx) } @@ -204,7 +209,7 @@ impl MenuRunner { pub fn set_hook_handler( &mut self, handler: Arc, - action_tx: mpsc::Sender, + action_tx: mpsc::Sender, ) { let dispatcher = DebouncedDispatcher::new(handler, action_tx); self.dispatcher = Some(dispatcher); @@ -357,6 +362,7 @@ impl MenuRunner { self.emit_hook(HookEvent::Hover { item: value, index: orig_idx, + modifiers: self.last_modifiers, }); } } @@ -604,7 +610,9 @@ impl MenuRunner { // Emit Open event self.emit_hook(HookEvent::Open); - while let Some(action) = self.action_rx.recv().await { + while let Some(modified) = self.action_rx.recv().await { + let ModifiedAction { action, modifiers } = modified; + self.last_modifiers = modifiers; let is_filter_update = matches!(&action, Action::UpdateFilter(_)); match self.apply_action(action) { @@ -614,7 +622,10 @@ impl MenuRunner { // Emit Filter event if the filter changed if is_filter_update { let text = self.filter_text.to_string(); - self.emit_hook(HookEvent::Filter { text }); + self.emit_hook(HookEvent::Filter { + text, + modifiers: self.last_modifiers, + }); } // Check for cursor movement -> Hover @@ -628,6 +639,7 @@ impl MenuRunner { self.emit_hook(HookEvent::Select { item: value.clone(), index: *index, + modifiers: self.last_modifiers, }); } // Emit Close event @@ -637,6 +649,7 @@ impl MenuRunner { return Ok(MenuResult::Selected { items, filter_text: self.filter_text.to_string(), + modifiers: self.last_modifiers, }); } ActionOutcome::Quicklist { items } => { @@ -646,6 +659,7 @@ impl MenuRunner { self.emit_hook(HookEvent::Quicklist { items: values.clone(), count, + modifiers: self.last_modifiers, }); self.emit_hook(HookEvent::Close); @@ -653,22 +667,29 @@ impl MenuRunner { return Ok(MenuResult::Quicklist { items, filter_text: self.filter_text.to_string(), + modifiers: self.last_modifiers, }); } ActionOutcome::Cancelled => { info!("menu cancelled"); - self.emit_hook(HookEvent::Cancel); + self.emit_hook(HookEvent::Cancel { + modifiers: self.last_modifiers, + }); self.emit_hook(HookEvent::Close); let _ = self.event_tx.send(MenuEvent::Cancelled); - return Ok(MenuResult::Cancelled); + return Ok(MenuResult::Cancelled { + modifiers: self.last_modifiers, + }); } ActionOutcome::Closed => { info!("menu closed by hook"); self.emit_hook(HookEvent::Close); let _ = self.event_tx.send(MenuEvent::Cancelled); - return Ok(MenuResult::Cancelled); + return Ok(MenuResult::Cancelled { + modifiers: Modifiers::default(), + }); } ActionOutcome::NoOp => {} } @@ -676,7 +697,9 @@ impl MenuRunner { // Sender dropped self.emit_hook(HookEvent::Close); - Ok(MenuResult::Cancelled) + Ok(MenuResult::Cancelled { + modifiers: Modifiers::default(), + }) } } @@ -688,7 +711,7 @@ mod tests { use crate::model::traits::Menu; use crate::runtime::json_menu::JsonMenu; - fn test_menu() -> (MenuRunner, mpsc::Sender) { + fn test_menu() -> (MenuRunner, mpsc::Sender) { let items = vec![ Item::from_plain_text("alpha"), Item::from_plain_text("beta"), @@ -818,9 +841,9 @@ mod tests { } // Cancel to exit - let _ = tx.send(Action::Cancel).await; - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Cancelled))); + let _ = tx.send(ModifiedAction::new(Action::Cancel)).await; + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); + assert!(matches!(result, Ok(MenuResult::Cancelled { .. }))); } #[tokio::test] @@ -834,7 +857,7 @@ mod tests { let _ = rx.recv().await; // Filter to "al" - let _ = tx.send(Action::UpdateFilter("al".to_string())).await; + let _ = tx.send(ModifiedAction::new(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" @@ -842,7 +865,7 @@ mod tests { assert_eq!(&*vs.filter_text, "al"); } - let _ = tx.send(Action::Cancel).await; + let _ = tx.send(ModifiedAction::new(Action::Cancel)).await; let _ = handle.await; } @@ -857,20 +880,20 @@ mod tests { let _ = rx.recv().await; // Need to send resize first so viewport has height - let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await; let _ = rx.recv().await; // Move down and confirm - let _ = tx.send(Action::MoveDown(1)).await; + let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await; let _ = rx.recv().await; - let _ = tx.send(Action::Confirm).await; + let _ = tx.send(ModifiedAction::new(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)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); assert!(matches!(result, Ok(MenuResult::Selected { .. }))); } @@ -884,18 +907,18 @@ mod tests { let _ = rx.recv().await; // initial // Filter to something that matches nothing - let _ = tx.send(Action::UpdateFilter("zzzzz".to_string())).await; + let _ = tx.send(ModifiedAction::new(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; + let _ = tx.send(ModifiedAction::new(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))); + let _ = tx.send(ModifiedAction::new(Action::Cancel)).await; + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); + assert!(matches!(result, Ok(MenuResult::Cancelled { .. }))); } #[tokio::test] @@ -908,8 +931,8 @@ mod tests { // Drop the only sender. drop(tx); - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Cancelled))); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); + assert!(matches!(result, Ok(MenuResult::Cancelled { .. }))); } // -- End-to-end output correctness -- @@ -922,18 +945,18 @@ mod tests { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial state - let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await; let _ = rx.recv().await; // Confirm at cursor 0, should get "alpha" - let _ = tx.send(Action::Confirm).await; + let _ = tx.send(ModifiedAction::new(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)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); assert!( matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha")) ); @@ -947,17 +970,17 @@ mod tests { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial - let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = tx.send(ModifiedAction::new(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 _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await; let _ = rx.recv().await; - let _ = tx.send(Action::MoveDown(1)).await; + let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await; let _ = rx.recv().await; - let _ = tx.send(Action::Confirm).await; + let _ = tx.send(ModifiedAction::new(Action::Confirm)).await; - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); assert!( matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma")) ); @@ -971,20 +994,20 @@ mod tests { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial - let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await; let _ = rx.recv().await; // Filter to "del", should match "delta" - let _ = tx.send(Action::UpdateFilter("del".to_string())).await; + let _ = tx.send(ModifiedAction::new(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 _ = tx.send(ModifiedAction::new(Action::Confirm)).await; - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); assert!( matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")) ); @@ -998,14 +1021,14 @@ mod tests { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial - let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = tx.send(ModifiedAction::new(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(), - )])) + .send(ModifiedAction::new(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); @@ -1013,15 +1036,15 @@ mod tests { } // Filter to "eps", only epsilon should match - let _ = tx.send(Action::UpdateFilter("eps".to_string())).await; + let _ = tx.send(ModifiedAction::new(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 _ = tx.send(ModifiedAction::new(Action::Confirm)).await; - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); assert!( matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon")) ); @@ -1036,13 +1059,13 @@ mod tests { let _ = rx.recv().await; // initial - let _ = tx.send(Action::Cancel).await; + let _ = tx.send(ModifiedAction::new(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))); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); + assert!(matches!(result, Ok(MenuResult::Cancelled { .. }))); } // -- Ordering invariant tests -- @@ -1065,14 +1088,14 @@ mod tests { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial - let _ = tx.send(Action::Resize { height: 50 }).await; + let _ = tx.send(ModifiedAction::new(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 _ = tx.send(ModifiedAction::new(Action::UpdateFilter("ban".to_string()))).await; + let _ = tx.send(ModifiedAction::new(Action::Confirm)).await; - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); // Must get "banana". Filter was applied before confirm ran. assert!(matches!( result, @@ -1089,16 +1112,16 @@ mod tests { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial - let _ = tx.send(Action::Resize { height: 50 }).await; + let _ = tx.send(ModifiedAction::new(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 _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await; + let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await; + let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await; + let _ = tx.send(ModifiedAction::new(Action::Confirm)).await; - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); // Cursor at index 3 -> "delta" assert!(matches!( result, @@ -1182,19 +1205,19 @@ mod tests { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial - let _ = tx.send(Action::Resize { height: 50 }).await; + let _ = tx.send(ModifiedAction::new(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(), - )])) + .send(ModifiedAction::new(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 _ = tx.send(ModifiedAction::new(Action::UpdateFilter("zep".to_string()))).await; + let _ = tx.send(ModifiedAction::new(Action::Confirm)).await; - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); // Must find "zephyr". It was added before the filter ran. assert!(matches!( result, @@ -1353,10 +1376,10 @@ mod tests { // Skip initial state let _ = rx.recv().await; - let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await; let _ = rx.recv().await; - let _ = tx.send(Action::Quicklist).await; + let _ = tx.send(ModifiedAction::new(Action::Quicklist)).await; if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await { assert_eq!(values.len(), 4); @@ -1364,7 +1387,7 @@ mod tests { panic!("expected Quicklist event"); } - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); assert!(matches!(result, Ok(MenuResult::Quicklist { .. }))); } @@ -1376,14 +1399,14 @@ mod tests { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial - let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = tx.send(ModifiedAction::new(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 _ = tx.send(ModifiedAction::new(Action::UpdateFilter("al".to_string()))).await; + let _ = tx.send(ModifiedAction::new(Action::Quicklist)).await; - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() })); match result { Ok(MenuResult::Quicklist { items, .. }) => { // "alpha" matches "al" @@ -1429,6 +1452,7 @@ mod tests { m.apply_action(Action::UpdateFilter("al".to_string())); m.emit_hook(HookEvent::Filter { text: "al".to_string(), + modifiers: Modifiers::default(), }); m.apply_action(Action::MoveDown(1)); m.check_cursor_hover(); @@ -1442,6 +1466,142 @@ mod tests { assert!(events.contains(&HookEventKind::Filter)); } + // -- Modifier flow-through tests -- + + #[tokio::test] + async fn modifiers_flow_through_to_selected_result() { + 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(ModifiedAction::new(Action::Resize { height: 10 })) + .await; + let _ = rx.recv().await; + + // Confirm with shift held + let shift = Modifiers { + shift: true, + ctrl: false, + alt: false, + }; + let _ = tx + .send(ModifiedAction::with_modifiers(Action::Confirm, shift)) + .await; + + let result = handle + .await + .unwrap_or(Ok(MenuResult::Cancelled { + modifiers: Modifiers::default(), + })); + match result { + Ok(MenuResult::Selected { modifiers, .. }) => { + assert!(modifiers.shift, "shift should be set on result"); + assert!(!modifiers.ctrl); + assert!(!modifiers.alt); + } + other => panic!("expected Selected with shift, got {other:?}"), + } + } + + #[tokio::test] + async fn modifiers_flow_through_to_cancelled_result() { + let (menu, tx) = test_menu(); + let _rx = menu.subscribe(); + let handle = tokio::spawn(async move { menu.run().await }); + + let mods = Modifiers { + shift: false, + ctrl: true, + alt: true, + }; + let _ = tx + .send(ModifiedAction::with_modifiers(Action::Cancel, mods)) + .await; + + let result = handle + .await + .unwrap_or(Ok(MenuResult::Cancelled { + modifiers: Modifiers::default(), + })); + match result { + Ok(MenuResult::Cancelled { modifiers }) => { + assert!(!modifiers.shift); + assert!(modifiers.ctrl, "ctrl should be set"); + assert!(modifiers.alt, "alt should be set"); + } + other => panic!("expected Cancelled with ctrl+alt, got {other:?}"), + } + } + + #[tokio::test] + async fn no_modifiers_result_has_empty_modifiers() { + 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(ModifiedAction::new(Action::Resize { height: 10 })) + .await; + let _ = rx.recv().await; + + // Confirm without modifiers + let _ = tx.send(ModifiedAction::new(Action::Confirm)).await; + + let result = handle + .await + .unwrap_or(Ok(MenuResult::Cancelled { + modifiers: Modifiers::default(), + })); + match result { + Ok(MenuResult::Selected { modifiers, .. }) => { + assert!( + modifiers.is_empty(), + "modifiers should be empty when none held" + ); + } + other => panic!("expected Selected with empty mods, got {other:?}"), + } + } + + #[tokio::test] + async fn quicklist_carries_modifiers() { + 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(ModifiedAction::new(Action::Resize { height: 10 })) + .await; + let _ = rx.recv().await; + + let mods = Modifiers { + shift: true, + ctrl: false, + alt: true, + }; + let _ = tx + .send(ModifiedAction::with_modifiers(Action::Quicklist, mods)) + .await; + + let result = handle + .await + .unwrap_or(Ok(MenuResult::Cancelled { + modifiers: Modifiers::default(), + })); + match result { + Ok(MenuResult::Quicklist { modifiers, .. }) => { + assert!(modifiers.shift); + assert!(!modifiers.ctrl); + assert!(modifiers.alt); + } + other => panic!("expected Quicklist with shift+alt, got {other:?}"), + } + } + // -- SelectionState unit tests -- #[test] diff --git a/crates/pikl-core/src/script/action_fd/mod.rs b/crates/pikl-core/src/script/action_fd/mod.rs index 189a1e3..ec23bb0 100644 --- a/crates/pikl-core/src/script/action_fd/mod.rs +++ b/crates/pikl-core/src/script/action_fd/mod.rs @@ -14,7 +14,7 @@ pub mod parse; use tokio::sync::mpsc; use crate::error::PiklError; -use crate::event::Action; +use crate::event::{Action, ModifiedAction, Modifiers}; pub use error::{ScriptError, ScriptErrorKind}; pub use parse::{load_script, parse_action}; @@ -22,7 +22,7 @@ pub use parse::{load_script, parse_action}; /// A parsed action from an action-fd script. #[derive(Debug, Clone, PartialEq)] pub enum ScriptAction { - Core(Action), + Core(Action, Modifiers), ShowUi, ShowTui, ShowGui, @@ -41,14 +41,14 @@ pub enum ShowAction { /// Returns the Show* variant if the script ends with one, or None. pub async fn run_script( script: Vec, - tx: &mpsc::Sender, + tx: &mpsc::Sender, ) -> Result, PiklError> { let mut show = None; for action in script { match action { - ScriptAction::Core(action) => { - tx.send(action) + ScriptAction::Core(action, modifiers) => { + tx.send(ModifiedAction { action, modifiers }) .await .map_err(|_| PiklError::ChannelClosed)?; } @@ -69,9 +69,9 @@ mod tests { #[tokio::test] async fn run_script_sends_actions_in_order() { let script = vec![ - ScriptAction::Core(Action::UpdateFilter("hello".to_string())), - ScriptAction::Core(Action::MoveDown(1)), - ScriptAction::Core(Action::Confirm), + ScriptAction::Core(Action::UpdateFilter("hello".to_string()), Modifiers::default()), + ScriptAction::Core(Action::MoveDown(1), Modifiers::default()), + ScriptAction::Core(Action::Confirm, Modifiers::default()), ]; let (tx, mut rx) = mpsc::channel(16); let result = run_script(script, &tx).await; @@ -79,14 +79,14 @@ mod tests { assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None); // Verify order - assert!(matches!(rx.recv().await, Some(Action::UpdateFilter(s)) if s == "hello")); - assert!(matches!(rx.recv().await, Some(Action::MoveDown(1)))); - assert!(matches!(rx.recv().await, Some(Action::Confirm))); + assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::UpdateFilter(s), .. }) if s == "hello")); + assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::MoveDown(1), .. }))); + assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::Confirm, .. }))); } #[tokio::test] async fn run_script_returns_none_without_show() { - let script = vec![ScriptAction::Core(Action::Confirm)]; + let script = vec![ScriptAction::Core(Action::Confirm, Modifiers::default())]; let (tx, _rx) = mpsc::channel(16); let result = run_script(script, &tx).await; assert!(result.is_ok()); @@ -96,7 +96,7 @@ mod tests { #[tokio::test] async fn run_script_returns_show_action() { let script = vec![ - ScriptAction::Core(Action::UpdateFilter("test".to_string())), + ScriptAction::Core(Action::UpdateFilter("test".to_string()), Modifiers::default()), ScriptAction::ShowUi, ]; let (tx, _rx) = mpsc::channel(16); diff --git a/crates/pikl-core/src/script/action_fd/parse.rs b/crates/pikl-core/src/script/action_fd/parse.rs index 62c98dd..778e114 100644 --- a/crates/pikl-core/src/script/action_fd/parse.rs +++ b/crates/pikl-core/src/script/action_fd/parse.rs @@ -4,7 +4,7 @@ use std::io::BufRead; -use crate::event::{Action, Mode}; +use crate::event::{Action, Mode, Modifiers}; use super::ScriptAction; use super::error::{ScriptError, ScriptErrorKind}; @@ -82,6 +82,30 @@ fn parse_positive_u16( } } +/// Parse modifier prefixes from the start of a trimmed line. +/// Returns (modifiers, remaining_line). +fn parse_modifier_prefix(line: &str) -> Result<(Modifiers, &str), Option> { + let mut mods = Modifiers::default(); + let mut rest = line; + + while let Some(stripped) = rest.strip_prefix('+') { + // Find the end of this modifier token (next space or '+') + let end = stripped + .find([' ', '+']) + .unwrap_or(stripped.len()); + let token = &stripped[..end]; + match token { + "shift" => mods.shift = true, + "ctrl" => mods.ctrl = true, + "alt" => mods.alt = true, + other => return Err(Some(format!("unknown modifier '+{other}'"))), + } + rest = stripped[end..].trim_start(); + } + + Ok((mods, rest)) +} + /// Parse a single line into a ScriptAction. pub fn parse_action(line_number: usize, line: &str) -> Result { let trimmed = line.trim(); @@ -90,41 +114,66 @@ pub fn parse_action(line_number: usize, line: &str) -> Result (c, Some(a)), - None => (trimmed, None), + None => (rest, None), + }; + + let core = |action: Action| -> Result { + Ok(ScriptAction::Core(action, modifiers)) }; match cmd { "filter" => { let text = arg.unwrap_or(""); - Ok(ScriptAction::Core(Action::UpdateFilter(text.to_string()))) + core(Action::UpdateFilter(text.to_string())) } "move-up" => { let n = parse_count(line_number, line, "move-up", arg)?; - Ok(ScriptAction::Core(Action::MoveUp(n))) + core(Action::MoveUp(n)) } "move-down" => { let n = parse_count(line_number, line, "move-down", arg)?; - Ok(ScriptAction::Core(Action::MoveDown(n))) + core(Action::MoveDown(n)) } - "move-to-top" => Ok(ScriptAction::Core(Action::MoveToTop)), - "move-to-bottom" => Ok(ScriptAction::Core(Action::MoveToBottom)), + "move-to-top" => core(Action::MoveToTop), + "move-to-bottom" => core(Action::MoveToBottom), "page-up" => { let n = parse_count(line_number, line, "page-up", arg)?; - Ok(ScriptAction::Core(Action::PageUp(n))) + core(Action::PageUp(n)) } "page-down" => { let n = parse_count(line_number, line, "page-down", arg)?; - Ok(ScriptAction::Core(Action::PageDown(n))) + core(Action::PageDown(n)) } "half-page-up" => { let n = parse_count(line_number, line, "half-page-up", arg)?; - Ok(ScriptAction::Core(Action::HalfPageUp(n))) + core(Action::HalfPageUp(n)) } "half-page-down" => { let n = parse_count(line_number, line, "half-page-down", arg)?; - Ok(ScriptAction::Core(Action::HalfPageDown(n))) + core(Action::HalfPageDown(n)) } "set-mode" => { let Some(mode_str) = arg else { @@ -136,9 +185,9 @@ pub fn parse_action(line_number: usize, line: &str) -> Result Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))), - "normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))), - "visual" => Ok(ScriptAction::Core(Action::SetMode(Mode::Visual))), + "insert" => core(Action::SetMode(Mode::Insert)), + "normal" => core(Action::SetMode(Mode::Normal)), + "visual" => core(Action::SetMode(Mode::Visual)), other => Err(invalid_arg( line_number, line, @@ -147,21 +196,21 @@ pub fn parse_action(line_number: usize, line: &str) -> Result Ok(ScriptAction::Core(Action::ToggleSelect)), - "select-all" => Ok(ScriptAction::Core(Action::SelectAll)), - "deselect-all" => Ok(ScriptAction::Core(Action::ClearSelections)), - "undo-selection" => Ok(ScriptAction::Core(Action::UndoSelection)), - "redo-selection" => Ok(ScriptAction::Core(Action::RedoSelection)), - "confirm" => Ok(ScriptAction::Core(Action::Confirm)), - "cancel" => Ok(ScriptAction::Core(Action::Cancel)), + "toggle-select" => core(Action::ToggleSelect), + "select-all" => core(Action::SelectAll), + "deselect-all" => core(Action::ClearSelections), + "undo-selection" => core(Action::UndoSelection), + "redo-selection" => core(Action::RedoSelection), + "confirm" => core(Action::Confirm), + "cancel" => core(Action::Cancel), "resize" => { let height = parse_positive_u16(line_number, line, "resize", arg)?; - Ok(ScriptAction::Core(Action::Resize { height })) + core(Action::Resize { height }) } "show-ui" => Ok(ScriptAction::ShowUi), "show-tui" => Ok(ScriptAction::ShowTui), "show-gui" => Ok(ScriptAction::ShowGui), - "streaming-done" => Ok(ScriptAction::Core(Action::StreamingDone)), + "streaming-done" => core(Action::StreamingDone), _ => Err(ScriptError { line: line_number, source_line: line.to_string(), @@ -244,7 +293,7 @@ mod tests { assert!(result.is_ok()); assert_eq!( result.unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::UpdateFilter("hello world".to_string())) + ScriptAction::Core(Action::UpdateFilter("hello world".to_string()), Modifiers::default()) ); } @@ -254,7 +303,7 @@ mod tests { assert!(result.is_ok()); assert_eq!( result.unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::UpdateFilter(String::new())) + ScriptAction::Core(Action::UpdateFilter(String::new()), Modifiers::default()) ); } @@ -262,27 +311,27 @@ mod tests { fn parse_movement_actions() { assert_eq!( parse_action(1, "move-up").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::MoveUp(1)) + ScriptAction::Core(Action::MoveUp(1), Modifiers::default()) ); assert_eq!( parse_action(1, "move-down").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::MoveDown(1)) + ScriptAction::Core(Action::MoveDown(1), Modifiers::default()) ); assert_eq!( parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::MoveToTop) + ScriptAction::Core(Action::MoveToTop, Modifiers::default()) ); assert_eq!( parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::MoveToBottom) + ScriptAction::Core(Action::MoveToBottom, Modifiers::default()) ); assert_eq!( parse_action(1, "page-up").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::PageUp(1)) + ScriptAction::Core(Action::PageUp(1), Modifiers::default()) ); assert_eq!( parse_action(1, "page-down").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::PageDown(1)) + ScriptAction::Core(Action::PageDown(1), Modifiers::default()) ); } @@ -290,19 +339,19 @@ mod tests { fn parse_movement_with_count() { assert_eq!( parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::MoveUp(5)) + ScriptAction::Core(Action::MoveUp(5), Modifiers::default()) ); assert_eq!( parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::MoveDown(3)) + ScriptAction::Core(Action::MoveDown(3), Modifiers::default()) ); assert_eq!( parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::PageUp(2)) + ScriptAction::Core(Action::PageUp(2), Modifiers::default()) ); assert_eq!( parse_action(1, "page-down 10").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::PageDown(10)) + ScriptAction::Core(Action::PageDown(10), Modifiers::default()) ); } @@ -324,11 +373,11 @@ mod tests { fn parse_confirm_cancel() { assert_eq!( parse_action(1, "confirm").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::Confirm) + ScriptAction::Core(Action::Confirm, Modifiers::default()) ); assert_eq!( parse_action(1, "cancel").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::Cancel) + ScriptAction::Core(Action::Cancel, Modifiers::default()) ); } @@ -336,7 +385,7 @@ mod tests { fn parse_resize_valid() { assert_eq!( parse_action(1, "resize 25").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::Resize { height: 25 }) + ScriptAction::Core(Action::Resize { height: 25 }, Modifiers::default()) ); } @@ -405,19 +454,19 @@ mod tests { fn parse_half_page_actions() { assert_eq!( parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::HalfPageUp(1)) + ScriptAction::Core(Action::HalfPageUp(1), Modifiers::default()) ); assert_eq!( parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::HalfPageDown(1)) + ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default()) ); assert_eq!( parse_action(1, "half-page-up 3").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::HalfPageUp(3)) + ScriptAction::Core(Action::HalfPageUp(3), Modifiers::default()) ); assert_eq!( parse_action(1, "half-page-down 2").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::HalfPageDown(2)) + ScriptAction::Core(Action::HalfPageDown(2), Modifiers::default()) ); } @@ -431,11 +480,11 @@ mod tests { fn parse_set_mode() { assert_eq!( parse_action(1, "set-mode insert").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::SetMode(Mode::Insert)) + ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default()) ); assert_eq!( parse_action(1, "set-mode normal").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::SetMode(Mode::Normal)) + ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default()) ); } @@ -448,7 +497,7 @@ mod tests { fn parse_set_mode_invalid() { assert_eq!( parse_action(1, "set-mode visual").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::SetMode(Mode::Visual)) + ScriptAction::Core(Action::SetMode(Mode::Visual), Modifiers::default()) ); } @@ -519,7 +568,7 @@ mod tests { ParsedLine { line_number: 1, source: "filter test".into(), - action: ScriptAction::Core(Action::UpdateFilter("test".into())), + action: ScriptAction::Core(Action::UpdateFilter("test".into()), Modifiers::default()), }, ParsedLine { line_number: 2, @@ -541,7 +590,7 @@ mod tests { ParsedLine { line_number: 2, source: "confirm".into(), - action: ScriptAction::Core(Action::Confirm), + action: ScriptAction::Core(Action::Confirm, Modifiers::default()), }, ]; let err = validate_show_last(&actions).unwrap_err(); @@ -555,7 +604,7 @@ mod tests { ParsedLine { line_number: 1, source: "filter x".into(), - action: ScriptAction::Core(Action::UpdateFilter("x".into())), + action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()), }, ParsedLine { line_number: 2, @@ -565,7 +614,7 @@ mod tests { ParsedLine { line_number: 3, source: "confirm".into(), - action: ScriptAction::Core(Action::Confirm), + action: ScriptAction::Core(Action::Confirm, Modifiers::default()), }, ]; let err = validate_show_last(&actions).unwrap_err(); @@ -589,12 +638,12 @@ mod tests { ParsedLine { line_number: 1, source: "filter x".into(), - action: ScriptAction::Core(Action::UpdateFilter("x".into())), + action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()), }, ParsedLine { line_number: 2, source: "confirm".into(), - action: ScriptAction::Core(Action::Confirm), + action: ScriptAction::Core(Action::Confirm, Modifiers::default()), }, ]; assert!(validate_show_last(&actions).is_ok()); @@ -659,23 +708,23 @@ mod tests { fn parse_selection_actions() { assert_eq!( parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::ToggleSelect) + ScriptAction::Core(Action::ToggleSelect, Modifiers::default()) ); assert_eq!( parse_action(1, "select-all").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::SelectAll) + ScriptAction::Core(Action::SelectAll, Modifiers::default()) ); assert_eq!( parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::ClearSelections) + ScriptAction::Core(Action::ClearSelections, Modifiers::default()) ); assert_eq!( parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::UndoSelection) + ScriptAction::Core(Action::UndoSelection, Modifiers::default()) ); assert_eq!( parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::RedoSelection) + ScriptAction::Core(Action::RedoSelection, Modifiers::default()) ); } @@ -683,7 +732,7 @@ mod tests { fn parse_streaming_done() { assert_eq!( parse_action(1, "streaming-done").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::StreamingDone) + ScriptAction::Core(Action::StreamingDone, Modifiers::default()) ); } @@ -699,11 +748,11 @@ mod tests { // Extra whitespace around the mode value should be trimmed assert_eq!( parse_action(1, "set-mode insert ").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::SetMode(Mode::Insert)) + ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default()) ); assert_eq!( parse_action(1, "set-mode normal ").unwrap_or(ScriptAction::Comment), - ScriptAction::Core(Action::SetMode(Mode::Normal)) + ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default()) ); } @@ -714,16 +763,96 @@ mod tests { assert!(result.is_ok()); let actions = result.unwrap_or_default(); assert_eq!(actions.len(), 5); - assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2))); + assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2), Modifiers::default())); assert_eq!( actions[1], - ScriptAction::Core(Action::SetMode(Mode::Normal)) + ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default()) ); assert_eq!( actions[2], - ScriptAction::Core(Action::SetMode(Mode::Insert)) + ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default()) ); - assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1))); - assert_eq!(actions[4], ScriptAction::Core(Action::Confirm)); + assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default())); + assert_eq!(actions[4], ScriptAction::Core(Action::Confirm, Modifiers::default())); + } + + // -- Modifier prefix tests -- + + #[test] + fn parse_shift_prefix() { + let result = parse_action(1, "+shift confirm"); + assert!(result.is_ok()); + let expected_mods = Modifiers { + shift: true, + ctrl: false, + alt: false, + }; + assert_eq!( + result.unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::Confirm, expected_mods) + ); + } + + #[test] + fn parse_multiple_modifier_prefixes() { + let result = parse_action(1, "+ctrl+alt move-down 3"); + assert!(result.is_ok()); + let expected_mods = Modifiers { + shift: false, + ctrl: true, + alt: true, + }; + assert_eq!( + result.unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::MoveDown(3), expected_mods) + ); + } + + #[test] + fn parse_all_modifier_prefixes() { + let result = parse_action(1, "+shift+ctrl+alt cancel"); + assert!(result.is_ok()); + let expected_mods = Modifiers { + shift: true, + ctrl: true, + alt: true, + }; + assert_eq!( + result.unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::Cancel, expected_mods) + ); + } + + #[test] + fn parse_unknown_modifier_is_error() { + let result = parse_action(1, "+super confirm"); + assert!(result.is_err()); + } + + #[test] + fn parse_modifier_without_action_is_error() { + let result = parse_action(1, "+shift"); + assert!(result.is_err()); + } + + #[test] + fn parse_no_modifier_prefix_has_empty_modifiers() { + let result = parse_action(1, "confirm"); + assert!(result.is_ok()); + assert_eq!( + result.unwrap_or(ScriptAction::Comment), + ScriptAction::Core(Action::Confirm, Modifiers::default()) + ); + } + + #[test] + fn parse_modifier_with_space_separated() { + // "+shift confirm" should work + let result = parse_action(1, "+shift confirm"); + assert!(result.is_ok()); + if let Ok(ScriptAction::Core(_, mods)) = result { + assert!(mods.shift); + assert!(!mods.ctrl); + } } } diff --git a/crates/pikl-gui/src/lib.rs b/crates/pikl-gui/src/lib.rs index 2b7350e..a235289 100644 --- a/crates/pikl-gui/src/lib.rs +++ b/crates/pikl-gui/src/lib.rs @@ -15,7 +15,9 @@ use iced_layershell::settings::{LayerShellSettings, Settings}; use iced_layershell::to_layer_message; use tokio::sync::{broadcast, mpsc}; -use pikl_core::event::{Action, MenuEvent, Mode, ViewState, VisibleItem}; +use pikl_core::event::{ + Action, MenuEvent, ModifiedAction, Modifiers as PiklModifiers, Mode, ViewState, VisibleItem, +}; /// Number of visible items in the list viewport. const VIEWPORT_HEIGHT: u16 = 20; @@ -52,12 +54,12 @@ static BRIDGE_RX: OnceLock>>> = OnceLock::n /// Global slot for the action sender. Moved into the Pikl /// struct during boot (called exactly once by iced). -static ACTION_TX: OnceLock>>> = OnceLock::new(); +static ACTION_TX: OnceLock>>> = OnceLock::new(); /// Top-level GUI state. struct Pikl { /// Channel to send actions into the core engine. - action_tx: mpsc::Sender, + action_tx: mpsc::Sender, /// Current view snapshot from core. view_state: Option, /// Local copy of filter text for the text input widget. @@ -82,13 +84,13 @@ struct Pikl { /// runs the iced event loop, and exits when the user /// confirms or cancels. pub fn run( - action_tx: mpsc::Sender, + action_tx: mpsc::Sender, event_rx: broadcast::Receiver, ) -> Result<(), iced_layershell::Error> { // Send initial resize to core so it knows our viewport. - let _ = action_tx.blocking_send(Action::Resize { + let _ = action_tx.blocking_send(ModifiedAction::new(Action::Resize { height: VIEWPORT_HEIGHT, - }); + })); // Bridge: spawn a thread that reads core events and // forwards them as Messages through an mpsc channel. @@ -109,7 +111,7 @@ pub fn run( .get() .and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take()) .unwrap_or_else(|| { - let (tx, _) = mpsc::channel(1); + let (tx, _) = mpsc::channel::(1); tx }); let pikl = Pikl { @@ -228,7 +230,7 @@ fn update(state: &mut Pikl, message: Message) -> Task { let tx = state.action_tx.clone(); Task::perform( async move { - let _ = tx.send(Action::UpdateFilter(new_text)).await; + let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(new_text))).await; }, |_| Message::Sent, ) @@ -415,6 +417,15 @@ fn core_event_stream() -> impl iced::futures::Stream { }) } +/// Convert iced keyboard modifiers to pikl Modifiers. +fn iced_modifiers(m: &iced::keyboard::Modifiers) -> PiklModifiers { + PiklModifiers { + shift: m.shift(), + ctrl: m.control(), + alt: m.alt(), + } +} + /// Handle a keyboard event and return the appropriate task. fn handle_key(state: &mut Pikl, key: Key, modifiers: Modifiers) -> Task { let action = match state.mode { @@ -443,9 +454,10 @@ fn handle_key(state: &mut Pikl, key: Key, modifiers: Modifiers) -> Task }; let tx = state.action_tx.clone(); + let mods = iced_modifiers(&modifiers); let send_task = Task::perform( async move { - let _ = tx.send(action).await; + let _ = tx.send(ModifiedAction::with_modifiers(action, mods)).await; }, |_| Message::Sent, ); @@ -470,9 +482,10 @@ fn map_insert_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option< Key::Named(Named::PageDown) => Some(Action::PageDown(1)), Key::Named(Named::Tab) if state.multi_enabled => { let tx = state.action_tx.clone(); + let mods = iced_modifiers(modifiers); tokio::spawn(async move { - let _ = tx.send(Action::ToggleSelect).await; - let _ = tx.send(Action::MoveDown(1)).await; + let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await; + let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await; }); None } @@ -515,9 +528,10 @@ fn map_normal_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option< "u" => Some(Action::UndoSelection), " " if state.multi_enabled => { let tx = state.action_tx.clone(); + let mods = iced_modifiers(modifiers); tokio::spawn(async move { - let _ = tx.send(Action::ToggleSelect).await; - let _ = tx.send(Action::MoveDown(1)).await; + let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await; + let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await; }); None } @@ -539,9 +553,10 @@ fn map_normal_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option< Key::Named(Named::ArrowDown) => Some(Action::MoveDown(1)), Key::Named(Named::Tab) if state.multi_enabled => { let tx = state.action_tx.clone(); + let mods = iced_modifiers(modifiers); tokio::spawn(async move { - let _ = tx.send(Action::ToggleSelect).await; - let _ = tx.send(Action::MoveDown(1)).await; + let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await; + let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await; }); None } @@ -580,11 +595,12 @@ fn map_visual_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option< let tx = state.action_tx.clone(); let start = anchor; let end = vs.cursor; + let mods = iced_modifiers(modifiers); tokio::spawn(async move { let _ = tx - .send(Action::SelectRange { start, end }) + .send(ModifiedAction::with_modifiers(Action::SelectRange { start, end }, mods)) .await; - let _ = tx.send(Action::SetMode(Mode::Normal)).await; + let _ = tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await; }); } state.visual_anchor = None; @@ -599,11 +615,12 @@ fn map_visual_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option< let tx = state.action_tx.clone(); let start = anchor; let end = vs.cursor; + let mods = iced_modifiers(modifiers); tokio::spawn(async move { let _ = tx - .send(Action::SelectRange { start, end }) + .send(ModifiedAction::with_modifiers(Action::SelectRange { start, end }, mods)) .await; - let _ = tx.send(Action::SetMode(Mode::Normal)).await; + let _ = tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await; }); } state.visual_anchor = None; diff --git a/crates/pikl-test-macros/src/codegen.rs b/crates/pikl-test-macros/src/codegen.rs index c37416e..9afdcb5 100644 --- a/crates/pikl-test-macros/src/codegen.rs +++ b/crates/pikl-test-macros/src/codegen.rs @@ -59,7 +59,7 @@ fn gen_imports(kind: TestKind) -> TokenStream { TestKind::Menu => { quote! { use pikl_core::item::Item; - use pikl_core::event::{Action, MenuEvent, MenuResult}; + use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction}; use pikl_core::menu::MenuRunner; use pikl_core::json_menu::JsonMenu; } @@ -67,7 +67,7 @@ fn gen_imports(kind: TestKind) -> TokenStream { TestKind::Ipc => { quote! { use pikl_core::item::Item; - use pikl_core::event::{Action, MenuEvent, MenuResult}; + use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction}; use pikl_core::menu::MenuRunner; use pikl_core::json_menu::JsonMenu; use crate::ipc::server::IpcServer; @@ -326,14 +326,14 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result { let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; - let _ = tx.send(Action::Resize { height: 50 }).await; + let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await; let _ = rx.recv().await; #(#action_sends)* drop(tx); - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: pikl_core::event::Modifiers::default() })); #result_assert } }) @@ -345,17 +345,17 @@ fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result> { let expr = match action { ActionExpr::Simple(name) => { let variant = menu_action_variant(name)?; - quote! { let _ = tx.send(#variant).await; } + quote! { let _ = tx.send(ModifiedAction::new(#variant)).await; } } ActionExpr::Filter(query) => { - quote! { let _ = tx.send(Action::UpdateFilter(#query.to_string())).await; } + quote! { let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(#query.to_string()))).await; } } ActionExpr::AddItems(items) => { let item_exprs: Vec = items .iter() .map(|s| quote! { serde_json::Value::String(#s.to_string()) }) .collect(); - quote! { let _ = tx.send(Action::AddItems(vec![#(#item_exprs),*])).await; } + quote! { let _ = tx.send(ModifiedAction::new(Action::AddItems(vec![#(#item_exprs),*]))).await; } } ActionExpr::Raw(_) => { return Err(syn::Error::new(Span::call_site(), "raw actions are only supported in headless tests")); @@ -450,7 +450,7 @@ fn gen_ipc(case: &TestCase, fixtures: &Fixtures) -> syn::Result { let handle = tokio::spawn(async move { menu.run().await }); - let _ = tx.send(Action::Resize { height: 50 }).await; + let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await; tokio::time::sleep(Duration::from_millis(30)).await; // Start IPC server on a temp socket @@ -484,7 +484,7 @@ fn gen_ipc(case: &TestCase, fixtures: &Fixtures) -> syn::Result { drop(writer); drop(tx); drop(ipc_server); - let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: pikl_core::event::Modifiers::default() })); #result_assert } }) @@ -559,7 +559,7 @@ fn gen_result_assert(case: &TestCase, label_key: &str) -> syn::Result Modifiers { + Modifiers { + shift: km.contains(KeyModifiers::SHIFT), + ctrl: km.contains(KeyModifiers::CONTROL), + alt: km.contains(KeyModifiers::ALT), + } +} + /// Start the TUI. Enters the alternate screen, runs the /// event loop, and restores the terminal on exit. Translates /// crossterm key events into [`Action`]s and renders /// [`ViewState`] snapshots. pub async fn run( - action_tx: mpsc::Sender, + action_tx: mpsc::Sender, mut event_rx: broadcast::Receiver, filter_history: Option>, ) -> std::io::Result<()> { @@ -65,7 +74,7 @@ pub async fn run( /// Inner event loop. Separated from [`run`] so terminal /// cleanup always happens even if this returns an error. async fn run_inner( - action_tx: &mpsc::Sender, + action_tx: &mpsc::Sender, event_rx: &mut broadcast::Receiver, terminal: &mut Terminal>, filter_history: Option>, @@ -74,9 +83,9 @@ async fn run_inner( let size = terminal.size()?; let list_height = size.height.saturating_sub(1); if action_tx - .send(Action::Resize { + .send(ModifiedAction::new(Action::Resize { height: list_height, - }) + })) .await .is_err() { @@ -135,7 +144,7 @@ async fn run_inner( history_cursor = Some(new_cursor); filter_text.clone_from(&history[new_cursor]); let _ = action_tx - .send(Action::UpdateFilter(filter_text.clone())) + .send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone()))) .await; true } @@ -153,7 +162,7 @@ async fn run_inner( pre_history_text = None; } let _ = action_tx - .send(Action::UpdateFilter(filter_text.clone())) + .send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone()))) .await; true } else { @@ -196,14 +205,14 @@ async fn run_inner( mode = *m; pending = PendingKey::None; } - if action_tx.send(action).await.is_err() { + if action_tx.send(ModifiedAction::with_modifiers(action, crossterm_modifiers(key.modifiers))).await.is_err() { break; // core is gone } } } Event::Resize(_, h) => { let list_height = h.saturating_sub(1); - if action_tx.send(Action::Resize { height: list_height }).await.is_err() { + if action_tx.send(ModifiedAction::new(Action::Resize { height: list_height })).await.is_err() { break; } } @@ -259,7 +268,7 @@ async fn handle_multi_action_key( multi_enabled: bool, visual_anchor: &mut Option, view_state: &Option, - action_tx: &mpsc::Sender, + action_tx: &mpsc::Sender, ) -> bool { match mode { Mode::Insert => { @@ -268,13 +277,15 @@ async fn handle_multi_action_key( } match key.code { KeyCode::Tab => { - let _ = action_tx.send(Action::ToggleSelect).await; - let _ = action_tx.send(Action::MoveDown(1)).await; + let mods = crossterm_modifiers(key.modifiers); + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await; + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await; true } KeyCode::BackTab => { - let _ = action_tx.send(Action::ToggleSelect).await; - let _ = action_tx.send(Action::MoveUp(1)).await; + let mods = crossterm_modifiers(key.modifiers); + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await; + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await; true } _ => false, @@ -288,18 +299,21 @@ async fn handle_multi_action_key( (KeyCode::Char(' '), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { - let _ = action_tx.send(Action::ToggleSelect).await; - let _ = action_tx.send(Action::MoveDown(1)).await; + let mods = crossterm_modifiers(key.modifiers); + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await; + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await; true } (KeyCode::Tab, _) => { - let _ = action_tx.send(Action::ToggleSelect).await; - let _ = action_tx.send(Action::MoveDown(1)).await; + let mods = crossterm_modifiers(key.modifiers); + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await; + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await; true } (KeyCode::BackTab, _) => { - let _ = action_tx.send(Action::ToggleSelect).await; - let _ = action_tx.send(Action::MoveUp(1)).await; + let mods = crossterm_modifiers(key.modifiers); + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await; + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await; true } _ => false, @@ -310,18 +324,19 @@ async fn handle_multi_action_key( (KeyCode::Char(' '), m) | (KeyCode::Enter, m) if !m.intersects(KeyModifiers::ALT) => { + let mods = crossterm_modifiers(key.modifiers); // Apply visual selection if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) { let cursor = vs.cursor; let _ = action_tx - .send(Action::SelectRange { + .send(ModifiedAction::with_modifiers(Action::SelectRange { start: anchor, end: cursor, - }) + }, mods)) .await; } *visual_anchor = None; - let _ = action_tx.send(Action::SetMode(Mode::Normal)).await; + let _ = action_tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await; true } _ => false, diff --git a/crates/pikl/src/handler.rs b/crates/pikl/src/handler.rs index 0e5a929..1f7241d 100644 --- a/crates/pikl/src/handler.rs +++ b/crates/pikl/src/handler.rs @@ -11,7 +11,7 @@ use tokio::process::Command; use tokio::sync::mpsc; use pikl_core::error::PiklError; -use pikl_core::event::Action; +use pikl_core::event::{Action, ModifiedAction}; use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, parse_hook_response}; /// A persistent handler hook process. Spawns a child process, @@ -25,7 +25,7 @@ pub struct ShellHandlerHook { impl ShellHandlerHook { /// Build from CLI flags. Returns None if no handler hooks are configured. - pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender) -> Option { + pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender) -> Option { let mut handlers: Vec<(HookEventKind, &str)> = Vec::new(); if let Some(ref cmd) = cli.on_open { @@ -91,7 +91,7 @@ impl HookHandler for ShellHandlerHook { async fn run_handler_process( command: &str, mut event_rx: mpsc::Receiver, - action_tx: mpsc::Sender, + action_tx: mpsc::Sender, ) -> Result<(), PiklError> { let mut child = Command::new("sh") .arg("-c") @@ -120,7 +120,7 @@ async fn run_handler_process( let mut lines = stdout_reader.lines(); while let Ok(Some(line)) = lines.next_line().await { if let Some(resp) = parse_hook_response(&line) { - let action = Action::ProcessHookResponse(resp); + let action = ModifiedAction::new(Action::ProcessHookResponse(resp)); if reader_action_tx.send(action).await.is_err() { break; } diff --git a/crates/pikl/src/ipc/server.rs b/crates/pikl/src/ipc/server.rs index 2dfe266..32a00fd 100644 --- a/crates/pikl/src/ipc/server.rs +++ b/crates/pikl/src/ipc/server.rs @@ -9,7 +9,7 @@ use tokio::net::UnixListener; use tokio::sync::{broadcast, mpsc, watch, RwLock}; use tracing::{info, warn}; -use pikl_core::event::{Action, MenuEvent, ViewState}; +use pikl_core::event::{MenuEvent, ModifiedAction, ViewState}; use super::protocol::{ipc_command_to_action, view_state_to_response, IpcCommand, IpcResponse}; @@ -19,7 +19,7 @@ use super::protocol::{ipc_command_to_action, view_state_to_response, IpcCommand, /// removes the socket file. pub struct IpcServer { socket_path: PathBuf, - action_tx: mpsc::Sender, + action_tx: mpsc::Sender, state: Arc>>, event_tx: broadcast::Sender, /// Dropping the sender signals all tasks to shut down. @@ -29,7 +29,7 @@ pub struct IpcServer { impl IpcServer { pub fn new( socket_path: PathBuf, - action_tx: mpsc::Sender, + action_tx: mpsc::Sender, event_tx: broadcast::Sender, ) -> Self { Self { @@ -143,7 +143,7 @@ impl Drop for IpcServer { /// dispatches actions, sends responses. async fn handle_connection( stream: tokio::net::UnixStream, - action_tx: mpsc::Sender, + action_tx: mpsc::Sender, state: Arc>>, event_tx: broadcast::Sender, ) -> Result<(), std::io::Error> { @@ -180,7 +180,7 @@ async fn handle_connection( // Write commands: convert to action, send, no response ref c if ipc_command_to_action(c).is_some() => { if let Some(action) = ipc_command_to_action(&cmd) { - let _ = action_tx.send(action).await; + let _ = action_tx.send(ModifiedAction::new(action)).await; } } diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index 66e0beb..ead7040 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -13,7 +13,7 @@ use pikl_core::column::ColumnConfig; use pikl_core::csv_input::{self, InputFormat}; use pikl_core::debounce::{DebounceMode, DebouncedDispatcher}; use pikl_core::error::PiklError; -use pikl_core::event::{Action, MenuResult, Mode}; +use pikl_core::event::{Action, MenuResult, ModifiedAction, Mode}; use pikl_core::format::FormatTemplate; use pikl_core::hook::{HookEventKind, HookHandler}; use pikl_core::input::{parse_line_to_value, read_items_sync}; @@ -409,7 +409,7 @@ fn build_menu(items: Vec, cli: &Cli, column_config: Option) /// Build the composite hook handler from CLI flags, if any hooks are specified. fn build_hook_handler( cli: &Cli, - action_tx: &tokio::sync::mpsc::Sender, + action_tx: &tokio::sync::mpsc::Sender, ) -> Option<(Arc, DebouncedDispatcher)> { let exec_handler = ShellExecHandler::from_cli(cli); let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone()); @@ -502,7 +502,9 @@ async fn run_headless( let event_rx = menu.subscribe(); // Default headless viewport - let _ = action_tx.send(Action::Resize { height: 50 }).await; + let _ = action_tx + .send(ModifiedAction::new(Action::Resize { height: 50 })) + .await; let menu_handle = tokio::spawn(menu.run()); @@ -592,7 +594,7 @@ async fn run_interactive( if signal_frontend == FrontendMode::Tui { pikl_tui::restore_terminal(); } - let _ = signal_tx.send(Action::Cancel).await; + let _ = signal_tx.send(ModifiedAction::new(Action::Cancel)).await; }); // Spawn background stdin reader for streaming mode. @@ -621,9 +623,9 @@ async fn run_interactive( if (batch.len() >= 100 || reader.buffer().is_empty()) && !batch.is_empty() { - let _ = stream_tx.blocking_send(Action::AddItems( + let _ = stream_tx.blocking_send(ModifiedAction::new(Action::AddItems( std::mem::take(&mut batch), - )); + ))); } } Err(e) => { @@ -633,9 +635,9 @@ async fn run_interactive( } } if !batch.is_empty() { - let _ = stream_tx.blocking_send(Action::AddItems(batch)); + let _ = stream_tx.blocking_send(ModifiedAction::new(Action::AddItems(batch))); } - let _ = stream_tx.blocking_send(Action::StreamingDone); + let _ = stream_tx.blocking_send(ModifiedAction::new(Action::StreamingDone)); } }); } @@ -682,13 +684,16 @@ async fn run_interactive( fn handle_result(result: Result, cli: &Cli) { let mut out = std::io::stdout().lock(); match result { - Ok(MenuResult::Selected { items, .. }) => { + Ok(MenuResult::Selected { + items, modifiers, .. + }) => { if cli.structured { for (value, index) in items { let output = OutputItem { value, action: OutputAction::Select, index, + modifiers, }; let _ = write_output_json(&mut out, &output); } @@ -698,13 +703,16 @@ fn handle_result(result: Result, cli: &Cli) { } } } - Ok(MenuResult::Quicklist { items, .. }) => { + Ok(MenuResult::Quicklist { + items, modifiers, .. + }) => { if cli.structured { for (value, index) in items { let output = OutputItem { value, action: OutputAction::Quicklist, index, + modifiers, }; let _ = write_output_json(&mut out, &output); } @@ -714,12 +722,13 @@ fn handle_result(result: Result, cli: &Cli) { } } } - Ok(MenuResult::Cancelled) => { + Ok(MenuResult::Cancelled { modifiers }) => { if cli.structured { let output = OutputItem { value: Value::Null, action: OutputAction::Cancel, index: 0, + modifiers, }; let _ = write_output_json(&mut out, &output); } diff --git a/crates/pikl/tests/headless.rs b/crates/pikl/tests/headless.rs index 5eb4d8c..5c9de92 100644 --- a/crates/pikl/tests/headless.rs +++ b/crates/pikl/tests/headless.rs @@ -68,6 +68,96 @@ fn headless_actions_after_show_ui_exits_2() { ); } +// -- Modifier prefix integration tests -- + +#[test] +fn modifier_prefix_in_structured_output() { + let (stdout, _stderr, code) = common::run_pikl( + "alpha\nbeta\n", + "+shift confirm\n", + &["--structured"], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + assert!( + stdout.contains(r#""modifiers":["shift"]"#), + "expected modifiers array in structured output, got: {stdout}" + ); +} + +#[test] +fn no_modifier_omits_field_in_structured_output() { + let (stdout, _stderr, code) = common::run_pikl( + "alpha\nbeta\n", + "confirm\n", + &["--structured"], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + assert!( + !stdout.contains("modifiers"), + "expected no modifiers field when none held, got: {stdout}" + ); +} + +#[test] +fn multiple_modifier_prefixes_in_structured() { + let (stdout, _stderr, code) = common::run_pikl( + "alpha\nbeta\n", + "+ctrl+alt confirm\n", + &["--structured"], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + assert!( + stdout.contains(r#""modifiers":["ctrl","alt"]"#), + "expected ctrl+alt modifiers, got: {stdout}" + ); +} + +#[test] +fn modifier_prefix_cancel_structured() { + let (stdout, _stderr, code) = common::run_pikl( + "alpha\n", + "+shift cancel\n", + &["--structured"], + ); + assert_eq!(code, 1, "expected exit 1 on cancel"); + assert!( + stdout.contains(r#""modifiers":["shift"]"#), + "expected modifiers on cancel output, got: {stdout}" + ); +} + +#[test] +fn modifier_prefix_does_not_affect_plain_output() { + let (stdout, _stderr, code) = common::run_pikl( + "alpha\nbeta\n", + "+shift confirm\n", + &[], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + assert!( + !stdout.contains("modifiers"), + "plain output should not contain modifiers, got: {stdout}" + ); + assert!( + stdout.contains("alpha"), + "expected alpha in plain output, got: {stdout}" + ); +} + +#[test] +fn unknown_modifier_prefix_exits_2() { + let (_stdout, stderr, code) = common::run_pikl( + "alpha\n", + "+super confirm\n", + &[], + ); + assert_eq!(code, 2, "expected exit 2 on unknown modifier"); + assert!( + stderr.contains("unknown modifier"), + "expected unknown modifier error, got: {stderr}" + ); +} + // -- CSV/TSV integration tests -- #[test] diff --git a/crates/pikl/tests/headless_dsl.rs b/crates/pikl/tests/headless_dsl.rs index df4ff0b..40dd399 100644 --- a/crates/pikl/tests/headless_dsl.rs +++ b/crates/pikl/tests/headless_dsl.rs @@ -331,4 +331,29 @@ pikl_tests! { exit: 0 } } + + headless mod modifier_prefix { + items: ["alpha", "beta", "gamma"]; + + test shift_confirm_plain_output_unchanged { + // Modifier prefixes don't affect plain text output. + actions: [raw "+shift confirm"] + stdout: "alpha" + exit: 0 + } + + test unknown_modifier_errors { + actions: [raw "+meta confirm"] + stderr contains: "unknown modifier" + exit: 2 + } + + test modifier_with_movement { + // Modifiers on navigation don't change behavior, + // but they should parse and not break anything. + actions: [raw "+shift move-down", confirm] + stdout: "beta" + exit: 0 + } + } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 303b805..481d89f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -48,6 +48,43 @@ metadata that came in, plus selection context: {"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3} ``` +When `--structured` is used, the output JSON includes a +`modifiers` field if any modifier keys (shift, ctrl, alt) +were held during confirmation. The value is an array of +strings: + +```jsonl +{"label": "firefox", "action": "select", "index": 0, "modifiers": ["shift"]} +{"label": "alacritty", "action": "select", "index": 2, "modifiers": ["ctrl", "alt"]} +``` + +When no modifiers are held, the field is omitted entirely. +Plain text output is unchanged: modifiers only appear in +structured JSON. + +### Working with Structured Output + +Check for shift modifier with jq: + +```sh +pikl --structured | jq 'if (.modifiers // [] | contains(["shift"])) then "alternate" else "default" end' +``` + +Extract a field: + +```sh +pikl --structured | jq -r '.label' +``` + +Branch on modifiers in a script: + +```sh +result=$(echo -e "a\nb\nc" | pikl --structured) +if echo "$result" | jq -e '.modifiers // [] | contains(["shift"])' > /dev/null 2>&1; then + echo "shift was held" +fi +``` + ### Streaming Input can arrive over time. The list populates progressively @@ -96,6 +133,12 @@ There are two ways to respond to them: **exec hooks** and | `on-filter` | Filter text changes | Dynamic item reloading | | `on-toggle` | User toggles an item's selection | Visual feedback (deferred) | +Hook event JSON includes a `modifiers` field on `select`, +`cancel`, `filter`, `hover`, and `quicklist` events when +modifier keys were held. Same format as structured output: +an array of strings like `["shift"]` or `["ctrl", "alt"]`. +Omitted when no modifiers are active. + ### Exec Hooks (fire-and-forget) `--on--exec` spawns a subprocess for each event. @@ -770,6 +813,18 @@ Available actions: | `show-tui` | (none) | Hand off to TUI specifically | | `show-gui` | (none) | Hand off to GUI specifically | +Actions can include modifier key prefixes with `+`: + +``` ++shift confirm ++ctrl+alt move-down 3 +``` + +Valid modifiers: `shift`, `ctrl`, `alt`. Multiple modifiers +chain with `+`. Lines without a `+` prefix work exactly as +before. This lets scripts signal intent to hooks and +structured output the same way a user holding Shift would. + `show-ui` auto-detects the appropriate interactive frontend (Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and `show-gui` are explicit overrides. All three must be the diff --git a/docs/DEVPLAN.md b/docs/DEVPLAN.md index 422ac21..1e2f9d9 100644 --- a/docs/DEVPLAN.md +++ b/docs/DEVPLAN.md @@ -349,7 +349,14 @@ navigate directories without spawning new processes. pass `-la` to it. The output would include both the selected item and the user-supplied arguments. - Open questions: + **Partially resolved:** modifier key support has landed. + Structured output and hook events now carry a `modifiers` + field when shift/ctrl/alt are held during confirmation. + This covers the signaling side: scripts can detect + Shift+Enter and branch on it. The free-text argument + input (where does the user type `-la`?) is still open. + + Remaining open questions: - UX flow: does the filter text become the args on Shift+Enter? Or does Shift+Enter open a second input field for args after selection? The filter-as-args @@ -363,9 +370,6 @@ navigate directories without spawning new processes. matches exactly one item just confirm that item (current behaviour), or should it also treat any "extra" text as args? Probably not, too implicit. - - Keybind: Shift+Enter is natural, but some terminals - don't distinguish it from Enter. May need a fallback - like Ctrl+Enter or a normal-mode keybind. This is a core feature (new keybind, new output field), not just a launcher script concern. Fits naturally after diff --git a/examples/demo.sh b/examples/demo.sh index 409a079..351609e 100755 --- a/examples/demo.sh +++ b/examples/demo.sh @@ -400,6 +400,19 @@ ipc_demo() { ITEMS } +modifier_keys_demo() { + echo "Modifier key demo. Use --structured to see modifiers in output." >&2 + echo "Press Enter normally, then try Shift+Enter or Ctrl+Enter." >&2 + echo "Compare the JSON output: modifiers only appear when held." >&2 + echo "" >&2 + cat <<'ITEMS' | pikl --structured +{"label": "Firefox", "url": "https://firefox.com"} +{"label": "Neovim", "url": "https://neovim.io"} +{"label": "Alacritty", "url": "https://alacritty.org"} +{"label": "mpv", "url": "https://mpv.io"} +ITEMS +} + session_demo() { echo "Session filter history: select with a filter, exit, relaunch." >&2 echo "Press Ctrl+P to recall your previous filter." >&2 @@ -442,6 +455,7 @@ scenarios=( "---" "IPC remote control" "Session filter history" + "Modifier keys (structured output)" "---" "on-select-exec hook (legacy)" ) @@ -475,6 +489,7 @@ run_scenario() { *"CSV + field"*) csv_filter ;; *"IPC remote"*) ipc_demo ;; *"Session filter"*) session_demo ;; + *"Modifier keys"*) modifier_keys_demo ;; *"on-select-exec"*) on_select_hook ;; "---") echo "that's a separator, not a scenario" >&2