diff --git a/crates/pikl-tui/src/lib.rs b/crates/pikl-tui/src/lib.rs new file mode 100644 index 0000000..cab946e --- /dev/null +++ b/crates/pikl-tui/src/lib.rs @@ -0,0 +1,518 @@ +//! TUI frontend for pikl-menu. Thin rendering layer on +//! top of pikl-core. Translates crossterm key events into +//! [`Action`]s and renders [`ViewState`] snapshots via +//! ratatui. All state lives in the core; this crate is +//! just I/O. + +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers}; +use futures::StreamExt; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, Paragraph}; +use tokio::sync::{broadcast, mpsc}; + +use pikl_core::event::{Action, MenuEvent, ViewState}; + +/// Restore the terminal to a sane state. Called on clean +/// exit and from the panic hook so Ctrl+C or a crash +/// doesn't leave the terminal in raw mode. +pub fn restore_terminal() { + let _ = crossterm::terminal::disable_raw_mode(); + let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen); +} + +/// Start the TUI. Enters the alternate screen, runs the +/// event loop, and restores the terminal on exit. Translates +/// crossterm key events into [`Action`]s and renders +/// [`ViewState`] snapshots. +pub async fn run( + action_tx: mpsc::Sender, + mut event_rx: broadcast::Receiver, +) -> std::io::Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?; + + let backend = CrosstermBackend::new(std::io::stderr()); + let mut terminal = Terminal::new(backend)?; + + // Drain any stale input that arrived between dup2 and raw mode + // (e.g. the Enter keypress from running the command). Poll with a + // short timeout so late-arriving bytes are caught too. + while crossterm::event::poll(std::time::Duration::from_millis(50))? { + let _ = crossterm::event::read()?; + } + + let result = run_inner(&action_tx, &mut event_rx, &mut terminal).await; + + // Always clean up terminal, even on error + restore_terminal(); + + result +} + +/// Inner event loop. Separated from [`run`] so terminal +/// cleanup always happens even if this returns an error. +async fn run_inner( + action_tx: &mpsc::Sender, + event_rx: &mut broadcast::Receiver, + terminal: &mut Terminal>, +) -> std::io::Result<()> { + // Send initial resize + let size = terminal.size()?; + let list_height = size.height.saturating_sub(1); + let _ = action_tx + .send(Action::Resize { + height: list_height, + }) + .await; + + let mut filter_text = String::new(); + let mut view_state: Option = None; + let mut event_stream = EventStream::new(); + + loop { + if let Some(ref vs) = view_state { + let ft = filter_text.clone(); + terminal.draw(|frame| { + render_menu(frame, vs, &ft); + })?; + } + + tokio::select! { + term_event = event_stream.next() => { + let Some(event_result) = term_event else { + break; + }; + let event = event_result?; + match event { + Event::Key(key) => { + if let Some(action) = map_key_event(key, &mut filter_text) { + let _ = action_tx.send(action).await; + } + } + Event::Resize(_, h) => { + let list_height = h.saturating_sub(1); + let _ = action_tx.send(Action::Resize { height: list_height }).await; + } + _ => {} + } + } + menu_event = event_rx.recv() => { + match menu_event { + Ok(MenuEvent::StateChanged(vs)) => { + // Sync filter text from core. Local keystrokes + // update filter_text immediately for responsiveness, + // but if core pushes a different value (e.g. IPC + // changed the filter), the core wins. + if &*vs.filter_text != filter_text.as_str() { + filter_text = vs.filter_text.to_string(); + } + view_state = Some(vs); + } + Ok(MenuEvent::Selected(_) | MenuEvent::Cancelled) => { + break; + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => { + break; + } + } + } + } + } + + Ok(()) +} + +/// Render the menu into the given frame. Extracted from the +/// event loop so it can be tested with a [`TestBackend`]. +fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(frame.area()); + + let filtered_count = vs.total_filtered; + let total_count = vs.total_items; + + let prompt = Paragraph::new(Line::from(vec![ + Span::styled("> ", Style::default().fg(Color::Cyan)), + Span::raw(filter_text), + Span::styled( + format!(" {filtered_count}/{total_count}"), + Style::default().fg(Color::DarkGray), + ), + ])); + frame.render_widget(prompt, chunks[0]); + + frame.set_cursor_position(((2 + filter_text.len()) as u16, 0)); + + let items: Vec = vs + .visible_items + .iter() + .enumerate() + .map(|(i, vi)| { + let style = if i == vs.cursor { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + ListItem::new(vi.label.as_str()).style(style) + }) + .collect(); + + let list = List::new(items); + frame.render_widget(list, chunks[1]); +} + +/// 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 { + 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::PageUp, _) => Some(Action::PageUp(1)), + (KeyCode::PageDown, _) => Some(Action::PageDown(1)), + (KeyCode::Backspace, _) => { + filter_text.pop(); + Some(Action::UpdateFilter(filter_text.clone())) + } + (KeyCode::Char(c), mods) if !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + filter_text.push(c); + Some(Action::UpdateFilter(filter_text.clone())) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + use pikl_core::event::{ViewState, VisibleItem}; + use ratatui::backend::TestBackend; + use ratatui::style::Modifier; + use std::sync::Arc; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn key_with_mods(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn sample_view_state() -> ViewState { + ViewState { + visible_items: vec![ + VisibleItem { + label: "alpha".into(), + index: 0, + }, + VisibleItem { + label: "bravo".into(), + index: 1, + }, + VisibleItem { + label: "charlie".into(), + index: 2, + }, + ], + cursor: 0, + filter_text: Arc::from(""), + total_items: 5, + total_filtered: 3, + } + } + + // -- Key mapping tests -- + + #[test] + fn esc_maps_to_cancel() { + let mut ft = String::new(); + assert_eq!( + map_key_event(key(KeyCode::Esc), &mut ft), + Some(Action::Cancel) + ); + } + + #[test] + fn enter_maps_to_confirm() { + let mut ft = String::new(); + assert_eq!( + map_key_event(key(KeyCode::Enter), &mut ft), + Some(Action::Confirm) + ); + } + + #[test] + fn arrow_up_maps_to_move_up() { + let mut ft = String::new(); + assert_eq!( + map_key_event(key(KeyCode::Up), &mut ft), + Some(Action::MoveUp(1)) + ); + } + + #[test] + fn arrow_down_maps_to_move_down() { + let mut ft = String::new(); + assert_eq!( + map_key_event(key(KeyCode::Down), &mut ft), + Some(Action::MoveDown(1)) + ); + } + + #[test] + fn ctrl_p_maps_to_move_up() { + let mut ft = String::new(); + let k = key_with_mods(KeyCode::Char('p'), KeyModifiers::CONTROL); + assert_eq!(map_key_event(k, &mut ft), Some(Action::MoveUp(1))); + } + + #[test] + fn ctrl_n_maps_to_move_down() { + let mut ft = String::new(); + let k = key_with_mods(KeyCode::Char('n'), KeyModifiers::CONTROL); + assert_eq!(map_key_event(k, &mut ft), Some(Action::MoveDown(1))); + } + + #[test] + fn page_keys_map_correctly() { + let mut ft = String::new(); + assert_eq!( + map_key_event(key(KeyCode::PageUp), &mut ft), + Some(Action::PageUp(1)) + ); + assert_eq!( + map_key_event(key(KeyCode::PageDown), &mut ft), + Some(Action::PageDown(1)) + ); + } + + #[test] + fn char_appends_to_filter() { + let mut ft = String::new(); + let action = map_key_event(key(KeyCode::Char('a')), &mut ft); + assert_eq!(ft, "a"); + assert_eq!(action, Some(Action::UpdateFilter("a".into()))); + + let action = map_key_event(key(KeyCode::Char('b')), &mut ft); + assert_eq!(ft, "ab"); + assert_eq!(action, Some(Action::UpdateFilter("ab".into()))); + } + + #[test] + fn backspace_pops_filter() { + let mut ft = "abc".to_string(); + let action = map_key_event(key(KeyCode::Backspace), &mut ft); + assert_eq!(ft, "ab"); + assert_eq!(action, Some(Action::UpdateFilter("ab".into()))); + } + + #[test] + fn backspace_on_empty_filter_is_noop() { + let mut ft = String::new(); + let action = map_key_event(key(KeyCode::Backspace), &mut ft); + assert_eq!(ft, ""); + assert_eq!(action, Some(Action::UpdateFilter(String::new()))); + } + + #[test] + fn ctrl_char_ignored() { + let mut ft = String::new(); + let k = key_with_mods(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(map_key_event(k, &mut ft), None); + assert_eq!(ft, ""); + } + + #[test] + fn alt_char_ignored() { + let mut ft = String::new(); + let k = key_with_mods(KeyCode::Char('x'), KeyModifiers::ALT); + assert_eq!(map_key_event(k, &mut ft), None); + assert_eq!(ft, ""); + } + + #[test] + fn shift_char_passes_through() { + let mut ft = String::new(); + let k = key_with_mods(KeyCode::Char('A'), KeyModifiers::SHIFT); + assert_eq!( + map_key_event(k, &mut ft), + Some(Action::UpdateFilter("A".into())) + ); + assert_eq!(ft, "A"); + } + + #[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); + } + + // -- Rendering tests (TestBackend) -- + + fn render_to_backend(width: u16, height: u16, vs: &ViewState, filter: &str) -> TestBackend { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).ok().expect("test terminal"); + terminal + .draw(|frame| { + render_menu(frame, vs, filter); + }) + .ok() + .expect("draw"); + terminal.backend().clone() + } + + fn line_text(backend: &TestBackend, row: u16) -> String { + let buf = backend.buffer(); + let width = buf.area.width; + let mut s = String::new(); + for col in 0..width { + let cell = &buf[(col, row)]; + s.push_str(cell.symbol()); + } + // Trim trailing whitespace for easier assertions + s.trim_end().to_string() + } + + #[test] + fn prompt_shows_count() { + let vs = sample_view_state(); + let backend = render_to_backend(30, 6, &vs, ""); + let prompt = line_text(&backend, 0); + assert!(prompt.contains(">"), "prompt should have > prefix"); + assert!( + prompt.contains("3/5"), + "prompt should show filtered/total: got '{prompt}'" + ); + } + + #[test] + fn prompt_shows_filter_text() { + let vs = sample_view_state(); + let backend = render_to_backend(30, 6, &vs, "foo"); + let prompt = line_text(&backend, 0); + assert!( + prompt.contains("foo"), + "prompt should contain filter text: got '{prompt}'" + ); + } + + #[test] + fn items_render_in_list_area() { + let vs = sample_view_state(); + let backend = render_to_backend(30, 6, &vs, ""); + // Items start at row 1 (row 0 is the prompt) + let row1 = line_text(&backend, 1); + let row2 = line_text(&backend, 2); + let row3 = line_text(&backend, 3); + assert!(row1.contains("alpha"), "row 1: got '{row1}'"); + assert!(row2.contains("bravo"), "row 2: got '{row2}'"); + assert!(row3.contains("charlie"), "row 3: got '{row3}'"); + } + + #[test] + fn cursor_row_has_reversed_style() { + let vs = sample_view_state(); // cursor at 0 + let backend = render_to_backend(30, 6, &vs, ""); + let buf = backend.buffer(); + // Row 1, col 0 should be the cursor row with REVERSED modifier + let cell = &buf[(0, 1)]; + assert!( + cell.modifier.contains(Modifier::REVERSED), + "cursor row should have REVERSED style" + ); + // Row 2 should not + let cell2 = &buf[(0, 2)]; + assert!( + !cell2.modifier.contains(Modifier::REVERSED), + "non-cursor row should not have REVERSED" + ); + } + + #[test] + fn cursor_at_middle_item() { + let mut vs = sample_view_state(); + vs.cursor = 1; // bravo + let backend = render_to_backend(30, 6, &vs, ""); + let buf = backend.buffer(); + // Row 1 (alpha) should NOT be reversed + let cell1 = &buf[(0, 1)]; + assert!(!cell1.modifier.contains(Modifier::REVERSED)); + // Row 2 (bravo) should be reversed + let cell2 = &buf[(0, 2)]; + assert!(cell2.modifier.contains(Modifier::REVERSED)); + } + + #[test] + fn empty_items_still_renders_prompt() { + let vs = ViewState { + visible_items: vec![], + cursor: 0, + filter_text: Arc::from(""), + total_items: 0, + total_filtered: 0, + }; + let backend = render_to_backend(30, 4, &vs, ""); + let prompt = line_text(&backend, 0); + assert!(prompt.contains(">"), "prompt renders even with no items"); + assert!(prompt.contains("0/0")); + } + + #[test] + fn narrow_viewport_truncates() { + let vs = sample_view_state(); + // 10 cols wide. Items should be truncated, not panic. + let backend = render_to_backend(10, 5, &vs, ""); + let row1 = line_text(&backend, 1); + // "alpha" is 5 chars, should fit in 10-wide viewport + assert!(row1.contains("alpha")); + } + + #[test] + fn minimal_viewport_does_not_panic() { + let vs = sample_view_state(); + // Absolute minimum: 1 col wide, 2 rows (1 prompt + 1 list) + let _backend = render_to_backend(1, 2, &vs, ""); + // Just verifying it doesn't panic + } + + #[test] + fn prompt_cursor_position_tracks_filter() { + let vs = sample_view_state(); + let backend = TestBackend::new(30, 6); + let mut terminal = Terminal::new(backend).ok().expect("test terminal"); + terminal + .draw(|frame| { + render_menu(frame, &vs, "hi"); + }) + .ok() + .expect("draw"); + // Cursor should be at column 2 ("> ") + 2 ("hi") = 4 + let pos = terminal.get_cursor_position().ok().expect("cursor"); + assert_eq!(pos.x, 4, "cursor x should be after '> hi'"); + assert_eq!(pos.y, 0, "cursor y should be on prompt row"); + } +}