feat: Add new system for multi-selections.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1090,6 +1090,7 @@ name = "pikl-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fancy-regex 0.14.0",
|
"fancy-regex 0.14.0",
|
||||||
|
"indexmap",
|
||||||
"nucleo-matcher",
|
"nucleo-matcher",
|
||||||
"pikl-test-macros",
|
"pikl-test-macros",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] }
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
nucleo-matcher = "0.3.1"
|
nucleo-matcher = "0.3.1"
|
||||||
fancy-regex = "0.14"
|
fancy-regex = "0.14"
|
||||||
|
indexmap = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
|
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub enum Mode {
|
|||||||
#[default]
|
#[default]
|
||||||
Insert,
|
Insert,
|
||||||
Normal,
|
Normal,
|
||||||
|
Visual,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A command the menu should process. Frontends and headless
|
/// A command the menu should process. Frontends and headless
|
||||||
@@ -37,6 +38,12 @@ pub enum Action {
|
|||||||
HalfPageUp(usize),
|
HalfPageUp(usize),
|
||||||
HalfPageDown(usize),
|
HalfPageDown(usize),
|
||||||
SetMode(Mode),
|
SetMode(Mode),
|
||||||
|
ToggleSelect,
|
||||||
|
SelectRange { start: usize, end: usize },
|
||||||
|
ClearSelections,
|
||||||
|
SelectAll,
|
||||||
|
UndoSelection,
|
||||||
|
RedoSelection,
|
||||||
Confirm,
|
Confirm,
|
||||||
Quicklist,
|
Quicklist,
|
||||||
Cancel,
|
Cancel,
|
||||||
@@ -53,7 +60,7 @@ pub enum Action {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum MenuEvent {
|
pub enum MenuEvent {
|
||||||
StateChanged(ViewState),
|
StateChanged(ViewState),
|
||||||
Selected(Value),
|
Selected(Vec<(Value, usize)>),
|
||||||
Quicklist(Vec<Value>),
|
Quicklist(Vec<Value>),
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
@@ -72,6 +79,8 @@ pub struct ViewState {
|
|||||||
pub total_items: usize,
|
pub total_items: usize,
|
||||||
pub total_filtered: usize,
|
pub total_filtered: usize,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
|
pub selection_count: usize,
|
||||||
|
pub multi_enabled: bool,
|
||||||
/// Monotonically increasing counter. Each call to
|
/// Monotonically increasing counter. Each call to
|
||||||
/// `build_view_state()` bumps this, so frontends can
|
/// `build_view_state()` bumps this, so frontends can
|
||||||
/// detect duplicate broadcasts and skip redundant redraws.
|
/// detect duplicate broadcasts and skip redundant redraws.
|
||||||
@@ -87,6 +96,7 @@ pub struct VisibleItem {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub formatted_text: Option<String>,
|
pub formatted_text: Option<String>,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
|
pub selected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
|
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
|
||||||
@@ -94,7 +104,7 @@ pub struct VisibleItem {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum MenuResult {
|
pub enum MenuResult {
|
||||||
Selected { value: Value, index: usize },
|
Selected { items: Vec<(Value, usize)> },
|
||||||
Quicklist { items: Vec<(Value, usize)> },
|
Quicklist { items: Vec<(Value, usize)> },
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ pub trait Menu: Send + 'static {
|
|||||||
/// for output and hook events.
|
/// for output and hook events.
|
||||||
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
||||||
|
|
||||||
|
/// Get the JSON value of an item by its original (pre-filter)
|
||||||
|
/// index. Used by multi-select to retrieve selected items
|
||||||
|
/// that may not be in the current filtered set.
|
||||||
|
fn serialize_original(&self, original_index: usize) -> Option<&serde_json::Value>;
|
||||||
|
|
||||||
/// Get the formatted display text for a filtered item,
|
/// Get the formatted display text for a filtered item,
|
||||||
/// if a format template is configured. Returns None if
|
/// if a format template is configured. Returns None if
|
||||||
/// no template is set, in which case the raw label is
|
/// no template is set, in which case the raw label is
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ impl Menu for JsonMenu {
|
|||||||
self.filter.matched_index(filtered_index)
|
self.filter.matched_index(filtered_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serialize_original(&self, original_index: usize) -> Option<&serde_json::Value> {
|
||||||
|
self.items.get(original_index).map(|item| &item.value)
|
||||||
|
}
|
||||||
|
|
||||||
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
|
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
|
||||||
let template = self.format_template.as_ref()?;
|
let template = self.format_template.as_ref()?;
|
||||||
let orig_idx = self.filter.matched_index(filtered_index)?;
|
let orig_idx = self.filter.matched_index(filtered_index)?;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use indexmap::IndexSet;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use tracing::{debug, info, trace};
|
use tracing::{debug, info, trace};
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ pub enum ActionOutcome {
|
|||||||
/// State changed, broadcast to subscribers.
|
/// State changed, broadcast to subscribers.
|
||||||
Broadcast,
|
Broadcast,
|
||||||
/// User confirmed a selection.
|
/// User confirmed a selection.
|
||||||
Selected { value: Value, index: usize },
|
Selected { items: Vec<(Value, usize)> },
|
||||||
/// User cancelled.
|
/// User cancelled.
|
||||||
Cancelled,
|
Cancelled,
|
||||||
/// Menu closed by hook command.
|
/// Menu closed by hook command.
|
||||||
@@ -34,6 +35,100 @@ pub enum ActionOutcome {
|
|||||||
NoOp,
|
NoOp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tracks multi-select state: which items are selected (by
|
||||||
|
/// original index), with undo/redo support.
|
||||||
|
pub struct SelectionState {
|
||||||
|
selected: IndexSet<usize>,
|
||||||
|
undo_stack: Vec<IndexSet<usize>>,
|
||||||
|
redo_stack: Vec<IndexSet<usize>>,
|
||||||
|
multi_enabled: bool,
|
||||||
|
selection_order: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SelectionState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: IndexSet::new(),
|
||||||
|
undo_stack: Vec::new(),
|
||||||
|
redo_stack: Vec::new(),
|
||||||
|
multi_enabled: false,
|
||||||
|
selection_order: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_undo(&mut self) {
|
||||||
|
self.undo_stack.push(self.selected.clone());
|
||||||
|
self.redo_stack.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle an item in/out of the selection set.
|
||||||
|
pub fn toggle(&mut self, original_index: usize) {
|
||||||
|
self.push_undo();
|
||||||
|
if !self.selected.shift_remove(&original_index) {
|
||||||
|
self.selected.insert(original_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-select a range of items (no toggle).
|
||||||
|
pub fn select_range(&mut self, indices: impl Iterator<Item = usize>) {
|
||||||
|
self.push_undo();
|
||||||
|
for idx in indices {
|
||||||
|
self.selected.insert(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select all the given items.
|
||||||
|
pub fn select_all(&mut self, indices: impl Iterator<Item = usize>) {
|
||||||
|
self.push_undo();
|
||||||
|
for idx in indices {
|
||||||
|
self.selected.insert(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all selections.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
if self.selected.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.push_undo();
|
||||||
|
self.selected.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo the last selection change.
|
||||||
|
pub fn undo(&mut self) {
|
||||||
|
if let Some(prev) = self.undo_stack.pop() {
|
||||||
|
self.redo_stack.push(std::mem::replace(&mut self.selected, prev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redo a previously undone selection change.
|
||||||
|
pub fn redo(&mut self) {
|
||||||
|
if let Some(next) = self.redo_stack.pop() {
|
||||||
|
self.undo_stack.push(std::mem::replace(&mut self.selected, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_selected(&self, original_index: usize) -> bool {
|
||||||
|
self.selected.contains(&original_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.selected.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items sorted by original index (input order).
|
||||||
|
pub fn ordered_items(&self) -> Vec<usize> {
|
||||||
|
let mut items: Vec<usize> = self.selected.iter().copied().collect();
|
||||||
|
items.sort_unstable();
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items in insertion order (the order they were selected).
|
||||||
|
pub fn insertion_ordered(&self) -> Vec<usize> {
|
||||||
|
self.selected.iter().copied().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The menu engine. Wraps any [`Menu`] implementation and
|
/// The menu engine. Wraps any [`Menu`] implementation and
|
||||||
/// drives it with an action/event channel loop. Create one,
|
/// drives it with an action/event channel loop. Create one,
|
||||||
/// grab the action sender and event subscriber, then call
|
/// grab the action sender and event subscriber, then call
|
||||||
@@ -48,6 +143,8 @@ pub struct MenuRunner<M: MutableMenu> {
|
|||||||
dispatcher: Option<DebouncedDispatcher>,
|
dispatcher: Option<DebouncedDispatcher>,
|
||||||
previous_cursor: Option<usize>,
|
previous_cursor: Option<usize>,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
|
selection: SelectionState,
|
||||||
|
visual_anchor: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M: MutableMenu> MenuRunner<M> {
|
impl<M: MutableMenu> MenuRunner<M> {
|
||||||
@@ -72,6 +169,8 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
dispatcher: None,
|
dispatcher: None,
|
||||||
previous_cursor: None,
|
previous_cursor: None,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
|
selection: SelectionState::new(),
|
||||||
|
visual_anchor: None,
|
||||||
};
|
};
|
||||||
(runner, action_tx)
|
(runner, action_tx)
|
||||||
}
|
}
|
||||||
@@ -99,6 +198,17 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
self.dispatcher = Some(dispatcher);
|
self.dispatcher = Some(dispatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable multi-select mode.
|
||||||
|
pub fn set_multi(&mut self, enabled: bool) {
|
||||||
|
self.selection.multi_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable selection-order output (insertion order instead
|
||||||
|
/// of input order).
|
||||||
|
pub fn set_selection_order(&mut self, enabled: bool) {
|
||||||
|
self.selection.selection_order = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-run the filter against all items with the current
|
/// Re-run the filter against all items with the current
|
||||||
/// filter text. Updates the viewport with the new count.
|
/// filter text. Updates the viewport with the new count.
|
||||||
fn run_filter(&mut self) {
|
fn run_filter(&mut self) {
|
||||||
@@ -127,10 +237,12 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
.filter_map(|i| {
|
.filter_map(|i| {
|
||||||
self.menu.filtered_label(i).map(|label| {
|
self.menu.filtered_label(i).map(|label| {
|
||||||
let formatted_text = self.menu.formatted_label(i);
|
let formatted_text = self.menu.formatted_label(i);
|
||||||
|
let orig_idx = self.menu.original_index(i).unwrap_or(0);
|
||||||
VisibleItem {
|
VisibleItem {
|
||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
formatted_text,
|
formatted_text,
|
||||||
index: i,
|
index: i,
|
||||||
|
selected: self.selection.is_selected(orig_idx),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -149,6 +261,8 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
total_items: self.menu.total(),
|
total_items: self.menu.total(),
|
||||||
total_filtered: self.menu.filtered_count(),
|
total_filtered: self.menu.filtered_count(),
|
||||||
mode: self.mode,
|
mode: self.mode,
|
||||||
|
selection_count: self.selection.count(),
|
||||||
|
multi_enabled: self.selection.multi_enabled,
|
||||||
generation: self.generation,
|
generation: self.generation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +343,76 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
self.viewport.page_down(n);
|
self.viewport.page_down(n);
|
||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
|
Action::ToggleSelect => {
|
||||||
|
if !self.selection.multi_enabled {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
if self.menu.filtered_count() == 0 {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
let cursor = self.viewport.cursor();
|
||||||
|
if let Some(orig_idx) = self.menu.original_index(cursor) {
|
||||||
|
self.selection.toggle(orig_idx);
|
||||||
|
}
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::SelectRange { start, end } => {
|
||||||
|
if !self.selection.multi_enabled {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
let min = start.min(end);
|
||||||
|
let max = start.max(end);
|
||||||
|
let indices: Vec<usize> = (min..=max)
|
||||||
|
.filter_map(|i| self.menu.original_index(i))
|
||||||
|
.collect();
|
||||||
|
self.selection.select_range(indices.into_iter());
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::ClearSelections => {
|
||||||
|
self.selection.clear();
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::SelectAll => {
|
||||||
|
if !self.selection.multi_enabled {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
let indices: Vec<usize> = (0..self.menu.filtered_count())
|
||||||
|
.filter_map(|i| self.menu.original_index(i))
|
||||||
|
.collect();
|
||||||
|
self.selection.select_all(indices.into_iter());
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::UndoSelection => {
|
||||||
|
self.selection.undo();
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::RedoSelection => {
|
||||||
|
self.selection.redo();
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
Action::Confirm => {
|
Action::Confirm => {
|
||||||
|
if self.menu.filtered_count() == 0 && self.selection.count() == 0 {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
if self.selection.multi_enabled && self.selection.count() > 0 {
|
||||||
|
// Multi-select: return all selected items
|
||||||
|
let indices = if self.selection.selection_order {
|
||||||
|
self.selection.insertion_ordered()
|
||||||
|
} else {
|
||||||
|
self.selection.ordered_items()
|
||||||
|
};
|
||||||
|
let items: Vec<(Value, usize)> = indices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|orig_idx| {
|
||||||
|
self.menu.serialize_original(orig_idx).map(|v| (v.clone(), orig_idx))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if items.is_empty() {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
ActionOutcome::Selected { items }
|
||||||
|
} else {
|
||||||
|
// Single-select: cursor item as one-element vec
|
||||||
if self.menu.filtered_count() == 0 {
|
if self.menu.filtered_count() == 0 {
|
||||||
return ActionOutcome::NoOp;
|
return ActionOutcome::NoOp;
|
||||||
}
|
}
|
||||||
@@ -237,12 +420,12 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
let index = self.menu.original_index(cursor).unwrap_or(0);
|
let index = self.menu.original_index(cursor).unwrap_or(0);
|
||||||
match self.menu.serialize_filtered(cursor) {
|
match self.menu.serialize_filtered(cursor) {
|
||||||
Some(value) => ActionOutcome::Selected {
|
Some(value) => ActionOutcome::Selected {
|
||||||
value: value.clone(),
|
items: vec![(value.clone(), index)],
|
||||||
index,
|
|
||||||
},
|
},
|
||||||
None => ActionOutcome::NoOp,
|
None => ActionOutcome::NoOp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Action::Quicklist => {
|
Action::Quicklist => {
|
||||||
if self.menu.filtered_count() == 0 {
|
if self.menu.filtered_count() == 0 {
|
||||||
return ActionOutcome::NoOp;
|
return ActionOutcome::NoOp;
|
||||||
@@ -271,6 +454,11 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
}
|
}
|
||||||
Action::SetMode(m) => {
|
Action::SetMode(m) => {
|
||||||
debug!(mode = ?m, "mode changed");
|
debug!(mode = ?m, "mode changed");
|
||||||
|
if m == Mode::Visual {
|
||||||
|
self.visual_anchor = Some(self.viewport.cursor());
|
||||||
|
} else if self.mode == Mode::Visual {
|
||||||
|
self.visual_anchor = None;
|
||||||
|
}
|
||||||
self.mode = m;
|
self.mode = m;
|
||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
@@ -367,18 +555,21 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
// Check for cursor movement -> Hover
|
// Check for cursor movement -> Hover
|
||||||
self.check_cursor_hover();
|
self.check_cursor_hover();
|
||||||
}
|
}
|
||||||
ActionOutcome::Selected { value, index } => {
|
ActionOutcome::Selected { items } => {
|
||||||
info!(index, "item selected");
|
let count = items.len();
|
||||||
// Emit Select event
|
info!(count, "item(s) selected");
|
||||||
|
// Emit Select event for the first item (primary selection)
|
||||||
|
if let Some((value, index)) = items.first() {
|
||||||
self.emit_hook(HookEvent::Select {
|
self.emit_hook(HookEvent::Select {
|
||||||
item: value.clone(),
|
item: value.clone(),
|
||||||
index,
|
index: *index,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Emit Close event
|
// Emit Close event
|
||||||
self.emit_hook(HookEvent::Close);
|
self.emit_hook(HookEvent::Close);
|
||||||
|
|
||||||
let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
|
let _ = self.event_tx.send(MenuEvent::Selected(items.clone()));
|
||||||
return Ok(MenuResult::Selected { value, index });
|
return Ok(MenuResult::Selected { items });
|
||||||
}
|
}
|
||||||
ActionOutcome::Quicklist { items } => {
|
ActionOutcome::Quicklist { items } => {
|
||||||
let values: Vec<Value> =
|
let values: Vec<Value> =
|
||||||
@@ -482,7 +673,7 @@ mod tests {
|
|||||||
let mut m = ready_menu();
|
let mut m = ready_menu();
|
||||||
m.apply_action(Action::MoveDown(1));
|
m.apply_action(Action::MoveDown(1));
|
||||||
let outcome = m.apply_action(Action::Confirm);
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
assert!(matches!(&outcome, ActionOutcome::Selected { value, .. } if value.as_str() == Some("beta")));
|
assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -605,8 +796,8 @@ mod tests {
|
|||||||
let _ = tx.send(Action::Confirm).await;
|
let _ = tx.send(Action::Confirm).await;
|
||||||
|
|
||||||
// Should get Selected event
|
// Should get Selected event
|
||||||
if let Ok(MenuEvent::Selected(value)) = rx.recv().await {
|
if let Ok(MenuEvent::Selected(items)) = rx.recv().await {
|
||||||
assert_eq!(value.as_str(), Some("beta"));
|
assert_eq!(items[0].0.as_str(), Some("beta"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||||
@@ -668,10 +859,10 @@ mod tests {
|
|||||||
let _ = tx.send(Action::Confirm).await;
|
let _ = tx.send(Action::Confirm).await;
|
||||||
|
|
||||||
let event = rx.recv().await;
|
let event = rx.recv().await;
|
||||||
assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha")));
|
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));
|
||||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("alpha")));
|
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -693,7 +884,7 @@ mod tests {
|
|||||||
let _ = tx.send(Action::Confirm).await;
|
let _ = tx.send(Action::Confirm).await;
|
||||||
|
|
||||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("gamma")));
|
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -718,7 +909,7 @@ mod tests {
|
|||||||
let _ = tx.send(Action::Confirm).await;
|
let _ = tx.send(Action::Confirm).await;
|
||||||
|
|
||||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")));
|
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -753,7 +944,7 @@ mod tests {
|
|||||||
let _ = tx.send(Action::Confirm).await;
|
let _ = tx.send(Action::Confirm).await;
|
||||||
|
|
||||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("epsilon")));
|
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -805,7 +996,7 @@ mod tests {
|
|||||||
// Must get "banana". Filter was applied before confirm ran.
|
// Must get "banana". Filter was applied before confirm ran.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana")
|
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("banana")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,7 +1022,7 @@ mod tests {
|
|||||||
// Cursor at index 3 -> "delta"
|
// Cursor at index 3 -> "delta"
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")
|
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +1118,7 @@ mod tests {
|
|||||||
// Must find "zephyr". It was added before the filter ran.
|
// Must find "zephyr". It was added before the filter ran.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr")
|
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("zephyr")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,7 +1199,7 @@ mod tests {
|
|||||||
assert!(m.menu.filtered_count() >= 1);
|
assert!(m.menu.filtered_count() >= 1);
|
||||||
let outcome = m.apply_action(Action::Confirm);
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
// "delta" is at original index 3
|
// "delta" is at original index 3
|
||||||
assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. }));
|
assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].1 == 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Quicklist tests --
|
// -- Quicklist tests --
|
||||||
@@ -1173,4 +1364,301 @@ mod tests {
|
|||||||
assert!(events.contains(&HookEventKind::Open));
|
assert!(events.contains(&HookEventKind::Open));
|
||||||
assert!(events.contains(&HookEventKind::Filter));
|
assert!(events.contains(&HookEventKind::Filter));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- SelectionState unit tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_toggle_adds_and_removes() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(3);
|
||||||
|
assert!(s.is_selected(3));
|
||||||
|
assert_eq!(s.count(), 1);
|
||||||
|
s.toggle(3);
|
||||||
|
assert!(!s.is_selected(3));
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_undo_redo() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(1);
|
||||||
|
s.toggle(2);
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
s.undo();
|
||||||
|
assert_eq!(s.count(), 1);
|
||||||
|
assert!(s.is_selected(1));
|
||||||
|
assert!(!s.is_selected(2));
|
||||||
|
s.redo();
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
assert!(s.is_selected(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_undo_on_empty_is_noop() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.undo();
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_redo_on_empty_is_noop() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.redo();
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_clear_then_undo_restores() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(0);
|
||||||
|
s.toggle(2);
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
s.clear();
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
s.undo();
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
assert!(s.is_selected(0));
|
||||||
|
assert!(s.is_selected(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_select_range() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.select_range([1, 2, 3].into_iter());
|
||||||
|
assert_eq!(s.count(), 3);
|
||||||
|
assert!(s.is_selected(1));
|
||||||
|
assert!(s.is_selected(2));
|
||||||
|
assert!(s.is_selected(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_select_all() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.select_all([0, 1, 2, 3].into_iter());
|
||||||
|
assert_eq!(s.count(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_ordered_items_sorted() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(3);
|
||||||
|
s.toggle(1);
|
||||||
|
s.toggle(0);
|
||||||
|
assert_eq!(s.ordered_items(), vec![0, 1, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_insertion_ordered() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(3);
|
||||||
|
s.toggle(1);
|
||||||
|
s.toggle(0);
|
||||||
|
assert_eq!(s.insertion_ordered(), vec![3, 1, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_new_action_clears_redo() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(1);
|
||||||
|
s.toggle(2);
|
||||||
|
s.undo(); // redo stack has the {1,2} state
|
||||||
|
s.toggle(3); // new action, should clear redo
|
||||||
|
s.redo(); // should be noop
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
assert!(s.is_selected(1));
|
||||||
|
assert!(s.is_selected(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Multi-select integration tests (apply_action) --
|
||||||
|
|
||||||
|
fn ready_multi_menu() -> MenuRunner<JsonMenu> {
|
||||||
|
let (mut m, _tx) = test_menu();
|
||||||
|
m.set_multi(true);
|
||||||
|
m.run_filter();
|
||||||
|
m.apply_action(Action::Resize { height: 10 });
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_then_confirm_returns_selected() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::MoveDown(1)); // cursor on "beta"
|
||||||
|
m.apply_action(Action::ToggleSelect);
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("beta"));
|
||||||
|
assert_eq!(items[0].1, 1);
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_two_items_confirm_returns_both() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select "alpha" (0)
|
||||||
|
m.apply_action(Action::MoveDown(2)); // cursor on "gamma" (2)
|
||||||
|
m.apply_action(Action::ToggleSelect);
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
// Default order = input order
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("alpha"));
|
||||||
|
assert_eq!(items[1].0.as_str(), Some("gamma"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_then_untoggle_falls_back_to_cursor() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::MoveDown(1)); // cursor on "beta"
|
||||||
|
m.apply_action(Action::ToggleSelect);
|
||||||
|
m.apply_action(Action::ToggleSelect); // untoggle
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
// No selections, fallback to cursor
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("beta"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_no_selections_returns_cursor() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::MoveDown(2)); // cursor on "gamma"
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("gamma"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_all_then_confirm() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::SelectAll);
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 4);
|
||||||
|
let labels: Vec<&str> = items.iter().filter_map(|(v, _)| v.as_str()).collect();
|
||||||
|
assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]);
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_selections_after_select() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select alpha
|
||||||
|
m.apply_action(Action::ClearSelections);
|
||||||
|
assert_eq!(m.selection.count(), 0);
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("alpha")); // cursor item
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_redo_through_apply_action() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select alpha
|
||||||
|
m.apply_action(Action::MoveDown(1));
|
||||||
|
m.apply_action(Action::ToggleSelect); // select beta
|
||||||
|
assert_eq!(m.selection.count(), 2);
|
||||||
|
m.apply_action(Action::UndoSelection);
|
||||||
|
assert_eq!(m.selection.count(), 1);
|
||||||
|
assert!(m.selection.is_selected(0)); // alpha
|
||||||
|
assert!(!m.selection.is_selected(1)); // beta undone
|
||||||
|
m.apply_action(Action::RedoSelection);
|
||||||
|
assert_eq!(m.selection.count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selections_survive_filter_change() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select alpha (orig idx 0)
|
||||||
|
m.apply_action(Action::MoveDown(1));
|
||||||
|
m.apply_action(Action::ToggleSelect); // select beta (orig idx 1)
|
||||||
|
// Change filter
|
||||||
|
m.apply_action(Action::UpdateFilter("del".to_string()));
|
||||||
|
// Selections should survive
|
||||||
|
assert_eq!(m.selection.count(), 2);
|
||||||
|
assert!(m.selection.is_selected(0));
|
||||||
|
assert!(m.selection.is_selected(1));
|
||||||
|
// Confirm should return the selected items (alpha, beta) even
|
||||||
|
// though they're not in the filtered set
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("alpha"));
|
||||||
|
assert_eq!(items[1].0.as_str(), Some("beta"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_disabled_toggle_is_noop() {
|
||||||
|
let mut m = ready_menu(); // not multi
|
||||||
|
let outcome = m.apply_action(Action::ToggleSelect);
|
||||||
|
assert!(matches!(outcome, ActionOutcome::NoOp));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_range_selects_all_in_range() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::SelectRange { start: 1, end: 3 });
|
||||||
|
assert_eq!(m.selection.count(), 3);
|
||||||
|
assert!(!m.selection.is_selected(0));
|
||||||
|
assert!(m.selection.is_selected(1));
|
||||||
|
assert!(m.selection.is_selected(2));
|
||||||
|
assert!(m.selection.is_selected(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_anchor_set_on_visual_mode() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::MoveDown(2)); // cursor at 2
|
||||||
|
m.apply_action(Action::SetMode(Mode::Visual));
|
||||||
|
assert_eq!(m.visual_anchor, Some(2));
|
||||||
|
m.apply_action(Action::SetMode(Mode::Normal));
|
||||||
|
assert_eq!(m.visual_anchor, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_state_shows_selection() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select alpha
|
||||||
|
let vs = m.build_view_state();
|
||||||
|
assert!(vs.multi_enabled);
|
||||||
|
assert_eq!(vs.selection_count, 1);
|
||||||
|
assert!(vs.visible_items[0].selected);
|
||||||
|
assert!(!vs.visible_items[1].selected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,20 +132,26 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
|
|||||||
line_number,
|
line_number,
|
||||||
line,
|
line,
|
||||||
"set-mode",
|
"set-mode",
|
||||||
"missing mode value (insert or normal)".to_string(),
|
"missing mode value (insert, normal, or visual)".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
match mode_str.trim() {
|
match mode_str.trim() {
|
||||||
"insert" => Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))),
|
"insert" => Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))),
|
||||||
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))),
|
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))),
|
||||||
|
"visual" => Ok(ScriptAction::Core(Action::SetMode(Mode::Visual))),
|
||||||
other => Err(invalid_arg(
|
other => Err(invalid_arg(
|
||||||
line_number,
|
line_number,
|
||||||
line,
|
line,
|
||||||
"set-mode",
|
"set-mode",
|
||||||
format!("unknown mode '{other}', expected insert or normal"),
|
format!("unknown mode '{other}', expected insert, normal, or visual"),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"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)),
|
"confirm" => Ok(ScriptAction::Core(Action::Confirm)),
|
||||||
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
|
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
|
||||||
"resize" => {
|
"resize" => {
|
||||||
@@ -439,7 +445,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_set_mode_invalid() {
|
fn parse_set_mode_invalid() {
|
||||||
assert!(parse_action(1, "set-mode visual").is_err());
|
assert_eq!(
|
||||||
|
parse_action(1, "set-mode visual").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::SetMode(Mode::Visual))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -645,6 +654,30 @@ mod tests {
|
|||||||
assert!(parse_action(1, "half-page-up xyz").is_err());
|
assert!(parse_action(1, "half-page-up xyz").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_selection_actions() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::ToggleSelect)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "select-all").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::SelectAll)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::ClearSelections)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::UndoSelection)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::RedoSelection)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_set_mode_wrong_case() {
|
fn parse_set_mode_wrong_case() {
|
||||||
assert!(parse_action(1, "set-mode Insert").is_err());
|
assert!(parse_action(1, "set-mode Insert").is_err());
|
||||||
|
|||||||
@@ -286,6 +286,48 @@ pikl_tests! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menu mod multi_select_basic {
|
||||||
|
items: ["alpha", "bravo", "charlie", "delta"];
|
||||||
|
multi: true;
|
||||||
|
|
||||||
|
test toggle_one_then_confirm {
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
selected_items: ["alpha"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test toggle_two_then_confirm {
|
||||||
|
actions: [toggle-select, move-down, move-down, toggle-select, confirm]
|
||||||
|
selected_items: ["alpha", "charlie"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test no_selection_falls_back_to_cursor {
|
||||||
|
actions: [move-down, confirm]
|
||||||
|
selected: "bravo"
|
||||||
|
}
|
||||||
|
|
||||||
|
test select_all_then_confirm {
|
||||||
|
actions: [select-all, confirm]
|
||||||
|
selected_items: ["alpha", "bravo", "charlie", "delta"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test clear_selections_resets {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, clear-selections, confirm]
|
||||||
|
// Falls back to cursor item (bravo, since we moved down once)
|
||||||
|
selected: "bravo"
|
||||||
|
}
|
||||||
|
|
||||||
|
test undo_restores_previous {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, undo-selection, confirm]
|
||||||
|
// Undo reverts the second toggle, only alpha remains
|
||||||
|
selected_items: ["alpha"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test redo_after_undo {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, undo-selection, redo-selection, confirm]
|
||||||
|
selected_items: ["alpha", "bravo"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nav mod half_page_small_height {
|
nav mod half_page_small_height {
|
||||||
viewport: { height: 2, count: 10 };
|
viewport: { height: 2, count: 10 };
|
||||||
|
|
||||||
|
|||||||
@@ -92,12 +92,15 @@ fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream
|
|||||||
// Build script string from actions
|
// Build script string from actions
|
||||||
let script = build_headless_script(&case.actions);
|
let script = build_headless_script(&case.actions);
|
||||||
|
|
||||||
// Build extra CLI args (e.g. --label-key)
|
// Build extra CLI args (e.g. --label-key, --multi)
|
||||||
let extra_args: Vec<TokenStream> = if let Some(ref key) = fixtures.label_key {
|
let mut extra_args: Vec<TokenStream> = Vec::new();
|
||||||
vec![quote! { "--label-key" }, quote! { #key }]
|
if let Some(ref key) = fixtures.label_key {
|
||||||
} else {
|
extra_args.push(quote! { "--label-key" });
|
||||||
Vec::new()
|
extra_args.push(quote! { #key });
|
||||||
};
|
}
|
||||||
|
if fixtures.multi == Some(true) {
|
||||||
|
extra_args.push(quote! { "--multi" });
|
||||||
|
}
|
||||||
|
|
||||||
// Build assertions
|
// Build assertions
|
||||||
let mut asserts = Vec::new();
|
let mut asserts = Vec::new();
|
||||||
@@ -327,7 +330,9 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
|||||||
} else if let Some(ref expected) = case.selected {
|
} else if let Some(ref expected) = case.selected {
|
||||||
quote! {
|
quote! {
|
||||||
match &result {
|
match &result {
|
||||||
Ok(MenuResult::Selected { value, .. }) => {
|
Ok(MenuResult::Selected { items, .. }) => {
|
||||||
|
assert!(!items.is_empty(), "expected Selected with items, got empty");
|
||||||
|
let value = &items[0].0;
|
||||||
let got = value.as_str()
|
let got = value.as_str()
|
||||||
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
|
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
@@ -339,6 +344,25 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
|||||||
other => panic!("expected Selected, got: {:?}", other),
|
other => panic!("expected Selected, got: {:?}", other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let Some(ref expected) = case.selected_items {
|
||||||
|
quote! {
|
||||||
|
match &result {
|
||||||
|
Ok(MenuResult::Selected { items, .. }) => {
|
||||||
|
let mut labels: Vec<&str> = items.iter()
|
||||||
|
.filter_map(|(v, _)| v.as_str()
|
||||||
|
.or_else(|| v.get(#label_key).and_then(|v| v.as_str())))
|
||||||
|
.collect();
|
||||||
|
labels.sort();
|
||||||
|
let mut expected = vec![#(#expected),*];
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(
|
||||||
|
labels, expected,
|
||||||
|
"expected selected_items {:?}, got {:?}", expected, labels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// No assertion on result. Probably an error, but let it compile.
|
// No assertion on result. Probably an error, but let it compile.
|
||||||
quote! {}
|
quote! {}
|
||||||
@@ -348,11 +372,18 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
|||||||
// we need to drop tx after sending actions.
|
// we need to drop tx after sending actions.
|
||||||
let drop_sender = quote! { drop(tx); };
|
let drop_sender = quote! { drop(tx); };
|
||||||
|
|
||||||
|
let multi_setup = if fixtures.multi == Some(true) {
|
||||||
|
quote! { menu.set_multi(true); }
|
||||||
|
} else {
|
||||||
|
quote! {}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn #test_name() {
|
async fn #test_name() {
|
||||||
let items = vec![#(#item_exprs),*];
|
let items = vec![#(#item_exprs),*];
|
||||||
let (menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
|
let (mut menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
|
||||||
|
#multi_setup
|
||||||
let mut rx = menu.subscribe();
|
let mut rx = menu.subscribe();
|
||||||
let handle = tokio::spawn(async move { menu.run().await });
|
let handle = tokio::spawn(async move { menu.run().await });
|
||||||
|
|
||||||
@@ -427,6 +458,12 @@ fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
|
|||||||
"half-page-down" => quote! { Action::HalfPageDown(1) },
|
"half-page-down" => quote! { Action::HalfPageDown(1) },
|
||||||
"set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) },
|
"set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) },
|
||||||
"set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) },
|
"set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) },
|
||||||
|
"set-mode-visual" => quote! { Action::SetMode(pikl_core::event::Mode::Visual) },
|
||||||
|
"toggle-select" => quote! { Action::ToggleSelect },
|
||||||
|
"select-all" => quote! { Action::SelectAll },
|
||||||
|
"clear-selections" => quote! { Action::ClearSelections },
|
||||||
|
"undo-selection" => quote! { Action::UndoSelection },
|
||||||
|
"redo-selection" => quote! { Action::RedoSelection },
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
Span::call_site(),
|
Span::call_site(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub struct Fixtures {
|
|||||||
pub items: Option<Vec<String>>,
|
pub items: Option<Vec<String>>,
|
||||||
pub label_key: Option<String>,
|
pub label_key: Option<String>,
|
||||||
pub viewport: Option<(usize, usize)>, // (height, count)
|
pub viewport: Option<(usize, usize)>, // (height, count)
|
||||||
|
pub multi: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TestCase {
|
pub struct TestCase {
|
||||||
@@ -44,6 +45,7 @@ pub struct TestCase {
|
|||||||
pub cursor: Option<usize>,
|
pub cursor: Option<usize>,
|
||||||
pub offset: Option<usize>,
|
pub offset: Option<usize>,
|
||||||
pub selected: Option<String>,
|
pub selected: Option<String>,
|
||||||
|
pub selected_items: Option<Vec<String>>,
|
||||||
pub cancelled: bool,
|
pub cancelled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ impl Parse for TestModule {
|
|||||||
items: None,
|
items: None,
|
||||||
label_key: None,
|
label_key: None,
|
||||||
viewport: None,
|
viewport: None,
|
||||||
|
multi: None,
|
||||||
};
|
};
|
||||||
let mut tests = Vec::new();
|
let mut tests = Vec::new();
|
||||||
|
|
||||||
@@ -120,11 +123,18 @@ impl Parse for TestModule {
|
|||||||
fixtures.viewport = Some(parse_viewport_def(&content)?);
|
fixtures.viewport = Some(parse_viewport_def(&content)?);
|
||||||
eat_semi(&content);
|
eat_semi(&content);
|
||||||
}
|
}
|
||||||
|
"multi" => {
|
||||||
|
consume_ident_or_keyword(&content)?;
|
||||||
|
content.parse::<Token![:]>()?;
|
||||||
|
let val: syn::LitBool = content.parse()?;
|
||||||
|
fixtures.multi = Some(val.value);
|
||||||
|
eat_semi(&content);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
content.span(),
|
content.span(),
|
||||||
format!(
|
format!(
|
||||||
"unexpected field '{ident_str}', expected test, items, label_key, or viewport"
|
"unexpected field '{ident_str}', expected test, items, label_key, viewport, or multi"
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -163,6 +173,7 @@ fn parse_test_case(input: ParseStream) -> syn::Result<TestCase> {
|
|||||||
cursor: None,
|
cursor: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
selected: None,
|
selected: None,
|
||||||
|
selected_items: None,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,6 +235,10 @@ fn parse_test_case(input: ParseStream) -> syn::Result<TestCase> {
|
|||||||
let val: LitStr = content.parse()?;
|
let val: LitStr = content.parse()?;
|
||||||
case.selected = Some(val.value());
|
case.selected = Some(val.value());
|
||||||
}
|
}
|
||||||
|
"selected_items" => {
|
||||||
|
content.parse::<Token![:]>()?;
|
||||||
|
case.selected_items = Some(parse_string_list(&content)?);
|
||||||
|
}
|
||||||
"cancelled" => {
|
"cancelled" => {
|
||||||
// Just the keyword presence means true. Optionally parse `: true`.
|
// Just the keyword presence means true. Optionally parse `: true`.
|
||||||
if content.peek(Token![:]) {
|
if content.peek(Token![:]) {
|
||||||
|
|||||||
@@ -83,12 +83,15 @@ async fn run_inner(
|
|||||||
let mut mode = Mode::Insert;
|
let mut mode = Mode::Insert;
|
||||||
let mut pending = PendingKey::None;
|
let mut pending = PendingKey::None;
|
||||||
let mut last_generation: u64 = 0;
|
let mut last_generation: u64 = 0;
|
||||||
|
let mut multi_enabled = false;
|
||||||
|
let mut visual_anchor: Option<usize> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Some(ref vs) = view_state {
|
if let Some(ref vs) = view_state {
|
||||||
let ft = filter_text.clone();
|
let ft = filter_text.clone();
|
||||||
|
let anchor = visual_anchor;
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
render_menu(frame, vs, &ft);
|
render_menu(frame, vs, &ft, anchor);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +103,24 @@ async fn run_inner(
|
|||||||
let event = event_result?;
|
let event = event_result?;
|
||||||
match event {
|
match event {
|
||||||
Event::Key(key) => {
|
Event::Key(key) => {
|
||||||
if let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending) {
|
// Handle multi-action keys (Tab, Space in normal/visual)
|
||||||
|
// before the single-action map_key_event path.
|
||||||
|
let handled = handle_multi_action_key(
|
||||||
|
key, mode, multi_enabled, &mut visual_anchor,
|
||||||
|
&view_state, action_tx,
|
||||||
|
).await;
|
||||||
|
if !handled
|
||||||
|
&& let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending)
|
||||||
|
{
|
||||||
// Track mode locally for key mapping
|
// Track mode locally for key mapping
|
||||||
if let Action::SetMode(m) = &action {
|
if let Action::SetMode(m) = &action {
|
||||||
|
if *m == Mode::Visual {
|
||||||
|
if let Some(ref vs) = view_state {
|
||||||
|
visual_anchor = Some(vs.cursor);
|
||||||
|
}
|
||||||
|
} else if mode == Mode::Visual {
|
||||||
|
visual_anchor = None;
|
||||||
|
}
|
||||||
mode = *m;
|
mode = *m;
|
||||||
pending = PendingKey::None;
|
pending = PendingKey::None;
|
||||||
}
|
}
|
||||||
@@ -136,6 +154,7 @@ async fn run_inner(
|
|||||||
mode = vs.mode;
|
mode = vs.mode;
|
||||||
pending = PendingKey::None;
|
pending = PendingKey::None;
|
||||||
}
|
}
|
||||||
|
multi_enabled = vs.multi_enabled;
|
||||||
view_state = Some(vs);
|
view_state = Some(vs);
|
||||||
}
|
}
|
||||||
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
|
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
|
||||||
@@ -155,9 +174,83 @@ async fn run_inner(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle keys that need to send multiple actions (Tab, Space
|
||||||
|
/// in normal mode, visual mode apply/cancel). Returns true
|
||||||
|
/// if the key was consumed.
|
||||||
|
async fn handle_multi_action_key(
|
||||||
|
key: KeyEvent,
|
||||||
|
mode: Mode,
|
||||||
|
multi_enabled: bool,
|
||||||
|
visual_anchor: &mut Option<usize>,
|
||||||
|
view_state: &Option<ViewState>,
|
||||||
|
action_tx: &mpsc::Sender<Action>,
|
||||||
|
) -> bool {
|
||||||
|
match mode {
|
||||||
|
Mode::Insert => {
|
||||||
|
if !multi_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||||
|
let _ = action_tx.send(Action::MoveDown(1)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||||
|
let _ = action_tx.send(Action::MoveUp(1)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Normal => {
|
||||||
|
if !multi_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(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;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
(KeyCode::Tab, _) => {
|
||||||
|
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||||
|
let _ = action_tx.send(Action::MoveDown(1)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
(KeyCode::BackTab, _) => {
|
||||||
|
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||||
|
let _ = action_tx.send(Action::MoveUp(1)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Visual => {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char(' '), m) | (KeyCode::Enter, m) if !m.intersects(KeyModifiers::ALT) => {
|
||||||
|
// Apply visual selection
|
||||||
|
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
|
||||||
|
let cursor = vs.cursor;
|
||||||
|
let _ = action_tx.send(Action::SelectRange {
|
||||||
|
start: anchor,
|
||||||
|
end: cursor,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
*visual_anchor = None;
|
||||||
|
let _ = action_tx.send(Action::SetMode(Mode::Normal)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render the menu into the given frame. Extracted from the
|
/// Render the menu into the given frame. Extracted from the
|
||||||
/// event loop so it can be tested with a [`TestBackend`].
|
/// event loop so it can be tested with a [`TestBackend`].
|
||||||
fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, visual_anchor: Option<usize>) {
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
||||||
@@ -169,9 +262,10 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
let mode_indicator = match vs.mode {
|
let mode_indicator = match vs.mode {
|
||||||
Mode::Insert => "[I]",
|
Mode::Insert => "[I]",
|
||||||
Mode::Normal => "[N]",
|
Mode::Normal => "[N]",
|
||||||
|
Mode::Visual => "[V]",
|
||||||
};
|
};
|
||||||
|
|
||||||
let prompt = Paragraph::new(Line::from(vec![
|
let mut prompt_spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{mode_indicator}> "),
|
format!("{mode_indicator}> "),
|
||||||
Style::default().fg(Color::Cyan),
|
Style::default().fg(Color::Cyan),
|
||||||
@@ -181,7 +275,16 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
format!(" {filtered_count}/{total_count}"),
|
format!(" {filtered_count}/{total_count}"),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
),
|
),
|
||||||
]));
|
];
|
||||||
|
|
||||||
|
if vs.selection_count > 0 {
|
||||||
|
prompt_spans.push(Span::styled(
|
||||||
|
format!(" [{}]", vs.selection_count),
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = Paragraph::new(Line::from(prompt_spans));
|
||||||
frame.render_widget(prompt, chunks[0]);
|
frame.render_widget(prompt, chunks[0]);
|
||||||
|
|
||||||
// mode_indicator len + "> " = mode_indicator.len() + 2
|
// mode_indicator len + "> " = mode_indicator.len() + 2
|
||||||
@@ -192,18 +295,42 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0));
|
frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine visual range for highlighting
|
||||||
|
let visual_range = if vs.mode == Mode::Visual {
|
||||||
|
visual_anchor.map(|anchor| {
|
||||||
|
let min = anchor.min(vs.cursor);
|
||||||
|
let max = anchor.max(vs.cursor);
|
||||||
|
min..=max
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let items: Vec<ListItem> = vs
|
let items: Vec<ListItem> = vs
|
||||||
.visible_items
|
.visible_items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, vi)| {
|
.map(|(i, vi)| {
|
||||||
let style = if i == vs.cursor {
|
let in_visual = visual_range.as_ref().is_some_and(|r| r.contains(&i));
|
||||||
|
let mut style = if i == vs.cursor {
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
|
} else if in_visual {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Blue)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if vi.selected {
|
||||||
|
style = style.fg(Color::Green);
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker = if vi.selected { "* " } else { " " };
|
||||||
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
||||||
|
if vs.multi_enabled {
|
||||||
|
ListItem::new(format!("{marker}{text}")).style(style)
|
||||||
|
} else {
|
||||||
ListItem::new(text).style(style)
|
ListItem::new(text).style(style)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -223,6 +350,7 @@ fn map_key_event(
|
|||||||
match mode {
|
match mode {
|
||||||
Mode::Insert => map_insert_mode(key, filter_text),
|
Mode::Insert => map_insert_mode(key, filter_text),
|
||||||
Mode::Normal => map_normal_mode(key, pending),
|
Mode::Normal => map_normal_mode(key, pending),
|
||||||
|
Mode::Visual => map_visual_mode(key, pending),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +417,12 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
|||||||
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
Some(Action::Cancel)
|
Some(Action::Cancel)
|
||||||
}
|
}
|
||||||
|
(KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Visual)),
|
||||||
|
(KeyCode::Char('U'), _) => Some(Action::ClearSelections),
|
||||||
|
(KeyCode::Char('u'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
Some(Action::UndoSelection)
|
||||||
|
}
|
||||||
|
(KeyCode::Char('r'), KeyModifiers::CONTROL) => Some(Action::RedoSelection),
|
||||||
(KeyCode::Enter, _) => Some(Action::Confirm),
|
(KeyCode::Enter, _) => Some(Action::Confirm),
|
||||||
(KeyCode::Esc, _) => Some(Action::Cancel),
|
(KeyCode::Esc, _) => Some(Action::Cancel),
|
||||||
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
|
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
|
||||||
@@ -297,6 +431,41 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visual mode: movement keys extend the range, V cancels,
|
||||||
|
/// Space/Enter apply (handled in handle_multi_action_key).
|
||||||
|
fn map_visual_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
||||||
|
// Handle pending `g` key for `gg` sequence
|
||||||
|
if *pending == PendingKey::G {
|
||||||
|
*pending = PendingKey::None;
|
||||||
|
if key.code == KeyCode::Char('g')
|
||||||
|
&& !key
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||||
|
{
|
||||||
|
return Some(Action::MoveToTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('j'), m) | (KeyCode::Down, m) if !m.intersects(KeyModifiers::ALT) => {
|
||||||
|
Some(Action::MoveDown(1))
|
||||||
|
}
|
||||||
|
(KeyCode::Char('k'), m) | (KeyCode::Up, m) if !m.intersects(KeyModifiers::ALT) => {
|
||||||
|
Some(Action::MoveUp(1))
|
||||||
|
}
|
||||||
|
(KeyCode::Char('G'), _) => Some(Action::MoveToBottom),
|
||||||
|
(KeyCode::Char('g'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
*pending = PendingKey::G;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
// V cancels visual mode, no selection applied
|
||||||
|
(KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Normal)),
|
||||||
|
// Escape always cancels the menu
|
||||||
|
(KeyCode::Esc, _) => Some(Action::Cancel),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -331,16 +500,19 @@ mod tests {
|
|||||||
label: "alpha".into(),
|
label: "alpha".into(),
|
||||||
formatted_text: None,
|
formatted_text: None,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
selected: false,
|
||||||
},
|
},
|
||||||
VisibleItem {
|
VisibleItem {
|
||||||
label: "bravo".into(),
|
label: "bravo".into(),
|
||||||
formatted_text: None,
|
formatted_text: None,
|
||||||
index: 1,
|
index: 1,
|
||||||
|
selected: false,
|
||||||
},
|
},
|
||||||
VisibleItem {
|
VisibleItem {
|
||||||
label: "charlie".into(),
|
label: "charlie".into(),
|
||||||
formatted_text: None,
|
formatted_text: None,
|
||||||
index: 2,
|
index: 2,
|
||||||
|
selected: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -348,6 +520,8 @@ mod tests {
|
|||||||
total_items: 5,
|
total_items: 5,
|
||||||
total_filtered: 3,
|
total_filtered: 3,
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
|
selection_count: 0,
|
||||||
|
multi_enabled: false,
|
||||||
generation: 1,
|
generation: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,7 +877,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, vs, filter);
|
render_menu(frame, vs, filter, None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
@@ -820,6 +994,8 @@ mod tests {
|
|||||||
total_items: 0,
|
total_items: 0,
|
||||||
total_filtered: 0,
|
total_filtered: 0,
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
|
selection_count: 0,
|
||||||
|
multi_enabled: false,
|
||||||
generation: 1,
|
generation: 1,
|
||||||
};
|
};
|
||||||
let backend = render_to_backend(30, 4, &vs, "");
|
let backend = render_to_backend(30, 4, &vs, "");
|
||||||
@@ -853,7 +1029,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, &vs, "hi");
|
render_menu(frame, &vs, "hi", None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
@@ -871,7 +1047,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, &vs, "");
|
render_menu(frame, &vs, "", None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ struct Cli {
|
|||||||
/// Wrap output in structured JSON with action metadata
|
/// Wrap output in structured JSON with action metadata
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
structured: bool,
|
structured: bool,
|
||||||
|
|
||||||
|
/// Enable multi-select mode (Space/Tab to toggle, V for visual)
|
||||||
|
#[arg(long)]
|
||||||
|
multi: bool,
|
||||||
|
|
||||||
|
/// Output selected items in selection order instead of input order
|
||||||
|
#[arg(long)]
|
||||||
|
selection_order: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -214,10 +222,11 @@ fn main() {
|
|||||||
let start_mode = match cli.start_mode.as_str() {
|
let start_mode = match cli.start_mode.as_str() {
|
||||||
"insert" => Mode::Insert,
|
"insert" => Mode::Insert,
|
||||||
"normal" => Mode::Normal,
|
"normal" => Mode::Normal,
|
||||||
|
"visual" => Mode::Visual,
|
||||||
other => {
|
other => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
std::io::stderr().lock(),
|
std::io::stderr().lock(),
|
||||||
"pikl: unknown mode '{other}', expected insert or normal"
|
"pikl: unknown mode '{other}', expected insert, normal, or visual"
|
||||||
);
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
@@ -333,6 +342,8 @@ async fn run_headless(
|
|||||||
) -> Result<MenuResult, PiklError> {
|
) -> Result<MenuResult, PiklError> {
|
||||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
||||||
menu.set_initial_mode(start_mode);
|
menu.set_initial_mode(start_mode);
|
||||||
|
menu.set_multi(cli.multi);
|
||||||
|
menu.set_selection_order(cli.selection_order);
|
||||||
|
|
||||||
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||||
menu.set_dispatcher(dispatcher);
|
menu.set_dispatcher(dispatcher);
|
||||||
@@ -380,6 +391,8 @@ async fn run_interactive(
|
|||||||
) -> Result<MenuResult, PiklError> {
|
) -> Result<MenuResult, PiklError> {
|
||||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
||||||
menu.set_initial_mode(start_mode);
|
menu.set_initial_mode(start_mode);
|
||||||
|
menu.set_multi(cli.multi);
|
||||||
|
menu.set_selection_order(cli.selection_order);
|
||||||
|
|
||||||
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||||
menu.set_dispatcher(dispatcher);
|
menu.set_dispatcher(dispatcher);
|
||||||
@@ -409,16 +422,20 @@ async fn run_interactive(
|
|||||||
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
||||||
let mut out = std::io::stdout().lock();
|
let mut out = std::io::stdout().lock();
|
||||||
match result {
|
match result {
|
||||||
Ok(MenuResult::Selected { value, index }) => {
|
Ok(MenuResult::Selected { items }) => {
|
||||||
if cli.structured {
|
if cli.structured {
|
||||||
|
for (value, index) in items {
|
||||||
let output = OutputItem {
|
let output = OutputItem {
|
||||||
value,
|
value,
|
||||||
action: OutputAction::Select,
|
action: OutputAction::Select,
|
||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
let _ = write_output_json(&mut out, &output);
|
let _ = write_output_json(&mut out, &output);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = write_plain_value(&mut out, &value);
|
for (value, _) in &items {
|
||||||
|
let _ = write_plain_value(&mut out, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(MenuResult::Quicklist { items }) => {
|
Ok(MenuResult::Quicklist { items }) => {
|
||||||
|
|||||||
@@ -284,6 +284,44 @@ pikl_tests! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headless mod multi_select_headless {
|
||||||
|
items: ["alpha", "bravo", "charlie"];
|
||||||
|
multi: true;
|
||||||
|
|
||||||
|
test toggle_and_confirm {
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test toggle_two_and_confirm {
|
||||||
|
actions: [toggle-select, move-down, move-down, toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test select_all_and_confirm {
|
||||||
|
actions: [select-all, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test deselect_all_clears {
|
||||||
|
actions: [toggle-select, deselect-all, confirm]
|
||||||
|
// Falls back to cursor item
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test without_multi_toggle_is_noop {
|
||||||
|
// This test does NOT have multi: true (uses the shared fixture)
|
||||||
|
// but toggle-select still just selects cursor on confirm
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
headless mod pipeline_headless {
|
headless mod pipeline_headless {
|
||||||
items: ["error_log", "warning_temp", "info_log"];
|
items: ["error_log", "warning_temp", "info_log"];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user