feat(tui): Add ratatui frontend rendering layer.

This commit is contained in:
2026-03-13 21:58:38 -04:00
parent 522b9f2894
commit 3f2e5c779b

518
crates/pikl-tui/src/lib.rs Normal file
View File

@@ -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<Action>,
mut event_rx: broadcast::Receiver<MenuEvent>,
) -> 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<Action>,
event_rx: &mut broadcast::Receiver<MenuEvent>,
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
) -> 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<ViewState> = 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<ListItem> = 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<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::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");
}
}