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

This commit is contained in:
2026-03-13 22:56:30 -04:00
parent 6a4cc85285
commit d9ed49e7d9
8 changed files with 795 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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