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:
@@ -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<ViewState> = 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<ListItem> = 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<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) {
|
||||
(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<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)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user