feat(core): Add input modes and half-page cursor movement.
Some checks failed
CI / Check (macos-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Clippy (strict) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
Some checks failed
CI / Check (macos-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Clippy (strict) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -11,6 +11,15 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Input mode. Insert mode sends keystrokes to the filter,
|
||||
/// normal mode uses vim-style navigation keybinds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Mode {
|
||||
#[default]
|
||||
Insert,
|
||||
Normal,
|
||||
}
|
||||
|
||||
/// A command the menu should process. Frontends and headless
|
||||
/// scripts both produce these. The menu loop consumes them
|
||||
/// sequentially.
|
||||
@@ -23,6 +32,9 @@ pub enum Action {
|
||||
MoveToBottom,
|
||||
PageUp(usize),
|
||||
PageDown(usize),
|
||||
HalfPageUp(usize),
|
||||
HalfPageDown(usize),
|
||||
SetMode(Mode),
|
||||
Confirm,
|
||||
Cancel,
|
||||
Resize { height: u16 },
|
||||
@@ -51,6 +63,7 @@ pub struct ViewState {
|
||||
pub filter_text: Arc<str>,
|
||||
pub total_items: usize,
|
||||
pub total_filtered: usize,
|
||||
pub mode: Mode,
|
||||
}
|
||||
|
||||
/// A single item in the current viewport window. Has the
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::error::PiklError;
|
||||
use crate::event::{Action, MenuEvent, MenuResult, ViewState, VisibleItem};
|
||||
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||
use crate::model::traits::Menu;
|
||||
use crate::navigation::Viewport;
|
||||
use serde_json::Value;
|
||||
@@ -35,6 +35,7 @@ pub struct MenuRunner<M: Menu> {
|
||||
menu: M,
|
||||
viewport: Viewport,
|
||||
filter_text: Arc<str>,
|
||||
mode: Mode,
|
||||
action_rx: mpsc::Receiver<Action>,
|
||||
event_tx: broadcast::Sender<MenuEvent>,
|
||||
}
|
||||
@@ -55,6 +56,7 @@ impl<M: Menu> MenuRunner<M> {
|
||||
menu,
|
||||
viewport: Viewport::new(),
|
||||
filter_text: Arc::from(""),
|
||||
mode: Mode::default(),
|
||||
action_rx,
|
||||
event_tx,
|
||||
};
|
||||
@@ -100,6 +102,7 @@ impl<M: Menu> MenuRunner<M> {
|
||||
filter_text: Arc::clone(&self.filter_text),
|
||||
total_items: self.menu.total(),
|
||||
total_filtered: self.menu.filtered_count(),
|
||||
mode: self.mode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +161,18 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.viewport.set_height(height as usize);
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::HalfPageUp(n) => {
|
||||
self.viewport.half_page_up(n);
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::HalfPageDown(n) => {
|
||||
self.viewport.half_page_down(n);
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::SetMode(m) => {
|
||||
self.mode = m;
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::AddItems(values) => {
|
||||
self.menu.add_raw(values);
|
||||
self.run_filter();
|
||||
@@ -166,6 +181,12 @@ impl<M: Menu> MenuRunner<M> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the initial mode before running the event loop.
|
||||
/// Used by `--start-mode` CLI flag.
|
||||
pub fn set_initial_mode(&mut self, mode: Mode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// Run the menu event loop. Consumes actions and
|
||||
/// broadcasts events.
|
||||
///
|
||||
@@ -617,6 +638,73 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// -- New action variant tests --
|
||||
|
||||
#[test]
|
||||
fn apply_half_page_down() {
|
||||
let (mut m, _tx) = test_menu();
|
||||
m.run_filter();
|
||||
m.apply_action(Action::Resize { height: 4 });
|
||||
let outcome = m.apply_action(Action::HalfPageDown(1));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.viewport.cursor(), 2); // 4/2 = 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_half_page_up() {
|
||||
let (mut m, _tx) = test_menu();
|
||||
m.run_filter();
|
||||
m.apply_action(Action::Resize { height: 4 });
|
||||
m.apply_action(Action::HalfPageDown(1));
|
||||
let outcome = m.apply_action(Action::HalfPageUp(1));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.viewport.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_set_mode_normal() {
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::SetMode(Mode::Normal));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.build_view_state().mode, Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_set_mode_insert() {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::SetMode(Mode::Normal));
|
||||
let outcome = m.apply_action(Action::SetMode(Mode::Insert));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.build_view_state().mode, Mode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_set_mode_preserves_filter() {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::UpdateFilter("al".to_string()));
|
||||
let count_before = m.menu.filtered_count();
|
||||
let filter_before = m.filter_text.clone();
|
||||
m.apply_action(Action::SetMode(Mode::Normal));
|
||||
assert_eq!(&*m.filter_text, &*filter_before);
|
||||
assert_eq!(m.menu.filtered_count(), count_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_initial_mode_works() {
|
||||
let (mut m, _tx) = test_menu();
|
||||
m.set_initial_mode(Mode::Normal);
|
||||
m.run_filter();
|
||||
assert_eq!(m.build_view_state().mode, Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_mode_after_switch() {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::SetMode(Mode::Normal));
|
||||
let vs = m.build_view_state();
|
||||
assert_eq!(vs.mode, Mode::Normal);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actions_process_in_order_add_items_then_filter_then_confirm() {
|
||||
// AddItems + filter + confirm, all back-to-back.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use std::io::BufRead;
|
||||
|
||||
use crate::event::Action;
|
||||
use crate::event::{Action, Mode};
|
||||
|
||||
use super::ScriptAction;
|
||||
use super::error::{ScriptError, ScriptErrorKind};
|
||||
@@ -118,6 +118,34 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
|
||||
let n = parse_count(line_number, line, "page-down", arg)?;
|
||||
Ok(ScriptAction::Core(Action::PageDown(n)))
|
||||
}
|
||||
"half-page-up" => {
|
||||
let n = parse_count(line_number, line, "half-page-up", arg)?;
|
||||
Ok(ScriptAction::Core(Action::HalfPageUp(n)))
|
||||
}
|
||||
"half-page-down" => {
|
||||
let n = parse_count(line_number, line, "half-page-down", arg)?;
|
||||
Ok(ScriptAction::Core(Action::HalfPageDown(n)))
|
||||
}
|
||||
"set-mode" => {
|
||||
let Some(mode_str) = arg else {
|
||||
return Err(invalid_arg(
|
||||
line_number,
|
||||
line,
|
||||
"set-mode",
|
||||
"missing mode value (insert or normal)".to_string(),
|
||||
));
|
||||
};
|
||||
match mode_str.trim() {
|
||||
"insert" => Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))),
|
||||
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))),
|
||||
other => Err(invalid_arg(
|
||||
line_number,
|
||||
line,
|
||||
"set-mode",
|
||||
format!("unknown mode '{other}', expected insert or normal"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
"confirm" => Ok(ScriptAction::Core(Action::Confirm)),
|
||||
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
|
||||
"resize" => {
|
||||
@@ -151,15 +179,14 @@ fn validate_show_last(actions: &[ParsedLine]) -> Result<(), ScriptError> {
|
||||
p.action,
|
||||
ScriptAction::ShowUi | ScriptAction::ShowTui | ScriptAction::ShowGui
|
||||
)
|
||||
}) {
|
||||
if show_pos < actions.len() - 1 {
|
||||
let offender = &actions[show_pos + 1];
|
||||
return Err(ScriptError {
|
||||
line: offender.line_number,
|
||||
source_line: offender.source.clone(),
|
||||
kind: ScriptErrorKind::ActionsAfterShowUi,
|
||||
});
|
||||
}
|
||||
}) && show_pos < actions.len() - 1
|
||||
{
|
||||
let offender = &actions[show_pos + 1];
|
||||
return Err(ScriptError {
|
||||
line: offender.line_number,
|
||||
source_line: offender.source.clone(),
|
||||
kind: ScriptErrorKind::ActionsAfterShowUi,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -367,6 +394,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_half_page_actions() {
|
||||
assert_eq!(
|
||||
parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment),
|
||||
ScriptAction::Core(Action::HalfPageUp(1))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment),
|
||||
ScriptAction::Core(Action::HalfPageDown(1))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_action(1, "half-page-up 3").unwrap_or(ScriptAction::Comment),
|
||||
ScriptAction::Core(Action::HalfPageUp(3))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_action(1, "half-page-down 2").unwrap_or(ScriptAction::Comment),
|
||||
ScriptAction::Core(Action::HalfPageDown(2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_half_page_zero_is_error() {
|
||||
assert!(parse_action(1, "half-page-up 0").is_err());
|
||||
assert!(parse_action(1, "half-page-down 0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_mode() {
|
||||
assert_eq!(
|
||||
parse_action(1, "set-mode insert").unwrap_or(ScriptAction::Comment),
|
||||
ScriptAction::Core(Action::SetMode(Mode::Insert))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_action(1, "set-mode normal").unwrap_or(ScriptAction::Comment),
|
||||
ScriptAction::Core(Action::SetMode(Mode::Normal))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_mode_missing_arg() {
|
||||
assert!(parse_action(1, "set-mode").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_mode_invalid() {
|
||||
assert!(parse_action(1, "set-mode visual").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_action() {
|
||||
let result = parse_action(3, "bogus");
|
||||
@@ -561,4 +636,52 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap_or(vec![ScriptAction::Comment]).is_empty());
|
||||
}
|
||||
|
||||
// -- New action edge case tests --
|
||||
|
||||
#[test]
|
||||
fn parse_half_page_non_numeric() {
|
||||
assert!(parse_action(1, "half-page-down abc").is_err());
|
||||
assert!(parse_action(1, "half-page-up xyz").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_mode_wrong_case() {
|
||||
assert!(parse_action(1, "set-mode Insert").is_err());
|
||||
assert!(parse_action(1, "set-mode Normal").is_err());
|
||||
assert!(parse_action(1, "set-mode INSERT").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_mode_whitespace() {
|
||||
// Extra whitespace around the mode value should be trimmed
|
||||
assert_eq!(
|
||||
parse_action(1, "set-mode insert ").unwrap_or(ScriptAction::Comment),
|
||||
ScriptAction::Core(Action::SetMode(Mode::Insert))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_action(1, "set-mode normal ").unwrap_or(ScriptAction::Comment),
|
||||
ScriptAction::Core(Action::SetMode(Mode::Normal))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_script_new_actions() {
|
||||
let input = "half-page-up 2\nset-mode normal\nset-mode insert\nhalf-page-down\nconfirm\n";
|
||||
let result = load_script(input.as_bytes());
|
||||
assert!(result.is_ok());
|
||||
let actions = result.unwrap_or_default();
|
||||
assert_eq!(actions.len(), 5);
|
||||
assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2)));
|
||||
assert_eq!(
|
||||
actions[1],
|
||||
ScriptAction::Core(Action::SetMode(Mode::Normal))
|
||||
);
|
||||
assert_eq!(
|
||||
actions[2],
|
||||
ScriptAction::Core(Action::SetMode(Mode::Insert))
|
||||
);
|
||||
assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1)));
|
||||
assert_eq!(actions[4], ScriptAction::Core(Action::Confirm));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,34 @@ pikl_tests! {
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
|
||||
nav mod half_page {
|
||||
viewport: { height: 10, count: 30 };
|
||||
|
||||
test half_page_down_from_top {
|
||||
actions: [half-page-down]
|
||||
cursor: 5
|
||||
offset: 0
|
||||
}
|
||||
|
||||
test half_page_up_from_middle {
|
||||
actions: [half-page-down, half-page-down, half-page-up]
|
||||
cursor: 5
|
||||
offset: 1
|
||||
}
|
||||
|
||||
test half_page_down_clamps_at_bottom {
|
||||
actions: [half-page-down, half-page-down, half-page-down,
|
||||
half-page-down, half-page-down, half-page-down]
|
||||
cursor: 29
|
||||
}
|
||||
|
||||
test half_page_up_clamps_at_top {
|
||||
actions: [half-page-down, half-page-up, half-page-up]
|
||||
cursor: 0
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -192,4 +220,79 @@ pikl_tests! {
|
||||
cancelled
|
||||
}
|
||||
}
|
||||
|
||||
menu mod pipeline_queries {
|
||||
items: ["error_log", "warning_temp", "info_log", "debug_temp"];
|
||||
|
||||
test exact_filter {
|
||||
actions: [filter "'log", confirm]
|
||||
selected: "error_log"
|
||||
}
|
||||
|
||||
test exact_then_inverse {
|
||||
actions: [filter "'log | !error", confirm]
|
||||
selected: "info_log"
|
||||
}
|
||||
|
||||
test cancel_pipeline {
|
||||
actions: [filter "'xyz", cancel]
|
||||
cancelled
|
||||
}
|
||||
}
|
||||
|
||||
menu mod mode_switching {
|
||||
items: ["alpha", "bravo"];
|
||||
|
||||
test set_mode_and_confirm {
|
||||
actions: [set-mode-normal, set-mode-insert, filter "bra", confirm]
|
||||
selected: "bravo"
|
||||
}
|
||||
}
|
||||
|
||||
menu mod regex_pipeline {
|
||||
items: ["item-001", "item-abc", "item-123"];
|
||||
|
||||
test regex_filter_confirm {
|
||||
actions: [filter "/[0-9]+/", confirm]
|
||||
selected: "item-001"
|
||||
}
|
||||
}
|
||||
|
||||
menu mod inverse_fuzzy {
|
||||
items: ["alpha", "bravo"];
|
||||
|
||||
test exclude_alpha {
|
||||
actions: [filter "!alpha", confirm]
|
||||
selected: "bravo"
|
||||
}
|
||||
}
|
||||
|
||||
menu mod three_stage {
|
||||
items: ["error_log_123", "warning_temp_456", "info_log_789", "debug_temp_012"];
|
||||
|
||||
test full_pipeline {
|
||||
actions: [filter "'log | !error | /[0-9]+/", confirm]
|
||||
selected: "info_log_789"
|
||||
}
|
||||
}
|
||||
|
||||
menu mod half_page_menu {
|
||||
items: ["a","b","c","d","e","f","g","h","i","j"];
|
||||
|
||||
test half_page_then_confirm {
|
||||
actions: [half-page-down, confirm]
|
||||
// viewport height=50, half=25, clamps to last item (index 9)
|
||||
selected: "j"
|
||||
}
|
||||
}
|
||||
|
||||
nav mod half_page_small_height {
|
||||
viewport: { height: 2, count: 10 };
|
||||
|
||||
test half_page_moves_one {
|
||||
actions: [half-page-down]
|
||||
cursor: 1
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user