feat: Add support for action modifier keys like ctrl,shift,alt.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled

This commit is contained in:
2026-03-15 11:51:17 -04:00
parent 0d241841bc
commit 47437f536b
18 changed files with 1055 additions and 244 deletions

View File

@@ -9,6 +9,8 @@
use std::sync::Arc; use std::sync::Arc;
use serde::ser::SerializeSeq;
use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use crate::hook::HookResponse; use crate::hook::HookResponse;
@@ -56,6 +58,64 @@ pub enum Action {
StreamingDone, 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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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 /// Broadcast from the menu loop to all subscribers
/// (frontends, tests). /// (frontends, tests).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -125,10 +185,89 @@ pub enum MenuResult {
Selected { Selected {
items: Vec<(Value, usize)>, items: Vec<(Value, usize)>,
filter_text: String, filter_text: String,
modifiers: Modifiers,
}, },
Quicklist { Quicklist {
items: Vec<(Value, usize)>, items: Vec<(Value, usize)>,
filter_text: String, 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);
}
} }

View File

@@ -6,6 +6,8 @@ use serde::Serialize;
use serde::ser::SerializeMap; use serde::ser::SerializeMap;
use serde_json::Value; use serde_json::Value;
use crate::event::Modifiers;
/// What the user did to produce this output. /// What the user did to produce this output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -25,27 +27,35 @@ pub struct OutputItem {
pub value: Value, pub value: Value,
pub action: OutputAction, pub action: OutputAction,
pub index: usize, pub index: usize,
pub modifiers: Modifiers,
} }
impl Serialize for OutputItem { impl Serialize for OutputItem {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let has_mods = !self.modifiers.is_empty();
match &self.value { match &self.value {
Value::Object(map) => { Value::Object(map) => {
// Flatten: merge object fields with action/index // 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 { for (k, v) in map {
s.serialize_entry(k, v)?; s.serialize_entry(k, v)?;
} }
s.serialize_entry("action", &self.action)?; s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?; s.serialize_entry("index", &self.index)?;
if has_mods {
s.serialize_entry("modifiers", &self.modifiers)?;
}
s.end() s.end()
} }
_ => { _ => {
// Non-object: put value in a "value" field // 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("value", &self.value)?;
s.serialize_entry("action", &self.action)?; s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?; s.serialize_entry("index", &self.index)?;
if has_mods {
s.serialize_entry("modifiers", &self.modifiers)?;
}
s.end() s.end()
} }
} }
@@ -63,6 +73,7 @@ mod tests {
value: json!({"label": "Firefox", "url": "https://firefox.com"}), value: json!({"label": "Firefox", "url": "https://firefox.com"}),
action: OutputAction::Select, action: OutputAction::Select,
index: 3, index: 3,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&item).unwrap_or_default(); let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["label"], "Firefox"); assert_eq!(json["label"], "Firefox");
@@ -77,6 +88,7 @@ mod tests {
value: json!("hello"), value: json!("hello"),
action: OutputAction::Select, action: OutputAction::Select,
index: 0, index: 0,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&item).unwrap_or_default(); let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["value"], "hello"); assert_eq!(json["value"], "hello");
@@ -90,6 +102,7 @@ mod tests {
value: json!(null), value: json!(null),
action: OutputAction::Cancel, action: OutputAction::Cancel,
index: 0, index: 0,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&item).unwrap_or_default(); let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "cancel"); assert_eq!(json["action"], "cancel");
@@ -101,6 +114,7 @@ mod tests {
value: json!("test"), value: json!("test"),
action: OutputAction::Quicklist, action: OutputAction::Quicklist,
index: 2, index: 2,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&item).unwrap_or_default(); let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "quicklist"); assert_eq!(json["action"], "quicklist");
@@ -113,6 +127,7 @@ mod tests {
value: json!("alpha"), value: json!("alpha"),
action: OutputAction::Select, action: OutputAction::Select,
index: 0, index: 0,
modifiers: Modifiers::default(),
}; };
let serialized = serde_json::to_string(&item).unwrap_or_default(); let serialized = serde_json::to_string(&item).unwrap_or_default();
assert!( assert!(
@@ -120,4 +135,61 @@ mod tests {
"output should contain the value text: {serialized}" "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());
}
} }

View File

@@ -10,7 +10,7 @@ use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::Instrument; use tracing::Instrument;
use crate::event::Action; use crate::event::{Action, ModifiedAction};
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse}; use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
/// Debounce mode for a hook event kind. /// Debounce mode for a hook event kind.
@@ -30,13 +30,13 @@ pub enum DebounceMode {
/// behavior. Each event kind can have its own mode. /// behavior. Each event kind can have its own mode.
pub struct DebouncedDispatcher { pub struct DebouncedDispatcher {
handler: Arc<dyn HookHandler>, handler: Arc<dyn HookHandler>,
_action_tx: mpsc::Sender<Action>, _action_tx: mpsc::Sender<ModifiedAction>,
modes: HashMap<HookEventKind, DebounceMode>, modes: HashMap<HookEventKind, DebounceMode>,
in_flight: HashMap<HookEventKind, JoinHandle<()>>, in_flight: HashMap<HookEventKind, JoinHandle<()>>,
} }
impl DebouncedDispatcher { impl DebouncedDispatcher {
pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<Action>) -> Self { pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<ModifiedAction>) -> Self {
Self { Self {
handler, handler,
_action_tx: action_tx, _action_tx: action_tx,
@@ -149,6 +149,7 @@ pub fn hook_response_to_action(resp: HookResponse) -> Action {
mod tests { mod tests {
use super::*; use super::*;
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::Modifiers;
use serde_json::json; use serde_json::json;
use std::sync::Mutex; use std::sync::Mutex;
@@ -217,12 +218,15 @@ mod tests {
// Rapid-fire filter events // Rapid-fire filter events
dispatcher.dispatch(HookEvent::Filter { dispatcher.dispatch(HookEvent::Filter {
text: "a".to_string(), text: "a".to_string(),
modifiers: Modifiers::default(),
}); });
dispatcher.dispatch(HookEvent::Filter { dispatcher.dispatch(HookEvent::Filter {
text: "ab".to_string(), text: "ab".to_string(),
modifiers: Modifiers::default(),
}); });
dispatcher.dispatch(HookEvent::Filter { dispatcher.dispatch(HookEvent::Filter {
text: "abc".to_string(), text: "abc".to_string(),
modifiers: Modifiers::default(),
}); });
// Advance past debounce window. sleep(0) processes // Advance past debounce window. sleep(0) processes
@@ -251,12 +255,14 @@ mod tests {
dispatcher.dispatch(HookEvent::Hover { dispatcher.dispatch(HookEvent::Hover {
item: json!("a"), item: json!("a"),
index: 0, index: 0,
modifiers: Modifiers::default(),
}); });
// Wait a bit, then send second hover which cancels first // Wait a bit, then send second hover which cancels first
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
dispatcher.dispatch(HookEvent::Hover { dispatcher.dispatch(HookEvent::Hover {
item: json!("b"), item: json!("b"),
index: 1, index: 1,
modifiers: Modifiers::default(),
}); });
// Advance past debounce for the second event // Advance past debounce for the second event

View File

@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::Modifiers;
/// A lifecycle event emitted by the menu engine. Handler /// A lifecycle event emitted by the menu engine. Handler
/// hooks receive these as JSON lines on stdin. The `event` /// hooks receive these as JSON lines on stdin. The `event`
@@ -16,11 +17,33 @@ use crate::error::PiklError;
pub enum HookEvent { pub enum HookEvent {
Open, Open,
Close, Close,
Hover { item: Value, index: usize }, Hover {
Select { item: Value, index: usize }, item: Value,
Cancel, index: usize,
Filter { text: String }, #[serde(skip_serializing_if = "Modifiers::is_empty")]
Quicklist { items: Vec<Value>, count: usize }, 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<Value>,
count: usize,
#[serde(skip_serializing_if = "Modifiers::is_empty")]
modifiers: Modifiers,
},
} }
/// Discriminant for [`HookEvent`], used as a key for /// Discriminant for [`HookEvent`], used as a key for
@@ -44,7 +67,7 @@ impl HookEvent {
HookEvent::Close => HookEventKind::Close, HookEvent::Close => HookEventKind::Close,
HookEvent::Hover { .. } => HookEventKind::Hover, HookEvent::Hover { .. } => HookEventKind::Hover,
HookEvent::Select { .. } => HookEventKind::Select, HookEvent::Select { .. } => HookEventKind::Select,
HookEvent::Cancel => HookEventKind::Cancel, HookEvent::Cancel { .. } => HookEventKind::Cancel,
HookEvent::Filter { .. } => HookEventKind::Filter, HookEvent::Filter { .. } => HookEventKind::Filter,
HookEvent::Quicklist { .. } => HookEventKind::Quicklist, HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
} }
@@ -116,11 +139,14 @@ mod tests {
let event = HookEvent::Hover { let event = HookEvent::Hover {
item: json!({"label": "test"}), item: json!({"label": "test"}),
index: 5, index: 5,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&event).unwrap_or_default(); let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "hover"); assert_eq!(json["event"], "hover");
assert_eq!(json["item"]["label"], "test"); assert_eq!(json["item"]["label"], "test");
assert_eq!(json["index"], 5); assert_eq!(json["index"], 5);
// Empty modifiers should be omitted
assert!(json.get("modifiers").is_none());
} }
#[test] #[test]
@@ -128,6 +154,7 @@ mod tests {
let event = HookEvent::Select { let event = HookEvent::Select {
item: json!("hello"), item: json!("hello"),
index: 0, index: 0,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&event).unwrap_or_default(); let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "select"); assert_eq!(json["event"], "select");
@@ -137,14 +164,19 @@ mod tests {
#[test] #[test]
fn event_cancel_serializes() { 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_eq!(json["event"], "cancel");
assert!(json.get("modifiers").is_none());
} }
#[test] #[test]
fn event_filter_serializes() { fn event_filter_serializes() {
let event = HookEvent::Filter { let event = HookEvent::Filter {
text: "foo".to_string(), text: "foo".to_string(),
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&event).unwrap_or_default(); let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "filter"); assert_eq!(json["event"], "filter");
@@ -160,7 +192,8 @@ mod tests {
assert_eq!( assert_eq!(
HookEvent::Hover { HookEvent::Hover {
item: json!(null), item: json!(null),
index: 0 index: 0,
modifiers: Modifiers::default(),
} }
.kind(), .kind(),
HookEventKind::Hover HookEventKind::Hover
@@ -168,15 +201,23 @@ mod tests {
assert_eq!( assert_eq!(
HookEvent::Select { HookEvent::Select {
item: json!(null), item: json!(null),
index: 0 index: 0,
modifiers: Modifiers::default(),
} }
.kind(), .kind(),
HookEventKind::Select HookEventKind::Select
); );
assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel); assert_eq!(
HookEvent::Cancel {
modifiers: Modifiers::default()
}
.kind(),
HookEventKind::Cancel
);
assert_eq!( assert_eq!(
HookEvent::Filter { HookEvent::Filter {
text: String::new() text: String::new(),
modifiers: Modifiers::default(),
} }
.kind(), .kind(),
HookEventKind::Filter HookEventKind::Filter
@@ -291,6 +332,7 @@ mod tests {
let event = HookEvent::Quicklist { let event = HookEvent::Quicklist {
items: vec![json!("alpha"), json!("beta")], items: vec![json!("alpha"), json!("beta")],
count: 2, count: 2,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&event).unwrap_or_default(); let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "quicklist"); assert_eq!(json["event"], "quicklist");
@@ -303,6 +345,7 @@ mod tests {
let event = HookEvent::Quicklist { let event = HookEvent::Quicklist {
items: vec![], items: vec![],
count: 0, count: 0,
modifiers: Modifiers::default(),
}; };
assert_eq!(event.kind(), HookEventKind::Quicklist); assert_eq!(event.kind(), HookEventKind::Quicklist);
} }
@@ -314,6 +357,7 @@ mod tests {
let event = HookEvent::Hover { let event = HookEvent::Hover {
item: json!({"label": "Firefox", "url": "https://firefox.com"}), item: json!({"label": "Firefox", "url": "https://firefox.com"}),
index: 2, index: 2,
modifiers: Modifiers::default(),
}; };
let serialized = serde_json::to_string(&event).unwrap_or_default(); let serialized = serde_json::to_string(&event).unwrap_or_default();
let parsed: Value = serde_json::from_str(&serialized).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["item"]["label"], "Firefox");
assert_eq!(parsed["index"], 2); 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"]));
}
} }

View File

@@ -11,7 +11,10 @@ use tracing::{debug, info, trace};
use crate::debounce::{DebouncedDispatcher, hook_response_to_action}; use crate::debounce::{DebouncedDispatcher, hook_response_to_action};
use crate::error::PiklError; 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::hook::{HookEvent, HookHandler};
use crate::model::traits::MutableMenu; use crate::model::traits::MutableMenu;
use crate::navigation::Viewport; use crate::navigation::Viewport;
@@ -147,7 +150,7 @@ pub struct MenuRunner<M: MutableMenu> {
viewport: Viewport, viewport: Viewport,
filter_text: Arc<str>, filter_text: Arc<str>,
mode: Mode, mode: Mode,
action_rx: mpsc::Receiver<Action>, action_rx: mpsc::Receiver<ModifiedAction>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
dispatcher: Option<DebouncedDispatcher>, dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>, previous_cursor: Option<usize>,
@@ -155,6 +158,7 @@ pub struct MenuRunner<M: MutableMenu> {
selection: SelectionState, selection: SelectionState,
visual_anchor: Option<usize>, visual_anchor: Option<usize>,
streaming: bool, streaming: bool,
last_modifiers: Modifiers,
} }
impl<M: MutableMenu> MenuRunner<M> { impl<M: MutableMenu> MenuRunner<M> {
@@ -162,7 +166,7 @@ impl<M: MutableMenu> MenuRunner<M> {
/// Returns the runner and an action sender. Call /// Returns the runner and an action sender. Call
/// [`subscribe`](Self::subscribe) to get an event handle, /// [`subscribe`](Self::subscribe) to get an event handle,
/// then [`run`](Self::run) to start the event loop. /// then [`run`](Self::run) to start the event loop.
pub fn new(menu: M) -> (Self, mpsc::Sender<Action>) { pub fn new(menu: M) -> (Self, mpsc::Sender<ModifiedAction>) {
let (action_tx, action_rx) = mpsc::channel(256); let (action_tx, action_rx) = mpsc::channel(256);
// 1024 slots: large enough that a burst of rapid state changes // 1024 slots: large enough that a burst of rapid state changes
// (e.g. streaming AddItems + filter updates) won't cause lag for // (e.g. streaming AddItems + filter updates) won't cause lag for
@@ -182,6 +186,7 @@ impl<M: MutableMenu> MenuRunner<M> {
selection: SelectionState::new(), selection: SelectionState::new(),
visual_anchor: None, visual_anchor: None,
streaming: false, streaming: false,
last_modifiers: Modifiers::default(),
}; };
(runner, action_tx) (runner, action_tx)
} }
@@ -204,7 +209,7 @@ impl<M: MutableMenu> MenuRunner<M> {
pub fn set_hook_handler( pub fn set_hook_handler(
&mut self, &mut self,
handler: Arc<dyn HookHandler>, handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
) { ) {
let dispatcher = DebouncedDispatcher::new(handler, action_tx); let dispatcher = DebouncedDispatcher::new(handler, action_tx);
self.dispatcher = Some(dispatcher); self.dispatcher = Some(dispatcher);
@@ -357,6 +362,7 @@ impl<M: MutableMenu> MenuRunner<M> {
self.emit_hook(HookEvent::Hover { self.emit_hook(HookEvent::Hover {
item: value, item: value,
index: orig_idx, index: orig_idx,
modifiers: self.last_modifiers,
}); });
} }
} }
@@ -604,7 +610,9 @@ impl<M: MutableMenu> MenuRunner<M> {
// Emit Open event // Emit Open event
self.emit_hook(HookEvent::Open); 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(_)); let is_filter_update = matches!(&action, Action::UpdateFilter(_));
match self.apply_action(action) { match self.apply_action(action) {
@@ -614,7 +622,10 @@ impl<M: MutableMenu> MenuRunner<M> {
// Emit Filter event if the filter changed // Emit Filter event if the filter changed
if is_filter_update { if is_filter_update {
let text = self.filter_text.to_string(); 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 // Check for cursor movement -> Hover
@@ -628,6 +639,7 @@ impl<M: MutableMenu> MenuRunner<M> {
self.emit_hook(HookEvent::Select { self.emit_hook(HookEvent::Select {
item: value.clone(), item: value.clone(),
index: *index, index: *index,
modifiers: self.last_modifiers,
}); });
} }
// Emit Close event // Emit Close event
@@ -637,6 +649,7 @@ impl<M: MutableMenu> MenuRunner<M> {
return Ok(MenuResult::Selected { return Ok(MenuResult::Selected {
items, items,
filter_text: self.filter_text.to_string(), filter_text: self.filter_text.to_string(),
modifiers: self.last_modifiers,
}); });
} }
ActionOutcome::Quicklist { items } => { ActionOutcome::Quicklist { items } => {
@@ -646,6 +659,7 @@ impl<M: MutableMenu> MenuRunner<M> {
self.emit_hook(HookEvent::Quicklist { self.emit_hook(HookEvent::Quicklist {
items: values.clone(), items: values.clone(),
count, count,
modifiers: self.last_modifiers,
}); });
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
@@ -653,22 +667,29 @@ impl<M: MutableMenu> MenuRunner<M> {
return Ok(MenuResult::Quicklist { return Ok(MenuResult::Quicklist {
items, items,
filter_text: self.filter_text.to_string(), filter_text: self.filter_text.to_string(),
modifiers: self.last_modifiers,
}); });
} }
ActionOutcome::Cancelled => { ActionOutcome::Cancelled => {
info!("menu cancelled"); info!("menu cancelled");
self.emit_hook(HookEvent::Cancel); self.emit_hook(HookEvent::Cancel {
modifiers: self.last_modifiers,
});
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled); let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled); return Ok(MenuResult::Cancelled {
modifiers: self.last_modifiers,
});
} }
ActionOutcome::Closed => { ActionOutcome::Closed => {
info!("menu closed by hook"); info!("menu closed by hook");
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled); let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled); return Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
});
} }
ActionOutcome::NoOp => {} ActionOutcome::NoOp => {}
} }
@@ -676,7 +697,9 @@ impl<M: MutableMenu> MenuRunner<M> {
// Sender dropped // Sender dropped
self.emit_hook(HookEvent::Close); 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::model::traits::Menu;
use crate::runtime::json_menu::JsonMenu; use crate::runtime::json_menu::JsonMenu;
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) { fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<ModifiedAction>) {
let items = vec![ let items = vec![
Item::from_plain_text("alpha"), Item::from_plain_text("alpha"),
Item::from_plain_text("beta"), Item::from_plain_text("beta"),
@@ -818,9 +841,9 @@ mod tests {
} }
// Cancel to exit // Cancel to exit
let _ = tx.send(Action::Cancel).await; let _ = tx.send(ModifiedAction::new(Action::Cancel)).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::Cancelled))); assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
} }
#[tokio::test] #[tokio::test]
@@ -834,7 +857,7 @@ mod tests {
let _ = rx.recv().await; let _ = rx.recv().await;
// Filter to "al" // 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 { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_items, 4); assert_eq!(vs.total_items, 4);
// alpha should match "al" // alpha should match "al"
@@ -842,7 +865,7 @@ mod tests {
assert_eq!(&*vs.filter_text, "al"); assert_eq!(&*vs.filter_text, "al");
} }
let _ = tx.send(Action::Cancel).await; let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
let _ = handle.await; let _ = handle.await;
} }
@@ -857,20 +880,20 @@ mod tests {
let _ = rx.recv().await; let _ = rx.recv().await;
// Need to send resize first so viewport has height // 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; let _ = rx.recv().await;
// Move down and confirm // 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 _ = rx.recv().await;
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
// Should get Selected event // Should get Selected event
if let Ok(MenuEvent::Selected(items)) = rx.recv().await { if let Ok(MenuEvent::Selected(items)) = rx.recv().await {
assert_eq!(items[0].0.as_str(), Some("beta")); 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 { .. }))); assert!(matches!(result, Ok(MenuResult::Selected { .. })));
} }
@@ -884,18 +907,18 @@ mod tests {
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
// Filter to something that matches nothing // 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 { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_filtered, 0); assert_eq!(vs.total_filtered, 0);
} }
// Confirm should be no-op // 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) // Cancel to exit (should still work)
let _ = tx.send(Action::Cancel).await; let _ = tx.send(ModifiedAction::new(Action::Cancel)).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::Cancelled))); assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
} }
#[tokio::test] #[tokio::test]
@@ -908,8 +931,8 @@ mod tests {
// Drop the only sender. // Drop the only sender.
drop(tx); drop(tx);
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::Cancelled))); assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
} }
// -- End-to-end output correctness -- // -- End-to-end output correctness --
@@ -922,18 +945,18 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial state 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; let _ = rx.recv().await;
// Confirm at cursor 0, should get "alpha" // 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; let event = rx.recv().await;
assert!( assert!(
matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha")) 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!( assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha")) 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 handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial 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; let _ = rx.recv().await;
// Move down twice -> cursor at index 2 -> "gamma" // 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 _ = rx.recv().await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = rx.recv().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!( assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma")) 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 handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial 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; let _ = rx.recv().await;
// Filter to "del", should match "delta" // 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 { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert!(vs.total_filtered >= 1); assert!(vs.total_filtered >= 1);
assert_eq!(vs.visible_items[0].label, "delta"); assert_eq!(vs.visible_items[0].label, "delta");
} }
// Confirm, should select "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!( assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")) 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 handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial 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; let _ = rx.recv().await;
// Add a new item // Add a new item
let _ = tx let _ = tx
.send(Action::AddItems(vec![serde_json::Value::String( .send(ModifiedAction::new(Action::AddItems(vec![
"epsilon".to_string(), serde_json::Value::String("epsilon".to_string()),
)])) ])))
.await; .await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_items, 5); assert_eq!(vs.total_items, 5);
@@ -1013,15 +1036,15 @@ mod tests {
} }
// Filter to "eps", only epsilon should match // 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 { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert!(vs.total_filtered >= 1); assert!(vs.total_filtered >= 1);
assert_eq!(vs.visible_items[0].label, "epsilon"); 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!( assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon")) 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 _ = rx.recv().await; // initial
let _ = tx.send(Action::Cancel).await; let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
// Should get Cancelled event // Should get Cancelled event
assert!(matches!(rx.recv().await, Ok(MenuEvent::Cancelled))); assert!(matches!(rx.recv().await, Ok(MenuEvent::Cancelled)));
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::Cancelled))); assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
} }
// -- Ordering invariant tests -- // -- Ordering invariant tests --
@@ -1065,14 +1088,14 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial 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; let _ = rx.recv().await;
// Back-to-back, no waiting between these // Back-to-back, no waiting between these
let _ = tx.send(Action::UpdateFilter("ban".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("ban".to_string()))).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() }));
// Must get "banana". Filter was applied before confirm ran. // Must get "banana". Filter was applied before confirm ran.
assert!(matches!( assert!(matches!(
result, result,
@@ -1089,16 +1112,16 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial 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; let _ = rx.recv().await;
// Three moves down back-to-back, then confirm // Three moves down back-to-back, then confirm
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).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() }));
// Cursor at index 3 -> "delta" // Cursor at index 3 -> "delta"
assert!(matches!( assert!(matches!(
result, result,
@@ -1182,19 +1205,19 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial 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; let _ = rx.recv().await;
// All back-to-back // All back-to-back
let _ = tx let _ = tx
.send(Action::AddItems(vec![serde_json::Value::String( .send(ModifiedAction::new(Action::AddItems(vec![
"zephyr".to_string(), serde_json::Value::String("zephyr".to_string()),
)])) ])))
.await; .await;
let _ = tx.send(Action::UpdateFilter("zep".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("zep".to_string()))).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() }));
// Must find "zephyr". It was added before the filter ran. // Must find "zephyr". It was added before the filter ran.
assert!(matches!( assert!(matches!(
result, result,
@@ -1353,10 +1376,10 @@ mod tests {
// Skip initial state // Skip initial state
let _ = rx.recv().await; 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 _ = 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 { if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await {
assert_eq!(values.len(), 4); assert_eq!(values.len(), 4);
@@ -1364,7 +1387,7 @@ mod tests {
panic!("expected Quicklist event"); 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 { .. }))); assert!(matches!(result, Ok(MenuResult::Quicklist { .. })));
} }
@@ -1376,14 +1399,14 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial 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; let _ = rx.recv().await;
// Filter then quicklist back-to-back // Filter then quicklist back-to-back
let _ = tx.send(Action::UpdateFilter("al".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("al".to_string()))).await;
let _ = tx.send(Action::Quicklist).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 { match result {
Ok(MenuResult::Quicklist { items, .. }) => { Ok(MenuResult::Quicklist { items, .. }) => {
// "alpha" matches "al" // "alpha" matches "al"
@@ -1429,6 +1452,7 @@ mod tests {
m.apply_action(Action::UpdateFilter("al".to_string())); m.apply_action(Action::UpdateFilter("al".to_string()));
m.emit_hook(HookEvent::Filter { m.emit_hook(HookEvent::Filter {
text: "al".to_string(), text: "al".to_string(),
modifiers: Modifiers::default(),
}); });
m.apply_action(Action::MoveDown(1)); m.apply_action(Action::MoveDown(1));
m.check_cursor_hover(); m.check_cursor_hover();
@@ -1442,6 +1466,142 @@ mod tests {
assert!(events.contains(&HookEventKind::Filter)); 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 -- // -- SelectionState unit tests --
#[test] #[test]

View File

@@ -14,7 +14,7 @@ pub mod parse;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::Action; use crate::event::{Action, ModifiedAction, Modifiers};
pub use error::{ScriptError, ScriptErrorKind}; pub use error::{ScriptError, ScriptErrorKind};
pub use parse::{load_script, parse_action}; 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. /// A parsed action from an action-fd script.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ScriptAction { pub enum ScriptAction {
Core(Action), Core(Action, Modifiers),
ShowUi, ShowUi,
ShowTui, ShowTui,
ShowGui, ShowGui,
@@ -41,14 +41,14 @@ pub enum ShowAction {
/// Returns the Show* variant if the script ends with one, or None. /// Returns the Show* variant if the script ends with one, or None.
pub async fn run_script( pub async fn run_script(
script: Vec<ScriptAction>, script: Vec<ScriptAction>,
tx: &mpsc::Sender<Action>, tx: &mpsc::Sender<ModifiedAction>,
) -> Result<Option<ShowAction>, PiklError> { ) -> Result<Option<ShowAction>, PiklError> {
let mut show = None; let mut show = None;
for action in script { for action in script {
match action { match action {
ScriptAction::Core(action) => { ScriptAction::Core(action, modifiers) => {
tx.send(action) tx.send(ModifiedAction { action, modifiers })
.await .await
.map_err(|_| PiklError::ChannelClosed)?; .map_err(|_| PiklError::ChannelClosed)?;
} }
@@ -69,9 +69,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn run_script_sends_actions_in_order() { async fn run_script_sends_actions_in_order() {
let script = vec![ let script = vec![
ScriptAction::Core(Action::UpdateFilter("hello".to_string())), ScriptAction::Core(Action::UpdateFilter("hello".to_string()), Modifiers::default()),
ScriptAction::Core(Action::MoveDown(1)), ScriptAction::Core(Action::MoveDown(1), Modifiers::default()),
ScriptAction::Core(Action::Confirm), ScriptAction::Core(Action::Confirm, Modifiers::default()),
]; ];
let (tx, mut rx) = mpsc::channel(16); let (tx, mut rx) = mpsc::channel(16);
let result = run_script(script, &tx).await; let result = run_script(script, &tx).await;
@@ -79,14 +79,14 @@ mod tests {
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None); assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
// Verify order // Verify order
assert!(matches!(rx.recv().await, Some(Action::UpdateFilter(s)) if s == "hello")); assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::UpdateFilter(s), .. }) if s == "hello"));
assert!(matches!(rx.recv().await, Some(Action::MoveDown(1)))); assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::MoveDown(1), .. })));
assert!(matches!(rx.recv().await, Some(Action::Confirm))); assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::Confirm, .. })));
} }
#[tokio::test] #[tokio::test]
async fn run_script_returns_none_without_show() { 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 (tx, _rx) = mpsc::channel(16);
let result = run_script(script, &tx).await; let result = run_script(script, &tx).await;
assert!(result.is_ok()); assert!(result.is_ok());
@@ -96,7 +96,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn run_script_returns_show_action() { async fn run_script_returns_show_action() {
let script = vec![ let script = vec![
ScriptAction::Core(Action::UpdateFilter("test".to_string())), ScriptAction::Core(Action::UpdateFilter("test".to_string()), Modifiers::default()),
ScriptAction::ShowUi, ScriptAction::ShowUi,
]; ];
let (tx, _rx) = mpsc::channel(16); let (tx, _rx) = mpsc::channel(16);

View File

@@ -4,7 +4,7 @@
use std::io::BufRead; use std::io::BufRead;
use crate::event::{Action, Mode}; use crate::event::{Action, Mode, Modifiers};
use super::ScriptAction; use super::ScriptAction;
use super::error::{ScriptError, ScriptErrorKind}; 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<String>> {
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. /// Parse a single line into a ScriptAction.
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> { pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
let trimmed = line.trim(); let trimmed = line.trim();
@@ -90,41 +114,66 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
return Ok(ScriptAction::Comment); return Ok(ScriptAction::Comment);
} }
let (cmd, arg) = match trimmed.split_once(' ') { // Parse optional modifier prefixes (+shift, +ctrl, +alt)
let (modifiers, rest) = parse_modifier_prefix(trimmed).map_err(|msg| ScriptError {
line: line_number,
source_line: line.to_string(),
kind: ScriptErrorKind::InvalidArgument {
action: String::new(),
message: msg.unwrap_or_default(),
},
})?;
if rest.is_empty() {
return Err(ScriptError {
line: line_number,
source_line: line.to_string(),
kind: ScriptErrorKind::InvalidArgument {
action: String::new(),
message: "modifier prefix without an action".to_string(),
},
});
}
let (cmd, arg) = match rest.split_once(' ') {
Some((c, a)) => (c, Some(a)), Some((c, a)) => (c, Some(a)),
None => (trimmed, None), None => (rest, None),
};
let core = |action: Action| -> Result<ScriptAction, ScriptError> {
Ok(ScriptAction::Core(action, modifiers))
}; };
match cmd { match cmd {
"filter" => { "filter" => {
let text = arg.unwrap_or(""); let text = arg.unwrap_or("");
Ok(ScriptAction::Core(Action::UpdateFilter(text.to_string()))) core(Action::UpdateFilter(text.to_string()))
} }
"move-up" => { "move-up" => {
let n = parse_count(line_number, line, "move-up", arg)?; let n = parse_count(line_number, line, "move-up", arg)?;
Ok(ScriptAction::Core(Action::MoveUp(n))) core(Action::MoveUp(n))
} }
"move-down" => { "move-down" => {
let n = parse_count(line_number, line, "move-down", arg)?; 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-top" => core(Action::MoveToTop),
"move-to-bottom" => Ok(ScriptAction::Core(Action::MoveToBottom)), "move-to-bottom" => core(Action::MoveToBottom),
"page-up" => { "page-up" => {
let n = parse_count(line_number, line, "page-up", arg)?; let n = parse_count(line_number, line, "page-up", arg)?;
Ok(ScriptAction::Core(Action::PageUp(n))) core(Action::PageUp(n))
} }
"page-down" => { "page-down" => {
let n = parse_count(line_number, line, "page-down", arg)?; let n = parse_count(line_number, line, "page-down", arg)?;
Ok(ScriptAction::Core(Action::PageDown(n))) core(Action::PageDown(n))
} }
"half-page-up" => { "half-page-up" => {
let n = parse_count(line_number, line, "half-page-up", arg)?; let n = parse_count(line_number, line, "half-page-up", arg)?;
Ok(ScriptAction::Core(Action::HalfPageUp(n))) core(Action::HalfPageUp(n))
} }
"half-page-down" => { "half-page-down" => {
let n = parse_count(line_number, line, "half-page-down", arg)?; let n = parse_count(line_number, line, "half-page-down", arg)?;
Ok(ScriptAction::Core(Action::HalfPageDown(n))) core(Action::HalfPageDown(n))
} }
"set-mode" => { "set-mode" => {
let Some(mode_str) = arg else { let Some(mode_str) = arg else {
@@ -136,9 +185,9 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
)); ));
}; };
match mode_str.trim() { match mode_str.trim() {
"insert" => Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))), "insert" => core(Action::SetMode(Mode::Insert)),
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))), "normal" => core(Action::SetMode(Mode::Normal)),
"visual" => Ok(ScriptAction::Core(Action::SetMode(Mode::Visual))), "visual" => core(Action::SetMode(Mode::Visual)),
other => Err(invalid_arg( other => Err(invalid_arg(
line_number, line_number,
line, line,
@@ -147,21 +196,21 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
)), )),
} }
} }
"toggle-select" => Ok(ScriptAction::Core(Action::ToggleSelect)), "toggle-select" => core(Action::ToggleSelect),
"select-all" => Ok(ScriptAction::Core(Action::SelectAll)), "select-all" => core(Action::SelectAll),
"deselect-all" => Ok(ScriptAction::Core(Action::ClearSelections)), "deselect-all" => core(Action::ClearSelections),
"undo-selection" => Ok(ScriptAction::Core(Action::UndoSelection)), "undo-selection" => core(Action::UndoSelection),
"redo-selection" => Ok(ScriptAction::Core(Action::RedoSelection)), "redo-selection" => core(Action::RedoSelection),
"confirm" => Ok(ScriptAction::Core(Action::Confirm)), "confirm" => core(Action::Confirm),
"cancel" => Ok(ScriptAction::Core(Action::Cancel)), "cancel" => core(Action::Cancel),
"resize" => { "resize" => {
let height = parse_positive_u16(line_number, line, "resize", arg)?; 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-ui" => Ok(ScriptAction::ShowUi),
"show-tui" => Ok(ScriptAction::ShowTui), "show-tui" => Ok(ScriptAction::ShowTui),
"show-gui" => Ok(ScriptAction::ShowGui), "show-gui" => Ok(ScriptAction::ShowGui),
"streaming-done" => Ok(ScriptAction::Core(Action::StreamingDone)), "streaming-done" => core(Action::StreamingDone),
_ => Err(ScriptError { _ => Err(ScriptError {
line: line_number, line: line_number,
source_line: line.to_string(), source_line: line.to_string(),
@@ -244,7 +293,7 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!( assert_eq!(
result.unwrap_or(ScriptAction::Comment), 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!(result.is_ok());
assert_eq!( assert_eq!(
result.unwrap_or(ScriptAction::Comment), 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() { fn parse_movement_actions() {
assert_eq!( assert_eq!(
parse_action(1, "move-up").unwrap_or(ScriptAction::Comment), parse_action(1, "move-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveUp(1)) ScriptAction::Core(Action::MoveUp(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "move-down").unwrap_or(ScriptAction::Comment), parse_action(1, "move-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(1)) ScriptAction::Core(Action::MoveDown(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment), parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveToTop) ScriptAction::Core(Action::MoveToTop, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment), parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveToBottom) ScriptAction::Core(Action::MoveToBottom, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "page-up").unwrap_or(ScriptAction::Comment), parse_action(1, "page-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageUp(1)) ScriptAction::Core(Action::PageUp(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "page-down").unwrap_or(ScriptAction::Comment), 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() { fn parse_movement_with_count() {
assert_eq!( assert_eq!(
parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment), parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveUp(5)) ScriptAction::Core(Action::MoveUp(5), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment), parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(3)) ScriptAction::Core(Action::MoveDown(3), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment), parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageUp(2)) ScriptAction::Core(Action::PageUp(2), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "page-down 10").unwrap_or(ScriptAction::Comment), 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() { fn parse_confirm_cancel() {
assert_eq!( assert_eq!(
parse_action(1, "confirm").unwrap_or(ScriptAction::Comment), parse_action(1, "confirm").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Confirm) ScriptAction::Core(Action::Confirm, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "cancel").unwrap_or(ScriptAction::Comment), 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() { fn parse_resize_valid() {
assert_eq!( assert_eq!(
parse_action(1, "resize 25").unwrap_or(ScriptAction::Comment), 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() { fn parse_half_page_actions() {
assert_eq!( assert_eq!(
parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment), parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::HalfPageUp(1)) ScriptAction::Core(Action::HalfPageUp(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment), parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::HalfPageDown(1)) ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "half-page-up 3").unwrap_or(ScriptAction::Comment), 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!( assert_eq!(
parse_action(1, "half-page-down 2").unwrap_or(ScriptAction::Comment), 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() { fn parse_set_mode() {
assert_eq!( assert_eq!(
parse_action(1, "set-mode insert").unwrap_or(ScriptAction::Comment), 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!( assert_eq!(
parse_action(1, "set-mode normal").unwrap_or(ScriptAction::Comment), 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() { fn parse_set_mode_invalid() {
assert_eq!( assert_eq!(
parse_action(1, "set-mode visual").unwrap_or(ScriptAction::Comment), 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 { ParsedLine {
line_number: 1, line_number: 1,
source: "filter test".into(), source: "filter test".into(),
action: ScriptAction::Core(Action::UpdateFilter("test".into())), action: ScriptAction::Core(Action::UpdateFilter("test".into()), Modifiers::default()),
}, },
ParsedLine { ParsedLine {
line_number: 2, line_number: 2,
@@ -541,7 +590,7 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 2, line_number: 2,
source: "confirm".into(), source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm), action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
}, },
]; ];
let err = validate_show_last(&actions).unwrap_err(); let err = validate_show_last(&actions).unwrap_err();
@@ -555,7 +604,7 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 1, line_number: 1,
source: "filter x".into(), source: "filter x".into(),
action: ScriptAction::Core(Action::UpdateFilter("x".into())), action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()),
}, },
ParsedLine { ParsedLine {
line_number: 2, line_number: 2,
@@ -565,7 +614,7 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 3, line_number: 3,
source: "confirm".into(), source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm), action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
}, },
]; ];
let err = validate_show_last(&actions).unwrap_err(); let err = validate_show_last(&actions).unwrap_err();
@@ -589,12 +638,12 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 1, line_number: 1,
source: "filter x".into(), source: "filter x".into(),
action: ScriptAction::Core(Action::UpdateFilter("x".into())), action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()),
}, },
ParsedLine { ParsedLine {
line_number: 2, line_number: 2,
source: "confirm".into(), source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm), action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
}, },
]; ];
assert!(validate_show_last(&actions).is_ok()); assert!(validate_show_last(&actions).is_ok());
@@ -659,23 +708,23 @@ mod tests {
fn parse_selection_actions() { fn parse_selection_actions() {
assert_eq!( assert_eq!(
parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment), parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::ToggleSelect) ScriptAction::Core(Action::ToggleSelect, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "select-all").unwrap_or(ScriptAction::Comment), parse_action(1, "select-all").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::SelectAll) ScriptAction::Core(Action::SelectAll, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment), parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::ClearSelections) ScriptAction::Core(Action::ClearSelections, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment), parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::UndoSelection) ScriptAction::Core(Action::UndoSelection, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment), 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() { fn parse_streaming_done() {
assert_eq!( assert_eq!(
parse_action(1, "streaming-done").unwrap_or(ScriptAction::Comment), 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 // Extra whitespace around the mode value should be trimmed
assert_eq!( assert_eq!(
parse_action(1, "set-mode insert ").unwrap_or(ScriptAction::Comment), 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!( assert_eq!(
parse_action(1, "set-mode normal ").unwrap_or(ScriptAction::Comment), 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()); assert!(result.is_ok());
let actions = result.unwrap_or_default(); let actions = result.unwrap_or_default();
assert_eq!(actions.len(), 5); 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!( assert_eq!(
actions[1], actions[1],
ScriptAction::Core(Action::SetMode(Mode::Normal)) ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
); );
assert_eq!( assert_eq!(
actions[2], 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[3], ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default()));
assert_eq!(actions[4], ScriptAction::Core(Action::Confirm)); 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);
}
} }
} }

View File

@@ -15,7 +15,9 @@ use iced_layershell::settings::{LayerShellSettings, Settings};
use iced_layershell::to_layer_message; use iced_layershell::to_layer_message;
use tokio::sync::{broadcast, mpsc}; 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. /// Number of visible items in the list viewport.
const VIEWPORT_HEIGHT: u16 = 20; const VIEWPORT_HEIGHT: u16 = 20;
@@ -52,12 +54,12 @@ static BRIDGE_RX: OnceLock<Mutex<Option<mpsc::Receiver<Message>>>> = OnceLock::n
/// Global slot for the action sender. Moved into the Pikl /// Global slot for the action sender. Moved into the Pikl
/// struct during boot (called exactly once by iced). /// struct during boot (called exactly once by iced).
static ACTION_TX: OnceLock<Mutex<Option<mpsc::Sender<Action>>>> = OnceLock::new(); static ACTION_TX: OnceLock<Mutex<Option<mpsc::Sender<ModifiedAction>>>> = OnceLock::new();
/// Top-level GUI state. /// Top-level GUI state.
struct Pikl { struct Pikl {
/// Channel to send actions into the core engine. /// Channel to send actions into the core engine.
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
/// Current view snapshot from core. /// Current view snapshot from core.
view_state: Option<ViewState>, view_state: Option<ViewState>,
/// Local copy of filter text for the text input widget. /// 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 /// runs the iced event loop, and exits when the user
/// confirms or cancels. /// confirms or cancels.
pub fn run( pub fn run(
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
event_rx: broadcast::Receiver<MenuEvent>, event_rx: broadcast::Receiver<MenuEvent>,
) -> Result<(), iced_layershell::Error> { ) -> Result<(), iced_layershell::Error> {
// Send initial resize to core so it knows our viewport. // 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, height: VIEWPORT_HEIGHT,
}); }));
// Bridge: spawn a thread that reads core events and // Bridge: spawn a thread that reads core events and
// forwards them as Messages through an mpsc channel. // forwards them as Messages through an mpsc channel.
@@ -109,7 +111,7 @@ pub fn run(
.get() .get()
.and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take()) .and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take())
.unwrap_or_else(|| { .unwrap_or_else(|| {
let (tx, _) = mpsc::channel(1); let (tx, _) = mpsc::channel::<ModifiedAction>(1);
tx tx
}); });
let pikl = Pikl { let pikl = Pikl {
@@ -228,7 +230,7 @@ fn update(state: &mut Pikl, message: Message) -> Task<Message> {
let tx = state.action_tx.clone(); let tx = state.action_tx.clone();
Task::perform( Task::perform(
async move { async move {
let _ = tx.send(Action::UpdateFilter(new_text)).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(new_text))).await;
}, },
|_| Message::Sent, |_| Message::Sent,
) )
@@ -415,6 +417,15 @@ fn core_event_stream() -> impl iced::futures::Stream<Item = Message> {
}) })
} }
/// 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. /// Handle a keyboard event and return the appropriate task.
fn handle_key(state: &mut Pikl, key: Key, modifiers: Modifiers) -> Task<Message> { fn handle_key(state: &mut Pikl, key: Key, modifiers: Modifiers) -> Task<Message> {
let action = match state.mode { let action = match state.mode {
@@ -443,9 +454,10 @@ fn handle_key(state: &mut Pikl, key: Key, modifiers: Modifiers) -> Task<Message>
}; };
let tx = state.action_tx.clone(); let tx = state.action_tx.clone();
let mods = iced_modifiers(&modifiers);
let send_task = Task::perform( let send_task = Task::perform(
async move { async move {
let _ = tx.send(action).await; let _ = tx.send(ModifiedAction::with_modifiers(action, mods)).await;
}, },
|_| Message::Sent, |_| 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::PageDown) => Some(Action::PageDown(1)),
Key::Named(Named::Tab) if state.multi_enabled => { Key::Named(Named::Tab) if state.multi_enabled => {
let tx = state.action_tx.clone(); let tx = state.action_tx.clone();
let mods = iced_modifiers(modifiers);
tokio::spawn(async move { tokio::spawn(async move {
let _ = tx.send(Action::ToggleSelect).await; let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
}); });
None None
} }
@@ -515,9 +528,10 @@ fn map_normal_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option<
"u" => Some(Action::UndoSelection), "u" => Some(Action::UndoSelection),
" " if state.multi_enabled => { " " if state.multi_enabled => {
let tx = state.action_tx.clone(); let tx = state.action_tx.clone();
let mods = iced_modifiers(modifiers);
tokio::spawn(async move { tokio::spawn(async move {
let _ = tx.send(Action::ToggleSelect).await; let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
}); });
None 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::ArrowDown) => Some(Action::MoveDown(1)),
Key::Named(Named::Tab) if state.multi_enabled => { Key::Named(Named::Tab) if state.multi_enabled => {
let tx = state.action_tx.clone(); let tx = state.action_tx.clone();
let mods = iced_modifiers(modifiers);
tokio::spawn(async move { tokio::spawn(async move {
let _ = tx.send(Action::ToggleSelect).await; let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
}); });
None None
} }
@@ -580,11 +595,12 @@ fn map_visual_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option<
let tx = state.action_tx.clone(); let tx = state.action_tx.clone();
let start = anchor; let start = anchor;
let end = vs.cursor; let end = vs.cursor;
let mods = iced_modifiers(modifiers);
tokio::spawn(async move { tokio::spawn(async move {
let _ = tx let _ = tx
.send(Action::SelectRange { start, end }) .send(ModifiedAction::with_modifiers(Action::SelectRange { start, end }, mods))
.await; .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; 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 tx = state.action_tx.clone();
let start = anchor; let start = anchor;
let end = vs.cursor; let end = vs.cursor;
let mods = iced_modifiers(modifiers);
tokio::spawn(async move { tokio::spawn(async move {
let _ = tx let _ = tx
.send(Action::SelectRange { start, end }) .send(ModifiedAction::with_modifiers(Action::SelectRange { start, end }, mods))
.await; .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; state.visual_anchor = None;

View File

@@ -59,7 +59,7 @@ fn gen_imports(kind: TestKind) -> TokenStream {
TestKind::Menu => { TestKind::Menu => {
quote! { quote! {
use pikl_core::item::Item; 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::menu::MenuRunner;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
} }
@@ -67,7 +67,7 @@ fn gen_imports(kind: TestKind) -> TokenStream {
TestKind::Ipc => { TestKind::Ipc => {
quote! { quote! {
use pikl_core::item::Item; 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::menu::MenuRunner;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
use crate::ipc::server::IpcServer; use crate::ipc::server::IpcServer;
@@ -326,14 +326,14 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().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; let _ = rx.recv().await;
#(#action_sends)* #(#action_sends)*
drop(tx); 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 #result_assert
} }
}) })
@@ -345,17 +345,17 @@ fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
let expr = match action { let expr = match action {
ActionExpr::Simple(name) => { ActionExpr::Simple(name) => {
let variant = menu_action_variant(name)?; let variant = menu_action_variant(name)?;
quote! { let _ = tx.send(#variant).await; } quote! { let _ = tx.send(ModifiedAction::new(#variant)).await; }
} }
ActionExpr::Filter(query) => { 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) => { ActionExpr::AddItems(items) => {
let item_exprs: Vec<TokenStream> = items let item_exprs: Vec<TokenStream> = items
.iter() .iter()
.map(|s| quote! { serde_json::Value::String(#s.to_string()) }) .map(|s| quote! { serde_json::Value::String(#s.to_string()) })
.collect(); .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(_) => { ActionExpr::Raw(_) => {
return Err(syn::Error::new(Span::call_site(), "raw actions are only supported in headless tests")); 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<TokenStream> {
let handle = tokio::spawn(async move { menu.run().await }); 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; tokio::time::sleep(Duration::from_millis(30)).await;
// Start IPC server on a temp socket // Start IPC server on a temp socket
@@ -484,7 +484,7 @@ fn gen_ipc(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
drop(writer); drop(writer);
drop(tx); drop(tx);
drop(ipc_server); 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 #result_assert
} }
}) })
@@ -559,7 +559,7 @@ fn gen_result_assert(case: &TestCase, label_key: &str) -> syn::Result<TokenStrea
if case.cancelled { if case.cancelled {
Ok(quote! { Ok(quote! {
assert!( assert!(
matches!(result, Ok(MenuResult::Cancelled)), matches!(result, Ok(MenuResult::Cancelled { .. })),
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r)) "expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
); );
}) })

View File

@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph}; use ratatui::widgets::{List, ListItem, Paragraph};
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use pikl_core::event::{Action, MenuEvent, Mode, ViewState}; use pikl_core::event::{Action, MenuEvent, ModifiedAction, Modifiers, Mode, ViewState};
/// Pending key state for multi-key sequences (e.g. `gg`). /// Pending key state for multi-key sequences (e.g. `gg`).
/// TUI-local, not part of core state. /// TUI-local, not part of core state.
@@ -32,12 +32,21 @@ pub fn restore_terminal() {
let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen); let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen);
} }
/// Convert crossterm key modifiers to pikl Modifiers.
fn crossterm_modifiers(km: KeyModifiers) -> 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 /// Start the TUI. Enters the alternate screen, runs the
/// event loop, and restores the terminal on exit. Translates /// event loop, and restores the terminal on exit. Translates
/// crossterm key events into [`Action`]s and renders /// crossterm key events into [`Action`]s and renders
/// [`ViewState`] snapshots. /// [`ViewState`] snapshots.
pub async fn run( pub async fn run(
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
mut event_rx: broadcast::Receiver<MenuEvent>, mut event_rx: broadcast::Receiver<MenuEvent>,
filter_history: Option<Vec<String>>, filter_history: Option<Vec<String>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
@@ -65,7 +74,7 @@ pub async fn run(
/// Inner event loop. Separated from [`run`] so terminal /// Inner event loop. Separated from [`run`] so terminal
/// cleanup always happens even if this returns an error. /// cleanup always happens even if this returns an error.
async fn run_inner( async fn run_inner(
action_tx: &mpsc::Sender<Action>, action_tx: &mpsc::Sender<ModifiedAction>,
event_rx: &mut broadcast::Receiver<MenuEvent>, event_rx: &mut broadcast::Receiver<MenuEvent>,
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>, terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
filter_history: Option<Vec<String>>, filter_history: Option<Vec<String>>,
@@ -74,9 +83,9 @@ async fn run_inner(
let size = terminal.size()?; let size = terminal.size()?;
let list_height = size.height.saturating_sub(1); let list_height = size.height.saturating_sub(1);
if action_tx if action_tx
.send(Action::Resize { .send(ModifiedAction::new(Action::Resize {
height: list_height, height: list_height,
}) }))
.await .await
.is_err() .is_err()
{ {
@@ -135,7 +144,7 @@ async fn run_inner(
history_cursor = Some(new_cursor); history_cursor = Some(new_cursor);
filter_text.clone_from(&history[new_cursor]); filter_text.clone_from(&history[new_cursor]);
let _ = action_tx let _ = action_tx
.send(Action::UpdateFilter(filter_text.clone())) .send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
.await; .await;
true true
} }
@@ -153,7 +162,7 @@ async fn run_inner(
pre_history_text = None; pre_history_text = None;
} }
let _ = action_tx let _ = action_tx
.send(Action::UpdateFilter(filter_text.clone())) .send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
.await; .await;
true true
} else { } else {
@@ -196,14 +205,14 @@ async fn run_inner(
mode = *m; mode = *m;
pending = PendingKey::None; 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 break; // core is gone
} }
} }
} }
Event::Resize(_, h) => { Event::Resize(_, h) => {
let list_height = h.saturating_sub(1); 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; break;
} }
} }
@@ -259,7 +268,7 @@ async fn handle_multi_action_key(
multi_enabled: bool, multi_enabled: bool,
visual_anchor: &mut Option<usize>, visual_anchor: &mut Option<usize>,
view_state: &Option<ViewState>, view_state: &Option<ViewState>,
action_tx: &mpsc::Sender<Action>, action_tx: &mpsc::Sender<ModifiedAction>,
) -> bool { ) -> bool {
match mode { match mode {
Mode::Insert => { Mode::Insert => {
@@ -268,13 +277,15 @@ async fn handle_multi_action_key(
} }
match key.code { match key.code {
KeyCode::Tab => { KeyCode::Tab => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
KeyCode::BackTab => { KeyCode::BackTab => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveUp(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true true
} }
_ => false, _ => false,
@@ -288,18 +299,21 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m) (KeyCode::Char(' '), m)
if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{ {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
(KeyCode::Tab, _) => { (KeyCode::Tab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
(KeyCode::BackTab, _) => { (KeyCode::BackTab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveUp(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true true
} }
_ => false, _ => false,
@@ -310,18 +324,19 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m) | (KeyCode::Enter, m) (KeyCode::Char(' '), m) | (KeyCode::Enter, m)
if !m.intersects(KeyModifiers::ALT) => if !m.intersects(KeyModifiers::ALT) =>
{ {
let mods = crossterm_modifiers(key.modifiers);
// Apply visual selection // Apply visual selection
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) { if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
let cursor = vs.cursor; let cursor = vs.cursor;
let _ = action_tx let _ = action_tx
.send(Action::SelectRange { .send(ModifiedAction::with_modifiers(Action::SelectRange {
start: anchor, start: anchor,
end: cursor, end: cursor,
}) }, mods))
.await; .await;
} }
*visual_anchor = None; *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 true
} }
_ => false, _ => false,

View File

@@ -11,7 +11,7 @@ use tokio::process::Command;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use pikl_core::error::PiklError; 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}; use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, parse_hook_response};
/// A persistent handler hook process. Spawns a child process, /// A persistent handler hook process. Spawns a child process,
@@ -25,7 +25,7 @@ pub struct ShellHandlerHook {
impl ShellHandlerHook { impl ShellHandlerHook {
/// Build from CLI flags. Returns None if no handler hooks are configured. /// Build from CLI flags. Returns None if no handler hooks are configured.
pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<Action>) -> Option<Self> { pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<ModifiedAction>) -> Option<Self> {
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new(); let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
if let Some(ref cmd) = cli.on_open { if let Some(ref cmd) = cli.on_open {
@@ -91,7 +91,7 @@ impl HookHandler for ShellHandlerHook {
async fn run_handler_process( async fn run_handler_process(
command: &str, command: &str,
mut event_rx: mpsc::Receiver<HookEvent>, mut event_rx: mpsc::Receiver<HookEvent>,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
) -> Result<(), PiklError> { ) -> Result<(), PiklError> {
let mut child = Command::new("sh") let mut child = Command::new("sh")
.arg("-c") .arg("-c")
@@ -120,7 +120,7 @@ async fn run_handler_process(
let mut lines = stdout_reader.lines(); let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
if let Some(resp) = parse_hook_response(&line) { 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() { if reader_action_tx.send(action).await.is_err() {
break; break;
} }

View File

@@ -9,7 +9,7 @@ use tokio::net::UnixListener;
use tokio::sync::{broadcast, mpsc, watch, RwLock}; use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::{info, warn}; 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}; 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. /// removes the socket file.
pub struct IpcServer { pub struct IpcServer {
socket_path: PathBuf, socket_path: PathBuf,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
state: Arc<RwLock<Option<ViewState>>>, state: Arc<RwLock<Option<ViewState>>>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
/// Dropping the sender signals all tasks to shut down. /// Dropping the sender signals all tasks to shut down.
@@ -29,7 +29,7 @@ pub struct IpcServer {
impl IpcServer { impl IpcServer {
pub fn new( pub fn new(
socket_path: PathBuf, socket_path: PathBuf,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
) -> Self { ) -> Self {
Self { Self {
@@ -143,7 +143,7 @@ impl Drop for IpcServer {
/// dispatches actions, sends responses. /// dispatches actions, sends responses.
async fn handle_connection( async fn handle_connection(
stream: tokio::net::UnixStream, stream: tokio::net::UnixStream,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
state: Arc<RwLock<Option<ViewState>>>, state: Arc<RwLock<Option<ViewState>>>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
) -> Result<(), std::io::Error> { ) -> Result<(), std::io::Error> {
@@ -180,7 +180,7 @@ async fn handle_connection(
// Write commands: convert to action, send, no response // Write commands: convert to action, send, no response
ref c if ipc_command_to_action(c).is_some() => { ref c if ipc_command_to_action(c).is_some() => {
if let Some(action) = ipc_command_to_action(&cmd) { if let Some(action) = ipc_command_to_action(&cmd) {
let _ = action_tx.send(action).await; let _ = action_tx.send(ModifiedAction::new(action)).await;
} }
} }

View File

@@ -13,7 +13,7 @@ use pikl_core::column::ColumnConfig;
use pikl_core::csv_input::{self, InputFormat}; use pikl_core::csv_input::{self, InputFormat};
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher}; use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
use pikl_core::error::PiklError; 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::format::FormatTemplate;
use pikl_core::hook::{HookEventKind, HookHandler}; use pikl_core::hook::{HookEventKind, HookHandler};
use pikl_core::input::{parse_line_to_value, read_items_sync}; use pikl_core::input::{parse_line_to_value, read_items_sync};
@@ -409,7 +409,7 @@ fn build_menu(items: Vec<Item>, cli: &Cli, column_config: Option<ColumnConfig>)
/// Build the composite hook handler from CLI flags, if any hooks are specified. /// Build the composite hook handler from CLI flags, if any hooks are specified.
fn build_hook_handler( fn build_hook_handler(
cli: &Cli, cli: &Cli,
action_tx: &tokio::sync::mpsc::Sender<Action>, action_tx: &tokio::sync::mpsc::Sender<ModifiedAction>,
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> { ) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
let exec_handler = ShellExecHandler::from_cli(cli); let exec_handler = ShellExecHandler::from_cli(cli);
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone()); let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
@@ -502,7 +502,9 @@ async fn run_headless(
let event_rx = menu.subscribe(); let event_rx = menu.subscribe();
// Default headless viewport // 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()); let menu_handle = tokio::spawn(menu.run());
@@ -592,7 +594,7 @@ async fn run_interactive(
if signal_frontend == FrontendMode::Tui { if signal_frontend == FrontendMode::Tui {
pikl_tui::restore_terminal(); 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. // Spawn background stdin reader for streaming mode.
@@ -621,9 +623,9 @@ async fn run_interactive(
if (batch.len() >= 100 || reader.buffer().is_empty()) if (batch.len() >= 100 || reader.buffer().is_empty())
&& !batch.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), std::mem::take(&mut batch),
)); )));
} }
} }
Err(e) => { Err(e) => {
@@ -633,9 +635,9 @@ async fn run_interactive(
} }
} }
if !batch.is_empty() { 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<MenuResult, PiklError>, cli: &Cli) { fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
let mut out = std::io::stdout().lock(); let mut out = std::io::stdout().lock();
match result { match result {
Ok(MenuResult::Selected { items, .. }) => { Ok(MenuResult::Selected {
items, modifiers, ..
}) => {
if cli.structured { if cli.structured {
for (value, index) in items { for (value, index) in items {
let output = OutputItem { let output = OutputItem {
value, value,
action: OutputAction::Select, action: OutputAction::Select,
index, index,
modifiers,
}; };
let _ = write_output_json(&mut out, &output); let _ = write_output_json(&mut out, &output);
} }
@@ -698,13 +703,16 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
} }
} }
} }
Ok(MenuResult::Quicklist { items, .. }) => { Ok(MenuResult::Quicklist {
items, modifiers, ..
}) => {
if cli.structured { if cli.structured {
for (value, index) in items { for (value, index) in items {
let output = OutputItem { let output = OutputItem {
value, value,
action: OutputAction::Quicklist, action: OutputAction::Quicklist,
index, index,
modifiers,
}; };
let _ = write_output_json(&mut out, &output); let _ = write_output_json(&mut out, &output);
} }
@@ -714,12 +722,13 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
} }
} }
} }
Ok(MenuResult::Cancelled) => { Ok(MenuResult::Cancelled { modifiers }) => {
if cli.structured { if cli.structured {
let output = OutputItem { let output = OutputItem {
value: Value::Null, value: Value::Null,
action: OutputAction::Cancel, action: OutputAction::Cancel,
index: 0, index: 0,
modifiers,
}; };
let _ = write_output_json(&mut out, &output); let _ = write_output_json(&mut out, &output);
} }

View File

@@ -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 -- // -- CSV/TSV integration tests --
#[test] #[test]

View File

@@ -331,4 +331,29 @@ pikl_tests! {
exit: 0 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
}
}
} }

View File

@@ -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} {"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 ### Streaming
Input can arrive over time. The list populates progressively 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-filter` | Filter text changes | Dynamic item reloading |
| `on-toggle` | User toggles an item's selection | Visual feedback (deferred) | | `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) ### Exec Hooks (fire-and-forget)
`--on-<event>-exec` spawns a subprocess for each event. `--on-<event>-exec` spawns a subprocess for each event.
@@ -770,6 +813,18 @@ Available actions:
| `show-tui` | (none) | Hand off to TUI specifically | | `show-tui` | (none) | Hand off to TUI specifically |
| `show-gui` | (none) | Hand off to GUI 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 `show-ui` auto-detects the appropriate interactive frontend
(Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and (Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and
`show-gui` are explicit overrides. All three must be the `show-gui` are explicit overrides. All three must be the

View File

@@ -349,7 +349,14 @@ navigate directories without spawning new processes.
pass `-la` to it. The output would include both the pass `-la` to it. The output would include both the
selected item and the user-supplied arguments. 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 - UX flow: does the filter text become the args on
Shift+Enter? Or does Shift+Enter open a second input Shift+Enter? Or does Shift+Enter open a second input
field for args after selection? The filter-as-args 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 matches exactly one item just confirm that item (current
behaviour), or should it also treat any "extra" text behaviour), or should it also treat any "extra" text
as args? Probably not, too implicit. 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), This is a core feature (new keybind, new output field),
not just a launcher script concern. Fits naturally after not just a launcher script concern. Fits naturally after

View File

@@ -400,6 +400,19 @@ ipc_demo() {
ITEMS 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() { session_demo() {
echo "Session filter history: select with a filter, exit, relaunch." >&2 echo "Session filter history: select with a filter, exit, relaunch." >&2
echo "Press Ctrl+P to recall your previous filter." >&2 echo "Press Ctrl+P to recall your previous filter." >&2
@@ -442,6 +455,7 @@ scenarios=(
"---" "---"
"IPC remote control" "IPC remote control"
"Session filter history" "Session filter history"
"Modifier keys (structured output)"
"---" "---"
"on-select-exec hook (legacy)" "on-select-exec hook (legacy)"
) )
@@ -475,6 +489,7 @@ run_scenario() {
*"CSV + field"*) csv_filter ;; *"CSV + field"*) csv_filter ;;
*"IPC remote"*) ipc_demo ;; *"IPC remote"*) ipc_demo ;;
*"Session filter"*) session_demo ;; *"Session filter"*) session_demo ;;
*"Modifier keys"*) modifier_keys_demo ;;
*"on-select-exec"*) on_select_hook ;; *"on-select-exec"*) on_select_hook ;;
"---") "---")
echo "that's a separator, not a scenario" >&2 echo "that's a separator, not a scenario" >&2