feat: Add support for action modifier keys like ctrl,shift,alt.
This commit is contained in:
@@ -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();
|
||||
@@ -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