feat(tui): Add ratatui frontend rendering layer.
This commit is contained in:
518
crates/pikl-tui/src/lib.rs
Normal file
518
crates/pikl-tui/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user