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