diff --git a/crates/pikl-core/src/model/event.rs b/crates/pikl-core/src/model/event.rs index 1769b8b..c553a03 100644 --- a/crates/pikl-core/src/model/event.rs +++ b/crates/pikl-core/src/model/event.rs @@ -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, pub total_items: usize, pub total_filtered: usize, + pub mode: Mode, } /// A single item in the current viewport window. Has the diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs index c3ec5d6..f5df78c 100644 --- a/crates/pikl-core/src/runtime/menu.rs +++ b/crates/pikl-core/src/runtime/menu.rs @@ -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 { menu: M, viewport: Viewport, filter_text: Arc, + mode: Mode, action_rx: mpsc::Receiver, event_tx: broadcast::Sender, } @@ -55,6 +56,7 @@ impl MenuRunner { menu, viewport: Viewport::new(), filter_text: Arc::from(""), + mode: Mode::default(), action_rx, event_tx, }; @@ -100,6 +102,7 @@ impl MenuRunner { 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 MenuRunner { 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 MenuRunner { } } + /// 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. diff --git a/crates/pikl-core/src/script/action_fd/parse.rs b/crates/pikl-core/src/script/action_fd/parse.rs index 274b80d..73857e9 100644 --- a/crates/pikl-core/src/script/action_fd/parse.rs +++ b/crates/pikl-core/src/script/action_fd/parse.rs @@ -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 { + 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)); + } } diff --git a/crates/pikl-core/tests/dsl_tests.rs b/crates/pikl-core/tests/dsl_tests.rs index 1ef1e48..707a8f3 100644 --- a/crates/pikl-core/tests/dsl_tests.rs +++ b/crates/pikl-core/tests/dsl_tests.rs @@ -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 + } + } } diff --git a/crates/pikl-test-macros/src/codegen.rs b/crates/pikl-test-macros/src/codegen.rs index 033ef69..a0fbccf 100644 --- a/crates/pikl-test-macros/src/codegen.rs +++ b/crates/pikl-test-macros/src/codegen.rs @@ -275,7 +275,12 @@ fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result> { let method = Ident::new(&name.replace('-', "_"), Span::call_site()); let needs_count = matches!( 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 { calls.push(quote! { v.#method(1); }); @@ -418,6 +423,10 @@ fn menu_action_variant(name: &str) -> syn::Result { "move-to-bottom" => quote! { Action::MoveToBottom }, "page-up" => quote! { Action::PageUp(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( Span::call_site(), diff --git a/crates/pikl-tui/src/lib.rs b/crates/pikl-tui/src/lib.rs index cab946e..e4490d6 100644 --- a/crates/pikl-tui/src/lib.rs +++ b/crates/pikl-tui/src/lib.rs @@ -14,7 +14,15 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{List, ListItem, Paragraph}; 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 /// 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 view_state: Option = None; let mut event_stream = EventStream::new(); + let mut mode = Mode::Insert; + let mut pending = PendingKey::None; loop { if let Some(ref vs) = view_state { @@ -89,7 +99,12 @@ async fn run_inner( let event = event_result?; match event { 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; } } @@ -110,6 +125,11 @@ async fn run_inner( if &*vs.filter_text != filter_text.as_str() { 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); } 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 total_count = vs.total_items; + let mode_indicator = match vs.mode { + Mode::Insert => "[I]", + Mode::Normal => "[N]", + }; + 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::styled( 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.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 = vs .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 /// `filter_text` in place for character/backspace keys. /// Returns `None` for unmapped keys. -fn map_key_event(key: KeyEvent, filter_text: &mut String) -> Option { +fn map_key_event( + key: KeyEvent, + filter_text: &mut String, + mode: Mode, + pending: &mut PendingKey, +) -> Option { + 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 { match (key.code, key.modifiers) { (KeyCode::Esc, _) => Some(Action::Cancel), (KeyCode::Enter, _) => Some(Action::Confirm), (KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => Some(Action::MoveUp(1)), - (KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - Some(Action::MoveDown(1)) - } + (KeyCode::Down, _) => Some(Action::MoveDown(1)), + (KeyCode::Char('n'), KeyModifiers::CONTROL) => Some(Action::SetMode(Mode::Normal)), (KeyCode::PageUp, _) => Some(Action::PageUp(1)), (KeyCode::PageDown, _) => Some(Action::PageDown(1)), (KeyCode::Backspace, _) => { @@ -193,6 +240,52 @@ fn map_key_event(key: KeyEvent, filter_text: &mut String) -> Option { } } +/// Normal mode: vim-style navigation keybinds. +fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option { + // 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)] mod tests { use super::*; @@ -240,16 +333,18 @@ mod tests { filter_text: Arc::from(""), total_items: 5, total_filtered: 3, + mode: Mode::Insert, } } - // -- Key mapping tests -- + // -- Insert mode key mapping tests -- #[test] fn esc_maps_to_cancel() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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) ); } @@ -257,8 +352,9 @@ mod tests { #[test] fn enter_maps_to_confirm() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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) ); } @@ -266,8 +362,9 @@ mod tests { #[test] fn arrow_up_maps_to_move_up() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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)) ); } @@ -275,8 +372,9 @@ mod tests { #[test] fn arrow_down_maps_to_move_down() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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)) ); } @@ -284,26 +382,35 @@ mod tests { #[test] fn ctrl_p_maps_to_move_up() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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] - fn ctrl_n_maps_to_move_down() { + fn ctrl_n_maps_to_normal_mode() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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] fn page_keys_map_correctly() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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)) ); 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)) ); } @@ -311,11 +418,12 @@ mod tests { #[test] fn char_appends_to_filter() { 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!(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!(action, Some(Action::UpdateFilter("ab".into()))); } @@ -323,7 +431,8 @@ mod tests { #[test] fn backspace_pops_filter() { 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!(action, Some(Action::UpdateFilter("ab".into()))); } @@ -331,33 +440,37 @@ mod tests { #[test] fn backspace_on_empty_filter_is_noop() { 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!(action, Some(Action::UpdateFilter(String::new()))); } #[test] - fn ctrl_char_ignored() { + fn ctrl_char_ignored_in_insert() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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, ""); } #[test] fn alt_char_ignored() { let mut ft = String::new(); + let mut pending = PendingKey::None; 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, ""); } #[test] fn shift_char_passes_through() { let mut ft = String::new(); + let mut pending = PendingKey::None; let k = key_with_mods(KeyCode::Char('A'), KeyModifiers::SHIFT); assert_eq!( - map_key_event(k, &mut ft), + map_key_event(k, &mut ft, Mode::Insert, &mut pending), Some(Action::UpdateFilter("A".into())) ); assert_eq!(ft, "A"); @@ -366,9 +479,184 @@ mod tests { #[test] fn unmapped_key_returns_none() { let mut ft = String::new(); - assert_eq!(map_key_event(key(KeyCode::Tab), &mut ft), None); - assert_eq!(map_key_event(key(KeyCode::Home), &mut ft), None); - assert_eq!(map_key_event(key(KeyCode::F(1)), &mut ft), None); + let mut pending = PendingKey::None; + assert_eq!( + 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) -- @@ -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] fn items_render_in_list_area() { let vs = sample_view_state(); @@ -474,6 +782,7 @@ mod tests { filter_text: Arc::from(""), total_items: 0, total_filtered: 0, + mode: Mode::Insert, }; let backend = render_to_backend(30, 4, &vs, ""); let prompt = line_text(&backend, 0); @@ -510,9 +819,54 @@ mod tests { }) .ok() .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"); - 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"); } + + #[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); + } } diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index 6322835..3a9dd72 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -6,7 +6,7 @@ use std::time::Duration; use clap::Parser; 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::item::Item; 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) #[arg(long, value_name = "SECONDS")] stdin_timeout: Option, + + /// Start in this input mode (insert or normal, default: insert) + #[arg(long, value_name = "MODE", default_value = "insert")] + start_mode: String, } fn main() { @@ -127,12 +131,25 @@ fn main() { 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 let label_key = cli.label_key.clone(); 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 { - rt.block_on(run_interactive(items, label_key)) + rt.block_on(run_interactive(items, label_key, start_mode)) }; // STEP 5: Handle result @@ -145,8 +162,10 @@ async fn run_headless( items: Vec, label_key: String, script: Vec, + start_mode: Mode, ) -> Result { - 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(); // Default headless viewport @@ -183,8 +202,13 @@ async fn run_headless( /// Run in interactive mode: start the TUI and let the user /// pick from the menu. -async fn run_interactive(items: Vec, label_key: String) -> Result { - let (menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key)); +async fn run_interactive( + items: Vec, + label_key: String, + start_mode: Mode, +) -> Result { + let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key)); + menu.set_initial_mode(start_mode); let event_rx = menu.subscribe(); // Handle SIGINT/SIGTERM: restore terminal and exit cleanly. @@ -222,11 +246,11 @@ fn run_result_hook( command: Option<&str>, value: &serde_json::Value, ) { - if let Some(cmd) = command { - if 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); - } + if let Some(cmd) = command + && 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); } } diff --git a/crates/pikl/tests/headless_dsl.rs b/crates/pikl/tests/headless_dsl.rs index 6d17b02..a6331f4 100644 --- a/crates/pikl/tests/headless_dsl.rs +++ b/crates/pikl/tests/headless_dsl.rs @@ -266,4 +266,31 @@ pikl_tests! { 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 + } + } }