use std::borrow::Cow;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use ratatui::Frame;
#[derive(Debug, Default, Clone)]
pub(super) struct ListScreenState {
cursor: usize,
move_mode: bool,
}
#[allow(dead_code)]
impl ListScreenState {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn cursor(&self) -> usize {
self.cursor
}
pub(super) fn move_mode(&self) -> bool {
self.move_mode
}
pub(super) fn set_cursor(&mut self, idx: usize, num_rows: usize) {
self.cursor = if num_rows == 0 {
0
} else {
idx.min(num_rows - 1)
};
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ListOutcome {
Consumed,
Activate,
Action(char),
MoveSwap { from: usize, to: usize },
Unhandled,
}
#[derive(Debug, Clone)]
pub(super) struct ListRowData<'a> {
pub(super) label: Cow<'a, str>,
pub(super) description: Cow<'a, str>,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct VerbHint<'a> {
pub(super) letter: char,
pub(super) label: &'a str,
}
#[derive(Debug, Clone)]
pub(super) struct ListScreenView<'a> {
pub(super) title: &'a str,
pub(super) rows: &'a [ListRowData<'a>],
pub(super) verbs: &'a [VerbHint<'a>],
pub(super) move_mode_supported: bool,
}
pub(super) fn handle_key(
state: &mut ListScreenState,
key: KeyEvent,
num_rows: usize,
verb_letters: &[char],
move_mode_supported: bool,
) -> ListOutcome {
if num_rows == 0 {
state.cursor = 0;
} else if state.cursor >= num_rows {
state.cursor = num_rows - 1;
}
if !move_mode_supported {
let was_in_move_mode = state.move_mode;
state.move_mode = false;
if was_in_move_mode {
return ListOutcome::Unhandled;
}
}
if state.move_mode {
return handle_move_mode(state, key, num_rows);
}
handle_normal_mode(state, key, num_rows, verb_letters, move_mode_supported)
}
fn handle_move_mode(state: &mut ListScreenState, key: KeyEvent, num_rows: usize) -> ListOutcome {
if key.modifiers != KeyModifiers::NONE {
return ListOutcome::Unhandled;
}
match key.code {
KeyCode::Esc | KeyCode::Enter => {
state.move_mode = false;
ListOutcome::Consumed
}
KeyCode::Up if num_rows >= 2 && state.cursor > 0 => {
let from = state.cursor;
let to = from - 1;
ListOutcome::MoveSwap { from, to }
}
KeyCode::Down if num_rows >= 2 && state.cursor + 1 < num_rows => {
let from = state.cursor;
let to = from + 1;
ListOutcome::MoveSwap { from, to }
}
KeyCode::Up | KeyCode::Down => ListOutcome::Consumed,
_ => ListOutcome::Unhandled,
}
}
fn handle_normal_mode(
state: &mut ListScreenState,
key: KeyEvent,
num_rows: usize,
verb_letters: &[char],
move_mode_supported: bool,
) -> ListOutcome {
match (key.code, key.modifiers) {
(KeyCode::Up, KeyModifiers::NONE) => {
if num_rows == 0 {
return ListOutcome::Consumed;
}
state.cursor = if state.cursor == 0 {
num_rows - 1
} else {
state.cursor - 1
};
ListOutcome::Consumed
}
(KeyCode::Down, KeyModifiers::NONE) => {
if num_rows == 0 {
return ListOutcome::Consumed;
}
state.cursor = if state.cursor + 1 >= num_rows {
0
} else {
state.cursor + 1
};
ListOutcome::Consumed
}
(KeyCode::Enter, KeyModifiers::NONE) => {
if num_rows == 0 {
return ListOutcome::Unhandled;
}
if move_mode_supported {
state.move_mode = true;
ListOutcome::Consumed
} else {
ListOutcome::Activate
}
}
(KeyCode::Char(c), KeyModifiers::NONE)
if num_rows > 0 && c.is_ascii_lowercase() && verb_letters.contains(&c) =>
{
ListOutcome::Action(c)
}
_ => ListOutcome::Unhandled,
}
}
pub(super) fn render(
state: &ListScreenState,
view: &ListScreenView<'_>,
area: Rect,
frame: &mut Frame,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
let title = Paragraph::new(Line::from(Span::styled(
view.title,
Style::default().add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center);
frame.render_widget(title, chunks[0]);
let help = Paragraph::new(help_line(
view.verbs,
state.move_mode,
view.move_mode_supported,
))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[1]);
let cursor = if view.rows.is_empty() {
0
} else {
state.cursor.min(view.rows.len() - 1)
};
let items: Vec<ListItem<'_>> = view
.rows
.iter()
.map(|row| ListItem::new(Line::from(row.label.as_ref())))
.collect();
let highlight_style = if state.move_mode {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default().add_modifier(Modifier::BOLD)
};
let list = List::new(items)
.highlight_symbol("▶ ")
.highlight_style(highlight_style);
let mut list_state = ListState::default();
if !view.rows.is_empty() {
list_state.select(Some(cursor));
}
frame.render_stateful_widget(list, chunks[3], &mut list_state);
let description = view
.rows
.get(cursor)
.map(|row| row.description.as_ref())
.unwrap_or("");
let description =
Paragraph::new(Line::from(Span::raw(description))).alignment(Alignment::Center);
frame.render_widget(description, chunks[5]);
}
fn help_line<'a>(
verbs: &'a [VerbHint<'a>],
move_mode: bool,
move_mode_supported: bool,
) -> Line<'a> {
if move_mode {
return Line::from(Span::styled(
"move-mode: ↑↓ reorder · Esc/Enter exit",
Style::default().add_modifier(Modifier::ITALIC),
));
}
let mut spans: Vec<Span<'a>> = Vec::with_capacity(verbs.len() * 4 + 2);
for (idx, verb) in verbs.iter().enumerate() {
if idx > 0 {
spans.push(Span::raw(" · "));
}
spans.push(Span::styled(
verb.letter.to_string(),
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::raw(verb.label));
}
if move_mode_supported {
if !spans.is_empty() {
spans.push(Span::raw(" · "));
}
spans.push(Span::styled(
"Enter",
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" move-mode"));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn key_mod(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
#[test]
fn default_state_is_cursor_zero_normal_mode() {
let s = ListScreenState::new();
assert_eq!(s.cursor(), 0);
assert!(!s.move_mode());
}
#[test]
fn down_advances_cursor() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Down), 3, &[], false);
assert_eq!(out, ListOutcome::Consumed);
assert_eq!(s.cursor(), 1);
}
#[test]
fn up_at_top_wraps_to_bottom() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Up), 3, &[], false);
assert_eq!(out, ListOutcome::Consumed);
assert_eq!(s.cursor(), 2);
}
#[test]
fn down_at_bottom_wraps_to_top() {
let mut s = ListScreenState::new();
s.set_cursor(2, 3);
let out = handle_key(&mut s, key(KeyCode::Down), 3, &[], false);
assert_eq!(out, ListOutcome::Consumed);
assert_eq!(s.cursor(), 0);
}
#[test]
fn arrows_on_empty_list_are_no_op() {
let mut s = ListScreenState::new();
assert_eq!(
handle_key(&mut s, key(KeyCode::Up), 0, &[], false),
ListOutcome::Consumed,
);
assert_eq!(
handle_key(&mut s, key(KeyCode::Down), 0, &[], false),
ListOutcome::Consumed,
);
assert_eq!(s.cursor(), 0);
}
#[test]
fn enter_with_move_mode_supported_toggles_into_move_mode() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Enter), 3, &[], true);
assert_eq!(out, ListOutcome::Consumed);
assert!(s.move_mode());
}
#[test]
fn enter_without_move_mode_returns_activate() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Enter), 3, &[], false);
assert_eq!(out, ListOutcome::Activate);
assert!(!s.move_mode());
}
#[test]
fn enter_on_empty_list_is_unhandled_regardless_of_move_mode_supported() {
let mut s = ListScreenState::new();
assert_eq!(
handle_key(&mut s, key(KeyCode::Enter), 0, &[], true),
ListOutcome::Unhandled,
);
assert_eq!(
handle_key(&mut s, key(KeyCode::Enter), 0, &[], false),
ListOutcome::Unhandled,
);
}
#[test]
fn esc_in_normal_mode_is_unhandled_for_global_quit() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Esc), 3, &[], false);
assert_eq!(out, ListOutcome::Unhandled);
}
#[test]
fn esc_in_move_mode_exits_move_mode() {
let mut s = ListScreenState::new();
s.move_mode = true;
let out = handle_key(&mut s, key(KeyCode::Esc), 3, &[], true);
assert_eq!(out, ListOutcome::Consumed);
assert!(!s.move_mode());
}
#[test]
fn enter_in_move_mode_exits_move_mode() {
let mut s = ListScreenState::new();
s.move_mode = true;
let out = handle_key(&mut s, key(KeyCode::Enter), 3, &[], true);
assert_eq!(out, ListOutcome::Consumed);
assert!(!s.move_mode());
}
#[test]
fn move_mode_down_requests_swap_without_moving_cursor() {
let mut s = ListScreenState::new();
s.move_mode = true;
s.set_cursor(0, 3);
let out = handle_key(&mut s, key(KeyCode::Down), 3, &[], true);
assert_eq!(out, ListOutcome::MoveSwap { from: 0, to: 1 });
assert_eq!(s.cursor(), 0);
assert!(s.move_mode());
}
#[test]
fn move_mode_up_requests_swap_without_moving_cursor() {
let mut s = ListScreenState::new();
s.move_mode = true;
s.set_cursor(2, 3);
let out = handle_key(&mut s, key(KeyCode::Up), 3, &[], true);
assert_eq!(out, ListOutcome::MoveSwap { from: 2, to: 1 });
assert_eq!(s.cursor(), 2);
assert!(s.move_mode());
}
#[test]
fn caller_ack_between_swaps_advances_to_next_neighbor() {
let mut s = ListScreenState::new();
s.move_mode = true;
s.set_cursor(0, 3);
let first = handle_key(&mut s, key(KeyCode::Down), 3, &[], true);
assert_eq!(first, ListOutcome::MoveSwap { from: 0, to: 1 });
s.set_cursor(1, 3);
let second = handle_key(&mut s, key(KeyCode::Down), 3, &[], true);
assert_eq!(second, ListOutcome::MoveSwap { from: 1, to: 2 });
}
#[test]
fn move_mode_down_with_stale_cursor_clamps_before_swap_check() {
let mut s = ListScreenState::new();
s.move_mode = true;
s.cursor = 99;
let out = handle_key(&mut s, key(KeyCode::Down), 3, &[], true);
assert_eq!(out, ListOutcome::Consumed);
assert_eq!(s.cursor(), 2);
assert!(s.move_mode());
}
#[test]
fn move_mode_up_at_top_does_not_swap_or_wrap() {
let mut s = ListScreenState::new();
s.move_mode = true;
s.set_cursor(0, 3);
let out = handle_key(&mut s, key(KeyCode::Up), 3, &[], true);
assert_eq!(out, ListOutcome::Consumed);
assert_eq!(s.cursor(), 0);
}
#[test]
fn move_mode_down_at_bottom_does_not_swap_or_wrap() {
let mut s = ListScreenState::new();
s.move_mode = true;
s.set_cursor(2, 3);
let out = handle_key(&mut s, key(KeyCode::Down), 3, &[], true);
assert_eq!(out, ListOutcome::Consumed);
assert_eq!(s.cursor(), 2);
}
#[test]
fn move_mode_with_one_row_is_consumed_no_swap() {
let mut s = ListScreenState::new();
s.move_mode = true;
let out = handle_key(&mut s, key(KeyCode::Down), 1, &[], true);
assert_eq!(out, ListOutcome::Consumed);
assert_eq!(s.cursor(), 0);
}
#[test]
fn move_mode_verb_letter_is_unhandled() {
let mut s = ListScreenState::new();
s.move_mode = true;
let out = handle_key(&mut s, key(KeyCode::Char('d')), 3, &['d'], true);
assert_eq!(out, ListOutcome::Unhandled);
assert!(s.move_mode());
}
#[test]
fn registered_verb_letter_emits_action() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Char('a')), 3, &['a', 'd'], false);
assert_eq!(out, ListOutcome::Action('a'));
}
#[test]
fn unregistered_letter_is_unhandled() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Char('z')), 3, &['a', 'd'], false);
assert_eq!(out, ListOutcome::Unhandled);
}
#[test]
fn verb_letter_with_modifier_is_unhandled() {
let mut s = ListScreenState::new();
let shift = handle_key(
&mut s,
key_mod(KeyCode::Char('a'), KeyModifiers::SHIFT),
3,
&['a'],
false,
);
assert_eq!(shift, ListOutcome::Unhandled);
let ctrl = handle_key(
&mut s,
key_mod(KeyCode::Char('a'), KeyModifiers::CONTROL),
3,
&['a'],
false,
);
assert_eq!(ctrl, ListOutcome::Unhandled);
}
#[test]
fn uppercase_letter_in_verb_list_still_does_not_dispatch() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Char('A')), 3, &['A'], false);
assert_eq!(out, ListOutcome::Unhandled);
}
#[test]
fn verb_letter_on_empty_list_returns_unhandled() {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key(KeyCode::Char('a')), 0, &['a'], false);
assert_eq!(out, ListOutcome::Unhandled);
}
#[test]
fn move_mode_supported_flipping_off_drops_trigger_key() {
let mut s = ListScreenState::new();
s.move_mode = true;
let out = handle_key(&mut s, key(KeyCode::Down), 3, &[], false);
assert!(!s.move_mode());
assert_eq!(out, ListOutcome::Unhandled);
assert_eq!(s.cursor(), 0);
}
#[test]
fn enter_with_stale_cursor_clamps_before_activate() {
let mut s = ListScreenState::new();
s.cursor = 5;
let out = handle_key(&mut s, key(KeyCode::Enter), 3, &[], false);
assert_eq!(out, ListOutcome::Activate);
assert_eq!(s.cursor(), 2);
}
#[test]
fn move_mode_arrows_with_modifier_are_unhandled() {
for mods in [KeyModifiers::SHIFT, KeyModifiers::CONTROL] {
let mut s = ListScreenState::new();
s.move_mode = true;
s.set_cursor(1, 3);
let out = handle_key(&mut s, key_mod(KeyCode::Down, mods), 3, &[], true);
assert_eq!(out, ListOutcome::Unhandled, "mods={mods:?}");
assert_eq!(
s.cursor(),
1,
"chord arrow must not move cursor (mods={mods:?})"
);
}
}
#[test]
fn arrows_with_modifier_in_normal_mode_are_unhandled() {
for mods in [KeyModifiers::SHIFT, KeyModifiers::CONTROL] {
let mut s = ListScreenState::new();
let out = handle_key(&mut s, key_mod(KeyCode::Down, mods), 3, &[], false);
assert_eq!(out, ListOutcome::Unhandled, "mods={mods:?}");
assert_eq!(s.cursor(), 0);
}
}
#[test]
fn handle_key_clamps_cursor_and_drops_move_mode_trigger_in_one_call() {
let mut s = ListScreenState::new();
s.cursor = 99;
s.move_mode = true;
let out = handle_key(&mut s, key(KeyCode::Down), 3, &[], false);
assert!(!s.move_mode(), "move_mode should clear");
assert_eq!(out, ListOutcome::Unhandled);
assert_eq!(s.cursor(), 2, "cursor clamped 99→2 and stays there");
}
#[test]
fn cursor_is_clamped_when_list_shrinks_under_it() {
let mut s = ListScreenState::new();
s.cursor = 5; let out = handle_key(&mut s, key(KeyCode::Down), 3, &[], false);
assert_eq!(out, ListOutcome::Consumed);
assert_eq!(s.cursor(), 0);
}
#[test]
fn set_cursor_clamps_to_valid_range() {
let mut s = ListScreenState::new();
s.set_cursor(99, 4);
assert_eq!(s.cursor(), 3);
s.set_cursor(99, 0);
assert_eq!(s.cursor(), 0);
}
#[test]
fn render_smoke_paints_title_cursor_and_description() {
let backend = TestBackend::new(40, 12);
let mut terminal = Terminal::new(backend).expect("backend");
let mut s = ListScreenState::new();
s.set_cursor(1, 3);
let rows = [
ListRowData {
label: Cow::Borrowed("First"),
description: Cow::Borrowed("desc-A"),
},
ListRowData {
label: Cow::Borrowed("Second"),
description: Cow::Borrowed("desc-B"),
},
ListRowData {
label: Cow::Borrowed("Third"),
description: Cow::Borrowed("desc-C"),
},
];
let verbs = [VerbHint {
letter: 'a',
label: "add",
}];
let view = ListScreenView {
title: "Demo",
rows: &rows,
verbs: &verbs,
move_mode_supported: false,
};
terminal
.draw(|frame| render(&s, &view, frame.area(), frame))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let dump: String = (0..buf.area.height)
.map(|y| {
let row: String = (0..buf.area.width)
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect();
format!("{row}\n")
})
.collect();
assert!(dump.contains("Demo"), "title missing:\n{dump}");
assert!(dump.contains("a add"), "help row missing 'a add':\n{dump}");
assert!(
dump.contains("▶ Second"),
"cursor on row 1 missing:\n{dump}"
);
assert!(dump.contains("First"), "row 0 label missing:\n{dump}");
assert!(dump.contains("Third"), "row 2 label missing:\n{dump}");
assert!(
dump.contains("desc-B"),
"highlighted row's description missing:\n{dump}",
);
assert!(
!dump.contains("desc-A"),
"non-highlighted description leaked:\n{dump}",
);
}
#[test]
fn render_in_move_mode_shows_move_mode_help_row() {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("backend");
let mut s = ListScreenState::new();
s.move_mode = true;
let rows = [ListRowData {
label: Cow::Borrowed("Only"),
description: Cow::Borrowed("desc"),
}];
let verbs = [VerbHint {
letter: 'a',
label: "add",
}];
let view = ListScreenView {
title: "Demo",
rows: &rows,
verbs: &verbs,
move_mode_supported: true,
};
terminal
.draw(|frame| render(&s, &view, frame.area(), frame))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let dump: String = (0..buf.area.height)
.map(|y| {
let row: String = (0..buf.area.width)
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect();
format!("{row}\n")
})
.collect();
assert!(
dump.contains("move-mode"),
"move-mode help row missing:\n{dump}",
);
assert!(
!dump.contains("a add"),
"verb-table help row leaked into move-mode:\n{dump}",
);
}
#[test]
fn render_advertises_enter_move_mode_when_supported_but_not_active() {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("backend");
let s = ListScreenState::new();
let rows = [ListRowData {
label: Cow::Borrowed("Only"),
description: Cow::Borrowed("desc"),
}];
let verbs = [VerbHint {
letter: 'a',
label: "add",
}];
let view = ListScreenView {
title: "Demo",
rows: &rows,
verbs: &verbs,
move_mode_supported: true,
};
terminal
.draw(|frame| render(&s, &view, frame.area(), frame))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let dump: String = (0..buf.area.height)
.map(|y| {
let row: String = (0..buf.area.width)
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect();
format!("{row}\n")
})
.collect();
assert!(dump.contains("a add"), "verbs still listed:\n{dump}");
assert!(
dump.contains("Enter move-mode"),
"Enter hint missing:\n{dump}",
);
}
#[test]
fn render_clamps_stale_cursor_for_both_highlight_and_description() {
let backend = TestBackend::new(40, 12);
let mut terminal = Terminal::new(backend).expect("backend");
let mut s = ListScreenState::new();
s.cursor = 99;
let rows = [
ListRowData {
label: Cow::Borrowed("First"),
description: Cow::Borrowed("desc-A"),
},
ListRowData {
label: Cow::Borrowed("Second"),
description: Cow::Borrowed("desc-B"),
},
ListRowData {
label: Cow::Borrowed("Third"),
description: Cow::Borrowed("desc-C"),
},
];
let verbs: [VerbHint<'_>; 0] = [];
let view = ListScreenView {
title: "Demo",
rows: &rows,
verbs: &verbs,
move_mode_supported: false,
};
terminal
.draw(|frame| render(&s, &view, frame.area(), frame))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let dump: String = (0..buf.area.height)
.map(|y| {
let row: String = (0..buf.area.width)
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect();
format!("{row}\n")
})
.collect();
assert!(
dump.contains("▶ Third"),
"highlight should clamp to last row:\n{dump}",
);
assert!(
dump.contains("desc-C"),
"description should clamp to same row as highlight:\n{dump}",
);
}
#[test]
fn render_with_empty_rows_does_not_panic() {
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("backend");
let s = ListScreenState::new();
let rows: [ListRowData<'_>; 0] = [];
let verbs: [VerbHint<'_>; 0] = [];
let view = ListScreenView {
title: "Empty",
rows: &rows,
verbs: &verbs,
move_mode_supported: false,
};
terminal
.draw(|frame| render(&s, &view, frame.area(), frame))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let dump: String = (0..buf.area.height)
.map(|y| {
let row: String = (0..buf.area.width)
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect();
format!("{row}\n")
})
.collect();
assert!(dump.contains("Empty"), "title still renders:\n{dump}");
}
fn dump_buffer(buf: &ratatui::buffer::Buffer) -> String {
let mut out =
String::with_capacity((buf.area.width as usize + 1) * buf.area.height as usize);
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn render_long_label_truncates_without_panic() {
let backend = TestBackend::new(20, 12);
let mut terminal = Terminal::new(backend).expect("backend");
let s = ListScreenState::new();
let long_label: String = "X".repeat(200);
let rows = [ListRowData {
label: Cow::Owned(long_label.clone()),
description: Cow::Borrowed("desc-A"),
}];
let verbs = [VerbHint {
letter: 'a',
label: "add",
}];
let view = ListScreenView {
title: "Demo",
rows: &rows,
verbs: &verbs,
move_mode_supported: false,
};
terminal
.draw(|frame| render(&s, &view, frame.area(), frame))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let dump = dump_buffer(&buf);
assert!(dump.contains("Demo"), "title missing:\n{dump}");
assert!(dump.contains("a add"), "help row missing:\n{dump}");
assert!(
dump.contains("desc-A"),
"description missing (long label pushed it off?):\n{dump}",
);
assert!(
dump.contains("▶ XXXXXXXXXXXXX"),
"cursor + long-label run missing:\n{dump}"
);
}
#[test]
fn render_wide_grapheme_label_preserves_cell_layout() {
let backend = TestBackend::new(40, 12);
let mut terminal = Terminal::new(backend).expect("backend");
let s = ListScreenState::new();
let rows = [ListRowData {
label: Cow::Borrowed("中文"),
description: Cow::Borrowed("cjk-desc"),
}];
let verbs = [VerbHint {
letter: 'a',
label: "add",
}];
let view = ListScreenView {
title: "Demo",
rows: &rows,
verbs: &verbs,
move_mode_supported: false,
};
terminal
.draw(|frame| render(&s, &view, frame.area(), frame))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let dump = dump_buffer(&buf);
assert!(dump.contains("中"), "wide grapheme '中' missing:\n{dump}");
assert!(dump.contains("文"), "wide grapheme '文' missing:\n{dump}");
assert!(
dump.contains("cjk-desc"),
"description should still render below wide label:\n{dump}",
);
let mut found_lead = 0_usize;
for y in 0..buf.area.height {
for x in 0..buf.area.width.saturating_sub(1) {
let lead = buf[(x, y)].symbol();
if lead == "中" || lead == "文" {
let trail = buf[(x + 1, y)].symbol();
assert!(
trail.is_empty() || trail == " ",
"trailing cell after {lead:?} should be empty or space, got {trail:?}",
);
found_lead += 1;
}
}
}
assert_eq!(
found_lead, 2,
"expected exactly one '中' and one '文' in the buffer:\n{dump}",
);
}
#[test]
fn render_multi_verb_help_row_uses_middle_dot_separator() {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("backend");
let s = ListScreenState::new();
let rows = [ListRowData {
label: Cow::Borrowed("Only"),
description: Cow::Borrowed("desc"),
}];
let verbs = [
VerbHint {
letter: 'a',
label: "add",
},
VerbHint {
letter: 'd',
label: "delete",
},
];
let view = ListScreenView {
title: "Demo",
rows: &rows,
verbs: &verbs,
move_mode_supported: false,
};
terminal
.draw(|frame| render(&s, &view, frame.area(), frame))
.expect("draw");
let buf = terminal.backend().buffer().clone();
let dump = dump_buffer(&buf);
assert!(
dump.contains("a add · d delete"),
"help row missing `·` separator between verbs:\n{dump}",
);
}
}