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

@@ -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

View File

@@ -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"]));
}
}

View File

@@ -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();
@@ -1442,6 +1466,142 @@ mod tests {
assert!(events.contains(&HookEventKind::Filter));
}
// -- Modifier flow-through tests --
#[tokio::test]
async fn modifiers_flow_through_to_selected_result() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx
.send(ModifiedAction::new(Action::Resize { height: 10 }))
.await;
let _ = rx.recv().await;
// Confirm with shift held
let shift = Modifiers {
shift: true,
ctrl: false,
alt: false,
};
let _ = tx
.send(ModifiedAction::with_modifiers(Action::Confirm, shift))
.await;
let result = handle
.await
.unwrap_or(Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
}));
match result {
Ok(MenuResult::Selected { modifiers, .. }) => {
assert!(modifiers.shift, "shift should be set on result");
assert!(!modifiers.ctrl);
assert!(!modifiers.alt);
}
other => panic!("expected Selected with shift, got {other:?}"),
}
}
#[tokio::test]
async fn modifiers_flow_through_to_cancelled_result() {
let (menu, tx) = test_menu();
let _rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let mods = Modifiers {
shift: false,
ctrl: true,
alt: true,
};
let _ = tx
.send(ModifiedAction::with_modifiers(Action::Cancel, mods))
.await;
let result = handle
.await
.unwrap_or(Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
}));
match result {
Ok(MenuResult::Cancelled { modifiers }) => {
assert!(!modifiers.shift);
assert!(modifiers.ctrl, "ctrl should be set");
assert!(modifiers.alt, "alt should be set");
}
other => panic!("expected Cancelled with ctrl+alt, got {other:?}"),
}
}
#[tokio::test]
async fn no_modifiers_result_has_empty_modifiers() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx
.send(ModifiedAction::new(Action::Resize { height: 10 }))
.await;
let _ = rx.recv().await;
// Confirm without modifiers
let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let result = handle
.await
.unwrap_or(Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
}));
match result {
Ok(MenuResult::Selected { modifiers, .. }) => {
assert!(
modifiers.is_empty(),
"modifiers should be empty when none held"
);
}
other => panic!("expected Selected with empty mods, got {other:?}"),
}
}
#[tokio::test]
async fn quicklist_carries_modifiers() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx
.send(ModifiedAction::new(Action::Resize { height: 10 }))
.await;
let _ = rx.recv().await;
let mods = Modifiers {
shift: true,
ctrl: false,
alt: true,
};
let _ = tx
.send(ModifiedAction::with_modifiers(Action::Quicklist, mods))
.await;
let result = handle
.await
.unwrap_or(Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
}));
match result {
Ok(MenuResult::Quicklist { modifiers, .. }) => {
assert!(modifiers.shift);
assert!(!modifiers.ctrl);
assert!(modifiers.alt);
}
other => panic!("expected Quicklist with shift+alt, got {other:?}"),
}
}
// -- SelectionState unit tests --
#[test]