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;
|
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
|
/// A command the menu should process. Frontends and headless
|
||||||
/// scripts both produce these. The menu loop consumes them
|
/// scripts both produce these. The menu loop consumes them
|
||||||
/// sequentially.
|
/// sequentially.
|
||||||
@@ -23,6 +32,9 @@ pub enum Action {
|
|||||||
MoveToBottom,
|
MoveToBottom,
|
||||||
PageUp(usize),
|
PageUp(usize),
|
||||||
PageDown(usize),
|
PageDown(usize),
|
||||||
|
HalfPageUp(usize),
|
||||||
|
HalfPageDown(usize),
|
||||||
|
SetMode(Mode),
|
||||||
Confirm,
|
Confirm,
|
||||||
Cancel,
|
Cancel,
|
||||||
Resize { height: u16 },
|
Resize { height: u16 },
|
||||||
@@ -51,6 +63,7 @@ pub struct ViewState {
|
|||||||
pub filter_text: Arc<str>,
|
pub filter_text: Arc<str>,
|
||||||
pub total_items: usize,
|
pub total_items: usize,
|
||||||
pub total_filtered: usize,
|
pub total_filtered: usize,
|
||||||
|
pub mode: Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single item in the current viewport window. Has the
|
/// A single item in the current viewport window. Has the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
use crate::error::PiklError;
|
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::model::traits::Menu;
|
||||||
use crate::navigation::Viewport;
|
use crate::navigation::Viewport;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -35,6 +35,7 @@ pub struct MenuRunner<M: Menu> {
|
|||||||
menu: M,
|
menu: M,
|
||||||
viewport: Viewport,
|
viewport: Viewport,
|
||||||
filter_text: Arc<str>,
|
filter_text: Arc<str>,
|
||||||
|
mode: Mode,
|
||||||
action_rx: mpsc::Receiver<Action>,
|
action_rx: mpsc::Receiver<Action>,
|
||||||
event_tx: broadcast::Sender<MenuEvent>,
|
event_tx: broadcast::Sender<MenuEvent>,
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
menu,
|
menu,
|
||||||
viewport: Viewport::new(),
|
viewport: Viewport::new(),
|
||||||
filter_text: Arc::from(""),
|
filter_text: Arc::from(""),
|
||||||
|
mode: Mode::default(),
|
||||||
action_rx,
|
action_rx,
|
||||||
event_tx,
|
event_tx,
|
||||||
};
|
};
|
||||||
@@ -100,6 +102,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
filter_text: Arc::clone(&self.filter_text),
|
filter_text: Arc::clone(&self.filter_text),
|
||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +161,18 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
self.viewport.set_height(height as usize);
|
self.viewport.set_height(height as usize);
|
||||||
ActionOutcome::Broadcast
|
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) => {
|
Action::AddItems(values) => {
|
||||||
self.menu.add_raw(values);
|
self.menu.add_raw(values);
|
||||||
self.run_filter();
|
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
|
/// Run the menu event loop. Consumes actions and
|
||||||
/// broadcasts events.
|
/// 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]
|
#[tokio::test]
|
||||||
async fn actions_process_in_order_add_items_then_filter_then_confirm() {
|
async fn actions_process_in_order_add_items_then_filter_then_confirm() {
|
||||||
// AddItems + filter + confirm, all back-to-back.
|
// AddItems + filter + confirm, all back-to-back.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
|
|
||||||
use crate::event::Action;
|
use crate::event::{Action, Mode};
|
||||||
|
|
||||||
use super::ScriptAction;
|
use super::ScriptAction;
|
||||||
use super::error::{ScriptError, ScriptErrorKind};
|
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)?;
|
let n = parse_count(line_number, line, "page-down", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::PageDown(n)))
|
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)),
|
"confirm" => Ok(ScriptAction::Core(Action::Confirm)),
|
||||||
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
|
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
|
||||||
"resize" => {
|
"resize" => {
|
||||||
@@ -151,15 +179,14 @@ fn validate_show_last(actions: &[ParsedLine]) -> Result<(), ScriptError> {
|
|||||||
p.action,
|
p.action,
|
||||||
ScriptAction::ShowUi | ScriptAction::ShowTui | ScriptAction::ShowGui
|
ScriptAction::ShowUi | ScriptAction::ShowTui | ScriptAction::ShowGui
|
||||||
)
|
)
|
||||||
}) {
|
}) && show_pos < actions.len() - 1
|
||||||
if show_pos < actions.len() - 1 {
|
{
|
||||||
let offender = &actions[show_pos + 1];
|
let offender = &actions[show_pos + 1];
|
||||||
return Err(ScriptError {
|
return Err(ScriptError {
|
||||||
line: offender.line_number,
|
line: offender.line_number,
|
||||||
source_line: offender.source.clone(),
|
source_line: offender.source.clone(),
|
||||||
kind: ScriptErrorKind::ActionsAfterShowUi,
|
kind: ScriptErrorKind::ActionsAfterShowUi,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn parse_unknown_action() {
|
fn parse_unknown_action() {
|
||||||
let result = parse_action(3, "bogus");
|
let result = parse_action(3, "bogus");
|
||||||
@@ -561,4 +636,52 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert!(result.unwrap_or(vec![ScriptAction::Comment]).is_empty());
|
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
|
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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,12 @@ fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
|||||||
let method = Ident::new(&name.replace('-', "_"), Span::call_site());
|
let method = Ident::new(&name.replace('-', "_"), Span::call_site());
|
||||||
let needs_count = matches!(
|
let needs_count = matches!(
|
||||||
name.as_str(),
|
name.as_str(),
|
||||||
"move-up" | "move-down" | "page-up" | "page-down"
|
"move-up"
|
||||||
|
| "move-down"
|
||||||
|
| "page-up"
|
||||||
|
| "page-down"
|
||||||
|
| "half-page-up"
|
||||||
|
| "half-page-down"
|
||||||
);
|
);
|
||||||
if needs_count {
|
if needs_count {
|
||||||
calls.push(quote! { v.#method(1); });
|
calls.push(quote! { v.#method(1); });
|
||||||
@@ -418,6 +423,10 @@ fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
|
|||||||
"move-to-bottom" => quote! { Action::MoveToBottom },
|
"move-to-bottom" => quote! { Action::MoveToBottom },
|
||||||
"page-up" => quote! { Action::PageUp(1) },
|
"page-up" => quote! { Action::PageUp(1) },
|
||||||
"page-down" => quote! { Action::PageDown(1) },
|
"page-down" => quote! { Action::PageDown(1) },
|
||||||
|
"half-page-up" => quote! { Action::HalfPageUp(1) },
|
||||||
|
"half-page-down" => quote! { Action::HalfPageDown(1) },
|
||||||
|
"set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) },
|
||||||
|
"set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) },
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
Span::call_site(),
|
Span::call_site(),
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{List, ListItem, Paragraph};
|
use ratatui::widgets::{List, ListItem, Paragraph};
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
use pikl_core::event::{Action, MenuEvent, ViewState};
|
use pikl_core::event::{Action, MenuEvent, Mode, ViewState};
|
||||||
|
|
||||||
|
/// Pending key state for multi-key sequences (e.g. `gg`).
|
||||||
|
/// TUI-local, not part of core state.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum PendingKey {
|
||||||
|
None,
|
||||||
|
G,
|
||||||
|
}
|
||||||
|
|
||||||
/// Restore the terminal to a sane state. Called on clean
|
/// Restore the terminal to a sane state. Called on clean
|
||||||
/// exit and from the panic hook so Ctrl+C or a crash
|
/// exit and from the panic hook so Ctrl+C or a crash
|
||||||
@@ -72,6 +80,8 @@ async fn run_inner(
|
|||||||
let mut filter_text = String::new();
|
let mut filter_text = String::new();
|
||||||
let mut view_state: Option<ViewState> = None;
|
let mut view_state: Option<ViewState> = None;
|
||||||
let mut event_stream = EventStream::new();
|
let mut event_stream = EventStream::new();
|
||||||
|
let mut mode = Mode::Insert;
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Some(ref vs) = view_state {
|
if let Some(ref vs) = view_state {
|
||||||
@@ -89,7 +99,12 @@ 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) {
|
if let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending) {
|
||||||
|
// Track mode locally for key mapping
|
||||||
|
if let Action::SetMode(m) = &action {
|
||||||
|
mode = *m;
|
||||||
|
pending = PendingKey::None;
|
||||||
|
}
|
||||||
let _ = action_tx.send(action).await;
|
let _ = action_tx.send(action).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +125,11 @@ async fn run_inner(
|
|||||||
if &*vs.filter_text != filter_text.as_str() {
|
if &*vs.filter_text != filter_text.as_str() {
|
||||||
filter_text = vs.filter_text.to_string();
|
filter_text = vs.filter_text.to_string();
|
||||||
}
|
}
|
||||||
|
// Sync mode from core
|
||||||
|
if vs.mode != mode {
|
||||||
|
mode = vs.mode;
|
||||||
|
pending = PendingKey::None;
|
||||||
|
}
|
||||||
view_state = Some(vs);
|
view_state = Some(vs);
|
||||||
}
|
}
|
||||||
Ok(MenuEvent::Selected(_) | MenuEvent::Cancelled) => {
|
Ok(MenuEvent::Selected(_) | MenuEvent::Cancelled) => {
|
||||||
@@ -138,8 +158,16 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
let filtered_count = vs.total_filtered;
|
let filtered_count = vs.total_filtered;
|
||||||
let total_count = vs.total_items;
|
let total_count = vs.total_items;
|
||||||
|
|
||||||
|
let mode_indicator = match vs.mode {
|
||||||
|
Mode::Insert => "[I]",
|
||||||
|
Mode::Normal => "[N]",
|
||||||
|
};
|
||||||
|
|
||||||
let prompt = Paragraph::new(Line::from(vec![
|
let prompt = Paragraph::new(Line::from(vec![
|
||||||
Span::styled("> ", Style::default().fg(Color::Cyan)),
|
Span::styled(
|
||||||
|
format!("{mode_indicator}> "),
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
Span::raw(filter_text),
|
Span::raw(filter_text),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" {filtered_count}/{total_count}"),
|
format!(" {filtered_count}/{total_count}"),
|
||||||
@@ -148,7 +176,13 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
]));
|
]));
|
||||||
frame.render_widget(prompt, chunks[0]);
|
frame.render_widget(prompt, chunks[0]);
|
||||||
|
|
||||||
frame.set_cursor_position(((2 + filter_text.len()) as u16, 0));
|
// mode_indicator len + "> " = mode_indicator.len() + 2
|
||||||
|
let prompt_prefix_len = mode_indicator.len() + 2;
|
||||||
|
|
||||||
|
// Show cursor in insert mode, hide in normal mode
|
||||||
|
if vs.mode == Mode::Insert {
|
||||||
|
frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0));
|
||||||
|
}
|
||||||
|
|
||||||
let items: Vec<ListItem> = vs
|
let items: Vec<ListItem> = vs
|
||||||
.visible_items
|
.visible_items
|
||||||
@@ -171,14 +205,27 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
/// Map a crossterm key event to an [`Action`], updating
|
/// Map a crossterm key event to an [`Action`], updating
|
||||||
/// `filter_text` in place for character/backspace keys.
|
/// `filter_text` in place for character/backspace keys.
|
||||||
/// Returns `None` for unmapped keys.
|
/// Returns `None` for unmapped keys.
|
||||||
fn map_key_event(key: KeyEvent, filter_text: &mut String) -> Option<Action> {
|
fn map_key_event(
|
||||||
|
key: KeyEvent,
|
||||||
|
filter_text: &mut String,
|
||||||
|
mode: Mode,
|
||||||
|
pending: &mut PendingKey,
|
||||||
|
) -> Option<Action> {
|
||||||
|
match mode {
|
||||||
|
Mode::Insert => map_insert_mode(key, filter_text),
|
||||||
|
Mode::Normal => map_normal_mode(key, pending),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert mode: characters go to filter, navigation via
|
||||||
|
/// arrows and ctrl keys.
|
||||||
|
fn map_insert_mode(key: KeyEvent, filter_text: &mut String) -> Option<Action> {
|
||||||
match (key.code, key.modifiers) {
|
match (key.code, key.modifiers) {
|
||||||
(KeyCode::Esc, _) => Some(Action::Cancel),
|
(KeyCode::Esc, _) => Some(Action::Cancel),
|
||||||
(KeyCode::Enter, _) => Some(Action::Confirm),
|
(KeyCode::Enter, _) => Some(Action::Confirm),
|
||||||
(KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => Some(Action::MoveUp(1)),
|
(KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => Some(Action::MoveUp(1)),
|
||||||
(KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
|
(KeyCode::Down, _) => Some(Action::MoveDown(1)),
|
||||||
Some(Action::MoveDown(1))
|
(KeyCode::Char('n'), KeyModifiers::CONTROL) => Some(Action::SetMode(Mode::Normal)),
|
||||||
}
|
|
||||||
(KeyCode::PageUp, _) => Some(Action::PageUp(1)),
|
(KeyCode::PageUp, _) => Some(Action::PageUp(1)),
|
||||||
(KeyCode::PageDown, _) => Some(Action::PageDown(1)),
|
(KeyCode::PageDown, _) => Some(Action::PageDown(1)),
|
||||||
(KeyCode::Backspace, _) => {
|
(KeyCode::Backspace, _) => {
|
||||||
@@ -193,6 +240,52 @@ fn map_key_event(key: KeyEvent, filter_text: &mut String) -> Option<Action> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normal mode: vim-style navigation keybinds.
|
||||||
|
fn map_normal_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);
|
||||||
|
}
|
||||||
|
// Not `gg`, process the second key normally below.
|
||||||
|
}
|
||||||
|
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('j'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
Some(Action::MoveDown(1))
|
||||||
|
}
|
||||||
|
(KeyCode::Char('k'), m) if !m.intersects(KeyModifiers::CONTROL | 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
|
||||||
|
}
|
||||||
|
(KeyCode::Char('d'), KeyModifiers::CONTROL) => Some(Action::HalfPageDown(1)),
|
||||||
|
(KeyCode::Char('u'), KeyModifiers::CONTROL) => Some(Action::HalfPageUp(1)),
|
||||||
|
(KeyCode::Char('f'), KeyModifiers::CONTROL) => Some(Action::PageDown(1)),
|
||||||
|
(KeyCode::Char('b'), KeyModifiers::CONTROL) => Some(Action::PageUp(1)),
|
||||||
|
(KeyCode::Char('e'), KeyModifiers::CONTROL) => Some(Action::SetMode(Mode::Insert)),
|
||||||
|
(KeyCode::Char('/'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
Some(Action::SetMode(Mode::Insert))
|
||||||
|
}
|
||||||
|
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
Some(Action::Cancel)
|
||||||
|
}
|
||||||
|
(KeyCode::Enter, _) => Some(Action::Confirm),
|
||||||
|
(KeyCode::Esc, _) => Some(Action::Cancel),
|
||||||
|
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
|
||||||
|
(KeyCode::Down, _) => Some(Action::MoveDown(1)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -240,16 +333,18 @@ mod tests {
|
|||||||
filter_text: Arc::from(""),
|
filter_text: Arc::from(""),
|
||||||
total_items: 5,
|
total_items: 5,
|
||||||
total_filtered: 3,
|
total_filtered: 3,
|
||||||
|
mode: Mode::Insert,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Key mapping tests --
|
// -- Insert mode key mapping tests --
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn esc_maps_to_cancel() {
|
fn esc_maps_to_cancel() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
map_key_event(key(KeyCode::Esc), &mut ft),
|
map_key_event(key(KeyCode::Esc), &mut ft, Mode::Insert, &mut pending),
|
||||||
Some(Action::Cancel)
|
Some(Action::Cancel)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,8 +352,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn enter_maps_to_confirm() {
|
fn enter_maps_to_confirm() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
map_key_event(key(KeyCode::Enter), &mut ft),
|
map_key_event(key(KeyCode::Enter), &mut ft, Mode::Insert, &mut pending),
|
||||||
Some(Action::Confirm)
|
Some(Action::Confirm)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -266,8 +362,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn arrow_up_maps_to_move_up() {
|
fn arrow_up_maps_to_move_up() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
map_key_event(key(KeyCode::Up), &mut ft),
|
map_key_event(key(KeyCode::Up), &mut ft, Mode::Insert, &mut pending),
|
||||||
Some(Action::MoveUp(1))
|
Some(Action::MoveUp(1))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,8 +372,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn arrow_down_maps_to_move_down() {
|
fn arrow_down_maps_to_move_down() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
map_key_event(key(KeyCode::Down), &mut ft),
|
map_key_event(key(KeyCode::Down), &mut ft, Mode::Insert, &mut pending),
|
||||||
Some(Action::MoveDown(1))
|
Some(Action::MoveDown(1))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,26 +382,35 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ctrl_p_maps_to_move_up() {
|
fn ctrl_p_maps_to_move_up() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
let k = key_with_mods(KeyCode::Char('p'), KeyModifiers::CONTROL);
|
let k = key_with_mods(KeyCode::Char('p'), KeyModifiers::CONTROL);
|
||||||
assert_eq!(map_key_event(k, &mut ft), Some(Action::MoveUp(1)));
|
assert_eq!(
|
||||||
|
map_key_event(k, &mut ft, Mode::Insert, &mut pending),
|
||||||
|
Some(Action::MoveUp(1))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_n_maps_to_move_down() {
|
fn ctrl_n_maps_to_normal_mode() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
let k = key_with_mods(KeyCode::Char('n'), KeyModifiers::CONTROL);
|
let k = key_with_mods(KeyCode::Char('n'), KeyModifiers::CONTROL);
|
||||||
assert_eq!(map_key_event(k, &mut ft), Some(Action::MoveDown(1)));
|
assert_eq!(
|
||||||
|
map_key_event(k, &mut ft, Mode::Insert, &mut pending),
|
||||||
|
Some(Action::SetMode(Mode::Normal))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_keys_map_correctly() {
|
fn page_keys_map_correctly() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
map_key_event(key(KeyCode::PageUp), &mut ft),
|
map_key_event(key(KeyCode::PageUp), &mut ft, Mode::Insert, &mut pending),
|
||||||
Some(Action::PageUp(1))
|
Some(Action::PageUp(1))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
map_key_event(key(KeyCode::PageDown), &mut ft),
|
map_key_event(key(KeyCode::PageDown), &mut ft, Mode::Insert, &mut pending),
|
||||||
Some(Action::PageDown(1))
|
Some(Action::PageDown(1))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -311,11 +418,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn char_appends_to_filter() {
|
fn char_appends_to_filter() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
let action = map_key_event(key(KeyCode::Char('a')), &mut ft);
|
let mut pending = PendingKey::None;
|
||||||
|
let action = map_key_event(key(KeyCode::Char('a')), &mut ft, Mode::Insert, &mut pending);
|
||||||
assert_eq!(ft, "a");
|
assert_eq!(ft, "a");
|
||||||
assert_eq!(action, Some(Action::UpdateFilter("a".into())));
|
assert_eq!(action, Some(Action::UpdateFilter("a".into())));
|
||||||
|
|
||||||
let action = map_key_event(key(KeyCode::Char('b')), &mut ft);
|
let action = map_key_event(key(KeyCode::Char('b')), &mut ft, Mode::Insert, &mut pending);
|
||||||
assert_eq!(ft, "ab");
|
assert_eq!(ft, "ab");
|
||||||
assert_eq!(action, Some(Action::UpdateFilter("ab".into())));
|
assert_eq!(action, Some(Action::UpdateFilter("ab".into())));
|
||||||
}
|
}
|
||||||
@@ -323,7 +431,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn backspace_pops_filter() {
|
fn backspace_pops_filter() {
|
||||||
let mut ft = "abc".to_string();
|
let mut ft = "abc".to_string();
|
||||||
let action = map_key_event(key(KeyCode::Backspace), &mut ft);
|
let mut pending = PendingKey::None;
|
||||||
|
let action = map_key_event(key(KeyCode::Backspace), &mut ft, Mode::Insert, &mut pending);
|
||||||
assert_eq!(ft, "ab");
|
assert_eq!(ft, "ab");
|
||||||
assert_eq!(action, Some(Action::UpdateFilter("ab".into())));
|
assert_eq!(action, Some(Action::UpdateFilter("ab".into())));
|
||||||
}
|
}
|
||||||
@@ -331,33 +440,37 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn backspace_on_empty_filter_is_noop() {
|
fn backspace_on_empty_filter_is_noop() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
let action = map_key_event(key(KeyCode::Backspace), &mut ft);
|
let mut pending = PendingKey::None;
|
||||||
|
let action = map_key_event(key(KeyCode::Backspace), &mut ft, Mode::Insert, &mut pending);
|
||||||
assert_eq!(ft, "");
|
assert_eq!(ft, "");
|
||||||
assert_eq!(action, Some(Action::UpdateFilter(String::new())));
|
assert_eq!(action, Some(Action::UpdateFilter(String::new())));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_char_ignored() {
|
fn ctrl_char_ignored_in_insert() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
let k = key_with_mods(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
let k = key_with_mods(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||||
assert_eq!(map_key_event(k, &mut ft), None);
|
assert_eq!(map_key_event(k, &mut ft, Mode::Insert, &mut pending), None);
|
||||||
assert_eq!(ft, "");
|
assert_eq!(ft, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn alt_char_ignored() {
|
fn alt_char_ignored() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
let k = key_with_mods(KeyCode::Char('x'), KeyModifiers::ALT);
|
let k = key_with_mods(KeyCode::Char('x'), KeyModifiers::ALT);
|
||||||
assert_eq!(map_key_event(k, &mut ft), None);
|
assert_eq!(map_key_event(k, &mut ft, Mode::Insert, &mut pending), None);
|
||||||
assert_eq!(ft, "");
|
assert_eq!(ft, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_char_passes_through() {
|
fn shift_char_passes_through() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
let k = key_with_mods(KeyCode::Char('A'), KeyModifiers::SHIFT);
|
let k = key_with_mods(KeyCode::Char('A'), KeyModifiers::SHIFT);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
map_key_event(k, &mut ft),
|
map_key_event(k, &mut ft, Mode::Insert, &mut pending),
|
||||||
Some(Action::UpdateFilter("A".into()))
|
Some(Action::UpdateFilter("A".into()))
|
||||||
);
|
);
|
||||||
assert_eq!(ft, "A");
|
assert_eq!(ft, "A");
|
||||||
@@ -366,9 +479,184 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unmapped_key_returns_none() {
|
fn unmapped_key_returns_none() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
assert_eq!(map_key_event(key(KeyCode::Tab), &mut ft), None);
|
let mut pending = PendingKey::None;
|
||||||
assert_eq!(map_key_event(key(KeyCode::Home), &mut ft), None);
|
assert_eq!(
|
||||||
assert_eq!(map_key_event(key(KeyCode::F(1)), &mut ft), None);
|
map_key_event(key(KeyCode::Tab), &mut ft, Mode::Insert, &mut pending),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(key(KeyCode::Home), &mut ft, Mode::Insert, &mut pending),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(key(KeyCode::F(1)), &mut ft, Mode::Insert, &mut pending),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Normal mode key mapping tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_j_maps_to_move_down() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(key(KeyCode::Char('j')), &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::MoveDown(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_k_maps_to_move_up() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(key(KeyCode::Char('k')), &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::MoveUp(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_big_g_maps_to_bottom() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
let k = key_with_mods(KeyCode::Char('G'), KeyModifiers::SHIFT);
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(k, &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::MoveToBottom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_gg_maps_to_top() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
// First g sets pending
|
||||||
|
let action = map_key_event(key(KeyCode::Char('g')), &mut ft, Mode::Normal, &mut pending);
|
||||||
|
assert_eq!(action, None);
|
||||||
|
assert_eq!(pending, PendingKey::G);
|
||||||
|
// Second g triggers move to top
|
||||||
|
let action = map_key_event(key(KeyCode::Char('g')), &mut ft, Mode::Normal, &mut pending);
|
||||||
|
assert_eq!(action, Some(Action::MoveToTop));
|
||||||
|
assert_eq!(pending, PendingKey::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_g_then_j_drops_g() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
// First g sets pending
|
||||||
|
map_key_event(key(KeyCode::Char('g')), &mut ft, Mode::Normal, &mut pending);
|
||||||
|
assert_eq!(pending, PendingKey::G);
|
||||||
|
// j after g: pending cleared, j processed normally
|
||||||
|
let action = map_key_event(key(KeyCode::Char('j')), &mut ft, Mode::Normal, &mut pending);
|
||||||
|
assert_eq!(action, Some(Action::MoveDown(1)));
|
||||||
|
assert_eq!(pending, PendingKey::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_ctrl_d_half_page_down() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
let k = key_with_mods(KeyCode::Char('d'), KeyModifiers::CONTROL);
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(k, &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::HalfPageDown(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_ctrl_u_half_page_up() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
let k = key_with_mods(KeyCode::Char('u'), KeyModifiers::CONTROL);
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(k, &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::HalfPageUp(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_ctrl_f_page_down() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
let k = key_with_mods(KeyCode::Char('f'), KeyModifiers::CONTROL);
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(k, &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::PageDown(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_ctrl_b_page_up() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
let k = key_with_mods(KeyCode::Char('b'), KeyModifiers::CONTROL);
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(k, &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::PageUp(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_slash_enters_insert() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(key(KeyCode::Char('/')), &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::SetMode(Mode::Insert))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_ctrl_e_enters_insert() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
let k = key_with_mods(KeyCode::Char('e'), KeyModifiers::CONTROL);
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(k, &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::SetMode(Mode::Insert))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_q_cancels() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(key(KeyCode::Char('q')), &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::Cancel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_enter_confirms() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(key(KeyCode::Enter), &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::Confirm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_esc_cancels() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
assert_eq!(
|
||||||
|
map_key_event(key(KeyCode::Esc), &mut ft, Mode::Normal, &mut pending),
|
||||||
|
Some(Action::Cancel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_chars_dont_filter() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
// 'a' in normal mode should not append to filter
|
||||||
|
let action = map_key_event(key(KeyCode::Char('a')), &mut ft, Mode::Normal, &mut pending);
|
||||||
|
assert_eq!(action, None);
|
||||||
|
assert_eq!(ft, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Rendering tests (TestBackend) --
|
// -- Rendering tests (TestBackend) --
|
||||||
@@ -420,6 +708,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_shows_mode_indicator() {
|
||||||
|
let vs = sample_view_state();
|
||||||
|
let backend = render_to_backend(30, 6, &vs, "");
|
||||||
|
let prompt = line_text(&backend, 0);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("[I]"),
|
||||||
|
"insert mode should show [I]: got '{prompt}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut vs_normal = sample_view_state();
|
||||||
|
vs_normal.mode = Mode::Normal;
|
||||||
|
let backend = render_to_backend(30, 6, &vs_normal, "");
|
||||||
|
let prompt = line_text(&backend, 0);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("[N]"),
|
||||||
|
"normal mode should show [N]: got '{prompt}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn items_render_in_list_area() {
|
fn items_render_in_list_area() {
|
||||||
let vs = sample_view_state();
|
let vs = sample_view_state();
|
||||||
@@ -474,6 +782,7 @@ mod tests {
|
|||||||
filter_text: Arc::from(""),
|
filter_text: Arc::from(""),
|
||||||
total_items: 0,
|
total_items: 0,
|
||||||
total_filtered: 0,
|
total_filtered: 0,
|
||||||
|
mode: Mode::Insert,
|
||||||
};
|
};
|
||||||
let backend = render_to_backend(30, 4, &vs, "");
|
let backend = render_to_backend(30, 4, &vs, "");
|
||||||
let prompt = line_text(&backend, 0);
|
let prompt = line_text(&backend, 0);
|
||||||
@@ -510,9 +819,54 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
// Cursor should be at column 2 ("> ") + 2 ("hi") = 4
|
// Cursor should be at column "[I]> " (5) + 2 ("hi") = 7
|
||||||
let pos = terminal.get_cursor_position().ok().expect("cursor");
|
let pos = terminal.get_cursor_position().ok().expect("cursor");
|
||||||
assert_eq!(pos.x, 4, "cursor x should be after '> hi'");
|
assert_eq!(pos.x, 7, "cursor x should be after '[I]> hi'");
|
||||||
assert_eq!(pos.y, 0, "cursor y should be on prompt row");
|
assert_eq!(pos.y, 0, "cursor y should be on prompt row");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_mode_cursor_not_on_prompt() {
|
||||||
|
let mut vs = sample_view_state();
|
||||||
|
vs.mode = Mode::Normal;
|
||||||
|
let backend = TestBackend::new(30, 6);
|
||||||
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
render_menu(frame, &vs, "");
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.expect("draw");
|
||||||
|
// In normal mode, set_cursor_position is never called,
|
||||||
|
// so the cursor should NOT be on row 0.
|
||||||
|
// TestBackend starts cursor at (0,0) but after a draw
|
||||||
|
// with hide_cursor semantics, get_cursor_position returns
|
||||||
|
// whatever ratatui left it at. The key assertion is that
|
||||||
|
// render_menu does NOT call set_cursor_position in normal mode.
|
||||||
|
// We verify by checking the cursor is not at the prompt text position.
|
||||||
|
let pos = terminal.get_cursor_position().ok().expect("cursor");
|
||||||
|
// In insert mode the cursor would be at (5, 0) for empty filter.
|
||||||
|
// In normal mode it should NOT be placed there.
|
||||||
|
let not_insert_cursor = pos.x != 5 || pos.y != 0;
|
||||||
|
assert!(
|
||||||
|
not_insert_cursor,
|
||||||
|
"normal mode should not position cursor on prompt: got ({}, {})",
|
||||||
|
pos.x, pos.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_cleared_on_mode_key() {
|
||||||
|
let mut ft = String::new();
|
||||||
|
let mut pending = PendingKey::None;
|
||||||
|
// Set pending=G
|
||||||
|
map_key_event(key(KeyCode::Char('g')), &mut ft, Mode::Normal, &mut pending);
|
||||||
|
assert_eq!(pending, PendingKey::G);
|
||||||
|
// Send '/' which triggers mode switch to Insert
|
||||||
|
let action = map_key_event(key(KeyCode::Char('/')), &mut ft, Mode::Normal, &mut pending);
|
||||||
|
assert_eq!(action, Some(Action::SetMode(Mode::Insert)));
|
||||||
|
// Pending should have been cleared by the mode switch key's processing.
|
||||||
|
// The '/' key doesn't match 'g', so pending resets to None in map_normal_mode.
|
||||||
|
assert_eq!(pending, PendingKey::None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::time::Duration;
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use pikl_core::error::PiklError;
|
use pikl_core::error::PiklError;
|
||||||
use pikl_core::event::{Action, MenuResult};
|
use pikl_core::event::{Action, MenuResult, Mode};
|
||||||
use pikl_core::input::read_items_sync;
|
use pikl_core::input::read_items_sync;
|
||||||
use pikl_core::item::Item;
|
use pikl_core::item::Item;
|
||||||
use pikl_core::json_menu::JsonMenu;
|
use pikl_core::json_menu::JsonMenu;
|
||||||
@@ -38,6 +38,10 @@ struct Cli {
|
|||||||
/// Timeout in seconds for reading stdin (default: 30 with --action-fd, 0 otherwise)
|
/// Timeout in seconds for reading stdin (default: 30 with --action-fd, 0 otherwise)
|
||||||
#[arg(long, value_name = "SECONDS")]
|
#[arg(long, value_name = "SECONDS")]
|
||||||
stdin_timeout: Option<u64>,
|
stdin_timeout: Option<u64>,
|
||||||
|
|
||||||
|
/// Start in this input mode (insert or normal, default: insert)
|
||||||
|
#[arg(long, value_name = "MODE", default_value = "insert")]
|
||||||
|
start_mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -127,12 +131,25 @@ fn main() {
|
|||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse start mode
|
||||||
|
let start_mode = match cli.start_mode.as_str() {
|
||||||
|
"insert" => Mode::Insert,
|
||||||
|
"normal" => Mode::Normal,
|
||||||
|
other => {
|
||||||
|
let _ = writeln!(
|
||||||
|
std::io::stderr().lock(),
|
||||||
|
"pikl: unknown mode '{other}', expected insert or normal"
|
||||||
|
);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// STEP 4: Branch on headless vs interactive
|
// STEP 4: Branch on headless vs interactive
|
||||||
let label_key = cli.label_key.clone();
|
let label_key = cli.label_key.clone();
|
||||||
let result = if let Some(script) = script {
|
let result = if let Some(script) = script {
|
||||||
rt.block_on(run_headless(items, label_key, script))
|
rt.block_on(run_headless(items, label_key, script, start_mode))
|
||||||
} else {
|
} else {
|
||||||
rt.block_on(run_interactive(items, label_key))
|
rt.block_on(run_interactive(items, label_key, start_mode))
|
||||||
};
|
};
|
||||||
|
|
||||||
// STEP 5: Handle result
|
// STEP 5: Handle result
|
||||||
@@ -145,8 +162,10 @@ async fn run_headless(
|
|||||||
items: Vec<Item>,
|
items: Vec<Item>,
|
||||||
label_key: String,
|
label_key: String,
|
||||||
script: Vec<ScriptAction>,
|
script: Vec<ScriptAction>,
|
||||||
|
start_mode: Mode,
|
||||||
) -> Result<MenuResult, PiklError> {
|
) -> Result<MenuResult, PiklError> {
|
||||||
let (menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key));
|
let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key));
|
||||||
|
menu.set_initial_mode(start_mode);
|
||||||
let event_rx = menu.subscribe();
|
let event_rx = menu.subscribe();
|
||||||
|
|
||||||
// Default headless viewport
|
// Default headless viewport
|
||||||
@@ -183,8 +202,13 @@ async fn run_headless(
|
|||||||
|
|
||||||
/// Run in interactive mode: start the TUI and let the user
|
/// Run in interactive mode: start the TUI and let the user
|
||||||
/// pick from the menu.
|
/// pick from the menu.
|
||||||
async fn run_interactive(items: Vec<Item>, label_key: String) -> Result<MenuResult, PiklError> {
|
async fn run_interactive(
|
||||||
let (menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key));
|
items: Vec<Item>,
|
||||||
|
label_key: String,
|
||||||
|
start_mode: Mode,
|
||||||
|
) -> Result<MenuResult, PiklError> {
|
||||||
|
let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key));
|
||||||
|
menu.set_initial_mode(start_mode);
|
||||||
let event_rx = menu.subscribe();
|
let event_rx = menu.subscribe();
|
||||||
|
|
||||||
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
|
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
|
||||||
@@ -222,11 +246,11 @@ fn run_result_hook(
|
|||||||
command: Option<&str>,
|
command: Option<&str>,
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
) {
|
) {
|
||||||
if let Some(cmd) = command {
|
if let Some(cmd) = command
|
||||||
if let Err(e) = rt.block_on(hook::run_hook(cmd, value)) {
|
&& let Err(e) = rt.block_on(hook::run_hook(cmd, value))
|
||||||
let _ = writeln!(std::io::stderr().lock(), "pikl: {hook_name} hook: {e}");
|
{
|
||||||
std::process::exit(2);
|
let _ = writeln!(std::io::stderr().lock(), "pikl: {hook_name} hook: {e}");
|
||||||
}
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,4 +266,31 @@ pikl_tests! {
|
|||||||
exit: 0
|
exit: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headless mod new_actions {
|
||||||
|
items: ["a","b","c","d","e","f","g","h","i","j"];
|
||||||
|
|
||||||
|
test half_page_down_confirm {
|
||||||
|
actions: [raw "half-page-down", confirm]
|
||||||
|
// headless viewport=50, half=25, clamps to last (idx 9) -> "j"
|
||||||
|
stdout: "j"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test set_mode_round_trip {
|
||||||
|
actions: [raw "set-mode normal", raw "set-mode insert", confirm]
|
||||||
|
stdout: "a"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headless mod pipeline_headless {
|
||||||
|
items: ["error_log", "warning_temp", "info_log"];
|
||||||
|
|
||||||
|
test pipeline_filter {
|
||||||
|
actions: [filter "'log | !error", confirm]
|
||||||
|
stdout: "info_log"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user