feat: Add support for action modifier keys like ctrl,shift,alt.
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user