use std::fmt;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span, Text},
widgets::Widget,
};
use crate::Theme;
use crate::color::{DISABLED_DIM, FIELD_FOCUS_LIGHTEN, FIELD_HOVER_LIGHTEN, dim, lighten};
use crate::linear_nav::{self, NavOutcome, ScrollStep};
use crate::list_core::{
self, ListItem, ListItemState, RowViewport, SCROLL_STEP, WheelPark, fit_to_height,
};
use crate::runtime::{
Component, Event, EventCtx, EventResult, KeyCode, KeyEvent, MouseButton, MouseKind, RenderCtx,
ScrollDirection,
};
use crate::selection_indicator;
use crate::text_width::display_width;
const ROW_FOCUS_LIGHTEN: u16 = 15;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListStyle {
pub foreground: Color,
pub background: Color,
pub focused_background: Color,
pub hovered_background: Color,
pub selected_foreground: Color,
pub focused_foreground: Color,
pub focused_row_background: Color,
pub selected_focused_foreground: Color,
pub selected_focused_background: Color,
pub selected_marker: Color,
pub unselected_marker: Color,
pub disabled_foreground: Color,
pub disabled_background: Color,
}
impl ListStyle {
#[must_use]
pub const fn fallback() -> Self {
Self {
foreground: Color::Reset,
background: Color::Reset,
focused_background: Color::Reset,
hovered_background: Color::DarkGray,
selected_foreground: Color::Black,
focused_foreground: Color::Reset,
focused_row_background: Color::Cyan,
selected_focused_foreground: Color::Black,
selected_focused_background: Color::Cyan,
selected_marker: Color::LightGreen,
unselected_marker: Color::DarkGray,
disabled_foreground: Color::DarkGray,
disabled_background: Color::Reset,
}
}
#[must_use]
pub const fn from_theme(theme: &Theme) -> Self {
let focused_background = lighten(theme.field, FIELD_FOCUS_LIGHTEN);
let hovered_background = lighten(theme.field, FIELD_HOVER_LIGHTEN);
let focused_row_background = lighten(focused_background, ROW_FOCUS_LIGHTEN);
Self {
foreground: theme.muted_foreground,
background: theme.field,
focused_background,
hovered_background,
selected_foreground: theme.foreground,
focused_foreground: theme.muted_foreground,
focused_row_background,
selected_focused_foreground: theme.foreground,
selected_focused_background: focused_row_background,
selected_marker: theme.primary,
unselected_marker: theme.muted_foreground,
disabled_foreground: theme.muted_foreground,
disabled_background: dim(theme.field, theme.surface, DISABLED_DIM),
}
}
const fn resolve_surface(&self, focused: bool, hovered: bool, disabled: bool) -> Color {
if disabled {
self.disabled_background
} else if hovered {
self.hovered_background
} else if focused {
self.focused_background
} else {
self.background
}
}
fn resolve_row(
&self,
focused: bool,
selected: bool,
disabled: bool,
background: Color,
) -> Style {
if disabled {
return Style::default()
.fg(self.disabled_foreground)
.bg(self.disabled_background);
}
let (foreground, background) = match (focused, selected) {
(true, true) => (
self.selected_focused_foreground,
self.selected_focused_background,
),
(true, false) => (self.focused_foreground, self.focused_row_background),
(false, true) => (self.selected_foreground, background),
(false, false) => (self.foreground, background),
};
Style::default().fg(foreground).bg(background)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListWidget<'a> {
items: &'a [Text<'static>],
scroll_offset: usize,
focused_row: Option<usize>,
selected_rows: &'a [usize],
disabled_rows: &'a [bool],
focused: bool,
hovered: bool,
disabled: bool,
style: ListStyle,
focus_symbol: &'a str,
}
impl<'a> ListWidget<'a> {
#[must_use]
pub const fn new(items: &'a [Text<'static>]) -> Self {
Self {
items,
scroll_offset: 0,
focused_row: None,
selected_rows: &[],
disabled_rows: &[],
focused: false,
hovered: false,
disabled: false,
style: ListStyle::fallback(),
focus_symbol: "",
}
}
#[must_use]
pub const fn themed(mut self, theme: &Theme) -> Self {
self.style = ListStyle::from_theme(theme);
self
}
#[must_use]
pub const fn style(mut self, style: ListStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub const fn scroll_offset(mut self, scroll_offset: usize) -> Self {
self.scroll_offset = scroll_offset;
self
}
#[must_use]
pub const fn focused_row(mut self, focused_row: Option<usize>) -> Self {
self.focused_row = focused_row;
self
}
#[must_use]
pub const fn selected_rows(mut self, selected_rows: &'a [usize]) -> Self {
self.selected_rows = selected_rows;
self
}
#[must_use]
pub const fn disabled_rows(mut self, disabled_rows: &'a [bool]) -> Self {
self.disabled_rows = disabled_rows;
self
}
#[must_use]
pub const fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
#[must_use]
pub const fn hovered(mut self, hovered: bool) -> Self {
self.hovered = hovered;
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub const fn focus_symbol(mut self, focus_symbol: &'a str) -> Self {
self.focus_symbol = focus_symbol;
self
}
}
impl Widget for ListWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let area = area.intersection(buf.area);
if area.width == 0 || area.height == 0 {
return;
}
buf.set_style(
area,
Style::default()
.fg(self.style.foreground)
.bg(self
.style
.resolve_surface(self.focused, self.hovered, self.disabled)),
);
let cursor_shown = self.focused && !self.disabled && self.focused_row.is_some();
let symbol_width = if cursor_shown {
u16::try_from(display_width(self.focus_symbol)).unwrap_or(u16::MAX)
} else {
0
};
let text_x = area.x.saturating_add(symbol_width).min(area.right());
let text_width = area.width.saturating_sub(symbol_width);
let mut y = area.y;
for (index, item) in self.items.iter().enumerate().skip(self.scroll_offset) {
if y >= area.bottom() {
break;
}
let height = u16::try_from(item.lines.len().max(1))
.unwrap_or(u16::MAX)
.min(area.bottom() - y);
let row_area = Rect::new(area.x, y, area.width, height);
buf.set_style(row_area, self.row_style(index));
if cursor_shown && self.focused_row == Some(index) {
Span::raw(self.focus_symbol).render(row_area, buf);
}
item.render(Rect::new(text_x, y, text_width, height), buf);
y = y.saturating_add(height);
}
}
}
impl ListWidget<'_> {
fn row_style(&self, index: usize) -> Style {
self.style.resolve_row(
self.focused && self.focused_row == Some(index),
self.selected_rows.contains(&index),
self.disabled || self.disabled_rows.get(index).copied().unwrap_or(false),
self.style
.resolve_surface(self.focused, self.hovered, self.disabled),
)
}
}
type ReadFn<S, T> = Box<dyn Fn(&S) -> Option<T>>;
type MultiSelectionFn<S, T> = Box<dyn Fn(&S, &T) -> bool>;
type OnChangeFn<T, M> = Box<dyn Fn(T) -> M>;
type OnFocusChangeFn<T, M> = Box<dyn Fn(T, usize) -> M>;
type ScrollFn<S> = Box<dyn Fn(&S) -> usize>;
type RenderItemFn<S, T> = Box<dyn for<'a> Fn(&S, ListItemState<'a, T>) -> Text<'static>>;
type StyleFn = Box<dyn Fn(&Theme) -> ListStyle>;
pub struct List<T, S, M> {
items: Vec<ListItem<T>>,
focused_item: Option<ReadFn<S, T>>,
on_focus_change: Option<OnFocusChangeFn<T, M>>,
selected: Option<ReadFn<S, T>>,
on_select: Option<OnChangeFn<T, M>>,
selected_many: Option<MultiSelectionFn<S, T>>,
on_toggle: Option<OnChangeFn<T, M>>,
scroll: Option<ScrollFn<S>>,
on_scroll_change: Option<Box<dyn Fn(usize) -> M>>,
disabled: bool,
render_item: Option<RenderItemFn<S, T>>,
style: Option<StyleFn>,
focus_symbol: String,
viewport: RowViewport,
cursor_visible: bool,
}
impl<T: fmt::Debug, S, M> fmt::Debug for List<T, S, M> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("List")
.field("items", &self.items)
.field("item_focus", &self.focused_item.is_some())
.field("selection", &self.selected.is_some())
.field("multi_selection", &self.selected_many.is_some())
.field("style", &self.style.is_some())
.field("disabled", &self.disabled)
.finish_non_exhaustive()
}
}
impl<T, S, M> List<T, S, M> {
#[must_use]
pub fn new(items: impl IntoIterator<Item = impl Into<ListItem<T>>>) -> Self {
Self {
items: items.into_iter().map(Into::into).collect(),
focused_item: None,
on_focus_change: None,
selected: None,
on_select: None,
selected_many: None,
on_toggle: None,
scroll: None,
on_scroll_change: None,
disabled: false,
render_item: None,
style: None,
focus_symbol: String::new(),
viewport: RowViewport::new(1),
cursor_visible: false,
}
}
#[must_use]
pub fn item_focus(
mut self,
read: impl Fn(&S) -> Option<T> + 'static,
on_change: impl Fn(T, usize) -> M + 'static,
) -> Self {
self.focused_item = Some(Box::new(read));
self.on_focus_change = Some(Box::new(on_change));
self
}
#[must_use]
pub fn selection(
mut self,
read: impl Fn(&S) -> Option<T> + 'static,
on_select: impl Fn(T) -> M + 'static,
) -> Self {
self.selected = Some(Box::new(read));
self.on_select = Some(Box::new(on_select));
self
}
#[must_use]
pub fn multi_selection(
mut self,
read: impl Fn(&S, &T) -> bool + 'static,
on_toggle: impl Fn(T) -> M + 'static,
) -> Self {
self.selected_many = Some(Box::new(read));
self.on_toggle = Some(Box::new(on_toggle));
self
}
#[must_use]
pub fn scroll(
mut self,
read: impl Fn(&S) -> usize + 'static,
on_change: impl Fn(usize) -> M + 'static,
) -> Self {
self.scroll = Some(Box::new(read));
self.on_scroll_change = Some(Box::new(on_change));
self
}
#[must_use]
pub fn render_item<R: Into<Text<'static>>>(
mut self,
f: impl for<'a> Fn(&S, ListItemState<'a, T>) -> R + 'static,
) -> Self {
self.render_item = Some(Box::new(move |state, row| f(state, row).into()));
self
}
#[must_use]
pub const fn row_height(mut self, rows: u16) -> Self {
self.viewport = RowViewport::new(rows);
self
}
#[must_use]
pub fn focus_symbol(mut self, symbol: impl Into<String>) -> Self {
self.focus_symbol = symbol.into();
self
}
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> ListStyle + 'static) -> Self {
self.style = Some(Box::new(style));
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl<T: Clone + PartialEq, S, M> List<T, S, M> {
fn disabled_at(&self, index: usize) -> bool {
list_core::disabled_at(&self.items, index)
}
fn focused_index(&self, state: &S) -> Option<usize> {
list_core::index_of(&self.items, &(self.focused_item.as_ref()?)(state)?)
}
fn selected_index(&self, state: &S) -> Option<usize> {
list_core::index_of(&self.items, &(self.selected.as_ref()?)(state)?)
}
fn selected_rows(&self, state: &S) -> Vec<usize> {
if let Some(selected_many) = &self.selected_many {
return self
.items
.iter()
.enumerate()
.filter_map(|(index, item)| selected_many(state, item.value()).then_some(index))
.collect();
}
self.selected_index(state).into_iter().collect()
}
fn move_focus(&self, index: usize, state: &S, area: Rect) -> EventResult<M> {
match &self.on_focus_change {
Some(on_change) => EventResult::Emit(on_change(
self.items[index].value().clone(),
self.offset_for_focus(state, area, index),
)),
None => EventResult::Ignored,
}
}
fn offset_for_focus(&self, state: &S, area: Rect, index: usize) -> usize {
let current = self
.scroll
.as_ref()
.map_or(self.viewport.painted_offset(), |read| read(state));
self.viewport
.cursor_visible_offset(area, self.items.len(), current, Some(index))
}
fn select(&self, index: usize) -> EventResult<M> {
if let Some(on_select) = &self.on_select {
return EventResult::Emit(on_select(self.items[index].value().clone()));
}
if let Some(on_toggle) = &self.on_toggle {
return EventResult::Emit(on_toggle(self.items[index].value().clone()));
}
EventResult::Ignored
}
fn scroll_view(
&mut self,
direction: ScrollDirection,
state: &S,
area: Rect,
ctx: &mut EventCtx<'_>,
) -> EventResult<M> {
let step = match direction {
ScrollDirection::Up => ScrollStep::Up,
ScrollDirection::Down => ScrollStep::Down,
ScrollDirection::Left | ScrollDirection::Right => return EventResult::Ignored,
};
let painted = self.viewport.painted_offset();
let next = linear_nav::wheel_offset(
self.items.len(),
self.viewport.visible_items(area),
painted,
step,
SCROLL_STEP,
);
ctx.transient::<WheelPark>()
.park(next, self.focused_index(state));
self.viewport.record_painted_offset(next);
let Some(on_change) = &self.on_scroll_change else {
return EventResult::Consumed;
};
let current = self.scroll.as_ref().map_or(painted, |read| read(state));
if current == next {
EventResult::Consumed
} else {
EventResult::Emit(on_change(next))
}
}
fn handle_key(&self, key: KeyEvent, state: &S, area: Rect) -> EventResult<M> {
if self.focused_item.is_none()
|| linear_nav::first_enabled(self.items.len(), |i| self.disabled_at(i)).is_none()
{
return EventResult::Ignored;
}
let cursor = self.focused_index(state);
if let Some(outcome) = linear_nav::nav_key_target(
key,
self.items.len(),
cursor,
self.viewport.visible_items(area).max(1),
|i| self.disabled_at(i),
) {
return match outcome {
NavOutcome::Move(target) => self.move_focus(target, state, area),
NavOutcome::Stay => EventResult::Consumed,
};
}
if linear_nav::has_reserved_modifier(key) {
return EventResult::Ignored;
}
if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
return match cursor {
Some(index) if !self.disabled_at(index) => self.select(index),
_ => EventResult::Ignored,
};
}
EventResult::Ignored
}
}
impl<T: Clone + PartialEq, S, M> Component<S, M> for List<T, S, M> {
fn prepare(&mut self, _state: &S) {
list_core::assert_unique_values(self.items.iter().map(ListItem::value), "List");
assert!(
!(self.selected.is_some() && self.selected_many.is_some()),
"List::selection(...) and List::multi_selection(...) cannot be used together; choose one selection mode"
);
}
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, S, M>) {
let area = ctx.area();
let state = ctx.state();
let focused_row = self.focused_index(state);
let selected_rows = self.selected_rows(state);
let disabled_rows: Vec<bool> = self.items.iter().map(ListItem::is_disabled).collect();
let style = self.style.as_ref().map_or_else(
|| ListStyle::from_theme(ctx.theme),
|style| style(ctx.theme),
);
self.cursor_visible = ctx.focused || ctx.hovered;
let selection_mode = if self.selected_many.is_some() {
Some(true)
} else if self.selected.is_some() {
Some(false)
} else {
None
};
let rows_per_item = self.viewport.rows_per_item();
let items: Vec<Text<'static>> = self
.items
.iter()
.enumerate()
.map(|(index, item)| {
let row = ListItemState {
index,
value: item.value(),
label: item.label(),
focused: self.cursor_visible && focused_row == Some(index),
selected: selected_rows.contains(&index),
disabled: self.disabled || item.is_disabled(),
};
let text = match &self.render_item {
Some(render_item) => render_item(state, row),
None => default_item_line(&row, selection_mode, &style),
};
fit_to_height(text, rows_per_item)
})
.collect();
let mut park = ctx
.transient_mut::<WheelPark>()
.map_or_else(WheelPark::default, |park| {
park.settle(focused_row);
*park
});
let requested_offset = self
.scroll
.as_ref()
.map_or_else(|| park.offset(), |scroll| scroll(state));
let offset = self.viewport.cursor_visible_offset(
area,
self.items.len(),
requested_offset,
park.cursor_to_show(focused_row),
);
park.record(offset);
if let Some(stored) = ctx.transient_mut::<WheelPark>() {
*stored = park;
}
self.viewport.record_painted_offset(offset);
ctx.render_widget(
ListWidget::new(&items)
.scroll_offset(offset)
.focused_row(focused_row)
.selected_rows(&selected_rows)
.disabled_rows(&disabled_rows)
.style(style)
.focused(self.cursor_visible)
.hovered(ctx.hovered)
.disabled(self.disabled)
.focus_symbol(&self.focus_symbol),
area,
);
}
fn handle_event(&mut self, event: &Event, state: &S, ctx: &mut EventCtx<'_>) -> EventResult<M> {
if self.disabled || self.items.is_empty() {
return EventResult::Ignored;
}
let area = ctx.area();
match event {
Event::Mouse(mouse) => match mouse.kind {
MouseKind::Down(MouseButton::Left) => {
match self
.viewport
.row_at(area, self.items.len(), mouse.column, mouse.row)
{
Some(index) if self.disabled_at(index) => EventResult::Consumed,
_ => EventResult::Ignored,
}
}
MouseKind::Moved => {
match self
.viewport
.row_at(area, self.items.len(), mouse.column, mouse.row)
{
Some(index) if self.disabled_at(index) => EventResult::Ignored,
Some(index) if Some(index) != self.focused_index(state) => {
self.move_focus(index, state, area)
}
Some(_) => EventResult::Consumed,
None => EventResult::Ignored,
}
}
MouseKind::Click(MouseButton::Left) => {
match self
.viewport
.row_at(area, self.items.len(), mouse.column, mouse.row)
{
Some(index) if self.disabled_at(index) => EventResult::Ignored,
Some(index) if self.selected.is_some() || self.selected_many.is_some() => {
self.select(index)
}
Some(index) => self.move_focus(index, state, area),
None => EventResult::Ignored,
}
}
MouseKind::Scroll(direction) => self.scroll_view(direction, state, area, ctx),
_ => EventResult::Ignored,
},
Event::Key(key) => self.handle_key(*key, state, area),
_ => EventResult::Ignored,
}
}
fn is_focusable(&self, _state: &S) -> bool {
self.focused_item.is_some()
&& !self.disabled
&& linear_nav::first_enabled(self.items.len(), |i| self.disabled_at(i)).is_some()
}
}
fn default_item_line<T>(
row: &ListItemState<'_, T>,
selection_mode: Option<bool>,
style: &ListStyle,
) -> Text<'static> {
let Some(multiple) = selection_mode else {
return Text::from(row.label.to_string());
};
let marker = selection_indicator::marker(row.selected, multiple);
let marker_color = selection_indicator::color(
row.disabled,
row.selected,
selection_indicator::MarkerColors {
disabled: style.disabled_foreground,
selected: style.selected_marker,
unselected: style.unselected_marker,
},
);
Text::from(Line::from(vec![
Span::styled(format!(" {marker}"), Style::default().fg(marker_color)),
Span::raw(" "),
Span::raw(row.label.to_string()),
]))
}
#[cfg(test)]
mod tests {
use std::panic::{AssertUnwindSafe, catch_unwind};
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
use super::*;
use crate::runtime::{ChildId, MouseEvent, Ratcn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Task {
A,
B,
C,
D,
E,
F,
}
#[derive(Debug, PartialEq)]
enum Msg {
Focused(Task, usize),
Selected(Task),
Toggled(Task),
Scrolled(usize),
ComponentFocus(crate::runtime::FocusState),
Hover(crate::runtime::HoverState),
Pressed,
}
#[derive(Default)]
struct State {
focused: Option<Task>,
selected: Option<Task>,
toggled: Vec<Task>,
scroll: usize,
component_focus: crate::runtime::FocusState,
hover: crate::runtime::HoverState,
}
struct TestBackendDriver {
terminal: Terminal<TestBackend>,
ratcn: Ratcn<State, Msg>,
}
impl TestBackendDriver {
fn new(width: u16, height: u16) -> Self {
Self {
terminal: Terminal::new(TestBackend::new(width, height)).expect("terminal"),
ratcn: Ratcn::new(),
}
}
fn render(&mut self, state: &State, items: impl IntoIterator<Item = ListItem<Task>>) {
let items: Vec<_> = items.into_iter().collect();
let theme = Theme::default_dark();
self.terminal
.draw(|frame| {
let area = frame.area();
self.ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("list"),
List::new(items.clone())
.item_focus(|state: &State| state.focused, Msg::Focused)
.selection(|state: &State| state.selected, Msg::Selected)
.scroll(|state: &State| state.scroll, Msg::Scrolled)
.focus_symbol(">"),
area,
);
});
})
.expect("draw");
}
fn event(&mut self, event: Event, state: &State) -> EventResult<Msg> {
self.ratcn.handle_event(event, state)
}
fn row(&self, row: u16) -> String {
let buffer = self.terminal.backend().buffer();
let width = usize::from(buffer.area.width);
buffer.content[usize::from(row) * width..]
.iter()
.take(width)
.map(ratatui::buffer::Cell::symbol)
.collect()
}
fn cell(&self, column: u16, row: u16) -> &ratatui::buffer::Cell {
self.terminal
.backend()
.buffer()
.cell((column, row))
.expect("cell")
}
}
fn item(value: Task, label: &str) -> ListItem<Task> {
ListItem::new(value, label)
}
struct TallListDriver {
terminal: Terminal<TestBackend>,
ratcn: Ratcn<State, Msg>,
}
impl TallListDriver {
const ROW_HEIGHT: u16 = 2;
fn new(width: u16, height: u16) -> Self {
Self {
terminal: Terminal::new(TestBackend::new(width, height)).expect("terminal"),
ratcn: Ratcn::new(),
}
}
fn render(&mut self, state: &State, items: &[ListItem<Task>]) {
let theme = Theme::default_dark();
self.terminal
.draw(|frame| {
let area = frame.area();
self.ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("list"),
List::new(items.to_vec())
.item_focus(|state: &State| state.focused, Msg::Focused)
.selection(|state: &State| state.selected, Msg::Selected)
.row_height(Self::ROW_HEIGHT)
.render_item(|_state: &State, row| {
Text::from(vec![
Line::from(row.label.to_string()),
Line::from(" subtitle"),
])
}),
area,
);
});
})
.expect("draw");
}
fn event(&mut self, event: Event, state: &State) -> EventResult<Msg> {
self.ratcn.handle_event(event, state)
}
}
#[test]
fn clicking_a_second_line_selects_the_item_that_owns_it() {
let state = State::default();
let items = vec![item(Task::A, "Alpha"), item(Task::B, "Bravo")];
let mut driver = TallListDriver::new(20, 6);
driver.render(&state, &items);
assert_eq!(
driver.event(mouse(MouseKind::Click(MouseButton::Left), 2, 1), &state),
EventResult::Emit(Msg::Selected(Task::A)),
"the subtitle line belongs to the first item"
);
assert_eq!(
driver.event(mouse(MouseKind::Click(MouseButton::Left), 2, 2), &state),
EventResult::Emit(Msg::Selected(Task::B)),
"the second item starts two rows down"
);
}
#[test]
fn trailing_partial_row_does_not_target_an_invisible_item() {
let state = State::default();
let items = vec![
item(Task::A, "Alpha"),
item(Task::B, "Bravo"),
item(Task::C, "Charlie"),
];
let mut driver = TallListDriver::new(20, 5);
driver.render(&state, &items);
assert_eq!(
driver.event(mouse(MouseKind::Click(MouseButton::Left), 2, 4), &state),
EventResult::Ignored,
"a trailing row that cannot fit a complete item is not interactive"
);
}
#[test]
fn paging_counts_items_that_fit_not_screen_rows() {
let state = State {
focused: Some(Task::A),
..State::default()
};
let items = vec![
item(Task::A, "Alpha"),
item(Task::B, "Bravo"),
item(Task::C, "Charlie"),
item(Task::D, "Delta"),
item(Task::E, "Echo"),
];
let mut driver = TallListDriver::new(20, 6);
driver.render(&state, &items);
assert_eq!(
driver.event(key(KeyCode::PageDown), &state),
EventResult::Emit(Msg::Focused(Task::D, 1)),
"three items fit, so a page is three items"
);
}
#[test]
fn rows_are_padded_and_truncated_to_the_declared_height() {
let short = fit_to_height(Text::from("one"), 3);
assert_eq!(short.lines.len(), 3);
assert_eq!(short.lines[0].to_string(), "one");
assert_eq!(short.lines[2].to_string(), "");
let long = fit_to_height(
Text::from(vec![Line::from("a"), Line::from("b"), Line::from("c")]),
2,
);
assert_eq!(long.lines.len(), 2);
assert_eq!(long.lines[1].to_string(), "b");
}
#[test]
fn zero_row_height_is_treated_as_one() {
let list: List<Task, State, Msg> = List::new([item(Task::A, "Alpha")]).row_height(0);
assert_eq!(list.viewport.rows_per_item(), 1);
}
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code))
}
fn ctrl_key(ch: char) -> Event {
Event::Key(KeyEvent {
code: KeyCode::Char(ch),
modifiers: crate::runtime::Modifiers {
ctrl: true,
..crate::runtime::Modifiers::NONE
},
})
}
fn mouse(kind: MouseKind, column: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind,
column,
row,
modifiers: crate::runtime::Modifiers::NONE,
})
}
#[test]
fn a_selected_row_keeps_the_rest_focus_or_hover_backdrop() {
let items = [Text::from("selected"), Text::from("other")];
let mut style = ListStyle::fallback();
style.background = Color::Blue;
style.focused_background = Color::Green;
style.hovered_background = Color::Magenta;
style.selected_foreground = Color::Yellow;
let area = Rect::new(0, 0, 10, 2);
for (focused, hovered, backdrop) in [
(false, false, Color::Blue),
(true, false, Color::Green),
(false, true, Color::Magenta),
(true, true, Color::Magenta),
] {
let mut buffer = Buffer::empty(area);
Widget::render(
ListWidget::new(&items)
.selected_rows(&[0])
.focused_row(Some(1))
.focused(focused)
.hovered(hovered)
.style(style),
area,
&mut buffer,
);
let selected = buffer.cell((0, 0)).expect("selected cell");
assert_eq!(
selected.bg, backdrop,
"selected row should match the list backdrop (focused: {focused}, hovered: {hovered})"
);
assert_eq!(selected.fg, Color::Yellow, "selection shows in the label");
}
}
#[test]
fn list_widget_preserves_explicit_text_colors_and_modifiers() {
let items = [
Text::from(Span::styled(
"selected",
Style::default()
.fg(Color::Magenta)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
)),
Text::from(Span::styled(
"disabled",
Style::default()
.fg(Color::Cyan)
.bg(Color::White)
.add_modifier(Modifier::ITALIC),
)),
];
let mut style = ListStyle::fallback();
style.selected_foreground = Color::Yellow;
style.background = Color::Blue;
style.disabled_foreground = Color::DarkGray;
style.disabled_background = Color::Red;
let area = Rect::new(0, 0, 10, 2);
let mut buffer = Buffer::empty(area);
Widget::render(
ListWidget::new(&items)
.selected_rows(&[0])
.disabled_rows(&[false, true])
.style(style),
area,
&mut buffer,
);
let selected = buffer.cell((0, 0)).expect("selected custom span");
assert_eq!(selected.fg, Color::Magenta);
assert_eq!(selected.bg, Color::Green);
assert!(selected.modifier.contains(Modifier::BOLD));
let disabled = buffer.cell((0, 1)).expect("disabled custom span");
assert_eq!(disabled.fg, Color::Cyan);
assert_eq!(disabled.bg, Color::White);
assert!(disabled.modifier.contains(Modifier::ITALIC));
}
#[test]
fn default_markers_preserve_selected_and_unselected_colors() {
let state = State {
selected: Some(Task::A),
..State::default()
};
let mut style = ListStyle::fallback();
style.selected_marker = Color::Yellow;
style.unselected_marker = Color::Magenta;
style.selected_foreground = Color::Red;
style.foreground = Color::Cyan;
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
"list",
List::new([item(Task::A, "Alpha"), item(Task::B, "Bravo")])
.selection(|state: &State| state.selected, Msg::Selected)
.style(move |_| style),
area,
);
});
})
.expect("draw");
let buffer = terminal.backend().buffer();
assert_eq!(
buffer.cell((1, 0)).expect("selected marker").fg,
Color::Yellow
);
assert_eq!(
buffer.cell((1, 1)).expect("unselected marker").fg,
Color::Magenta
);
}
#[test]
fn interactive_custom_row_colors_preserve_explicit_span_colors() {
let state = State {
selected: Some(Task::A),
..State::default()
};
let mut style = ListStyle::fallback();
style.selected_foreground = Color::Yellow;
style.background = Color::Blue;
style.disabled_foreground = Color::DarkGray;
style.disabled_background = Color::Red;
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(10, 2)).expect("terminal");
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
"list",
List::new([
item(Task::A, "selected"),
item(Task::B, "disabled").disabled(true),
])
.selection(|state: &State| state.selected, Msg::Selected)
.render_item(|_: &State, row| {
let modifier = if row.selected {
Modifier::BOLD
} else {
Modifier::ITALIC
};
Text::from(Span::styled(
row.label.to_string(),
Style::default()
.fg(Color::Magenta)
.bg(Color::Green)
.add_modifier(modifier),
))
})
.style(move |_| style),
area,
);
});
})
.expect("draw");
let buffer = terminal.backend().buffer();
let selected = buffer.cell((0, 0)).expect("selected custom span");
assert_eq!(selected.fg, Color::Magenta);
assert_eq!(selected.bg, Color::Green);
assert!(selected.modifier.contains(Modifier::BOLD));
let disabled = buffer.cell((0, 1)).expect("disabled custom span");
assert_eq!(disabled.fg, Color::Magenta);
assert_eq!(disabled.bg, Color::Green);
assert!(disabled.modifier.contains(Modifier::ITALIC));
}
#[test]
fn hidden_cursor_paints_the_caller_owned_scroll_offset() {
let items = [
Text::from("Alpha"),
Text::from("Bravo"),
Text::from("Charlie"),
Text::from("Delta"),
];
let area = Rect::new(0, 0, 10, 2);
for (focused, disabled) in [(false, false), (true, true)] {
let mut buffer = Buffer::empty(area);
ListWidget::new(&items)
.scroll_offset(2)
.focused_row(Some(3))
.focused(focused)
.disabled(disabled)
.focus_symbol("> ")
.render(area, &mut buffer);
assert_eq!(
buffer.cell((0, 0)).expect("first visible cell").symbol(),
"C"
);
}
}
#[test]
fn string_sugar_keys_items_by_their_labels() {
let list = List::<String, State, Msg>::new(["Inbox", "Archive"]);
assert_eq!(list.items[0].value(), "Inbox");
assert_eq!(list.items[0].label(), "Inbox");
}
#[test]
fn reorder_preserves_focused_and_selected_values() {
let mut driver = TestBackendDriver::new(20, 3);
let state = State {
focused: Some(Task::B),
selected: Some(Task::B),
..State::default()
};
driver.render(
&state,
[
item(Task::A, "Alpha"),
item(Task::B, "Bravo"),
item(Task::C, "Charlie"),
],
);
driver.render(
&state,
[
item(Task::C, "Charlie"),
item(Task::A, "Alpha"),
item(Task::B, "Bravo"),
],
);
assert!(driver.row(2).contains("> ● Bravo"));
assert_eq!(
driver.event(key(KeyCode::Enter), &state),
EventResult::Emit(Msg::Selected(Task::B))
);
}
#[test]
fn filtering_a_focused_value_parks_without_highlighting_or_emitting() {
let mut driver = TestBackendDriver::new(20, 2);
let state = State {
focused: Some(Task::B),
selected: Some(Task::B),
..State::default()
};
driver.render(&state, [item(Task::A, "Alpha"), item(Task::B, "Bravo")]);
driver.render(
&state,
[
item(Task::A, "Alpha").disabled(true),
item(Task::C, "Charlie"),
],
);
assert!(!driver.row(0).contains('>'));
assert!(!driver.row(0).contains('●'));
assert_eq!(
driver.event(key(KeyCode::Down), &state),
EventResult::Emit(Msg::Focused(Task::C, 0))
);
assert_eq!(
driver.event(key(KeyCode::End), &state),
EventResult::Emit(Msg::Focused(Task::C, 0))
);
}
#[test]
fn a_disabled_list_paints_one_backdrop_past_its_last_item() {
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
let mut ratcn = Ratcn::<State, Msg>::new();
let state = State::default();
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("list"),
List::new([item(Task::A, "Alpha"), item(Task::B, "Bravo")])
.item_focus(|state: &State| state.focused, Msg::Focused)
.disabled(true),
area,
);
});
})
.expect("draw");
let disabled = ListStyle::from_theme(&theme).disabled_background;
let buffer = terminal.backend().buffer();
for row in 0..4 {
assert_eq!(
buffer.cell((3, row)).expect("cell").bg,
disabled,
"row {row} must use the disabled backdrop, item row or not"
);
}
}
#[test]
fn disabled_items_are_dimmed_and_ignore_primary_clicks() {
let mut driver = TestBackendDriver::new(20, 2);
let state = State::default();
driver.render(
&state,
[
item(Task::A, "Alpha"),
item(Task::B, "Bravo").disabled(true),
],
);
assert_eq!(
driver.cell(3, 1).bg,
ListStyle::from_theme(&Theme::default_dark()).disabled_background
);
assert_eq!(
driver.event(mouse(MouseKind::Down(MouseButton::Left), 2, 1), &state),
EventResult::Consumed
);
assert_eq!(
driver.event(mouse(MouseKind::Up(MouseButton::Left), 2, 1), &state),
EventResult::Ignored
);
}
#[test]
fn disabled_rows_consume_pointer_down_and_cursorless_lists_do_not_take_focus() {
#[derive(Default)]
struct RoutedState {
focus: crate::runtime::FocusState,
selected: Option<Task>,
}
#[derive(Debug, PartialEq)]
enum RoutedMsg {
Focus(crate::runtime::FocusState),
Selected(Task),
}
let theme = Theme::default_dark();
let state = RoutedState {
focus: crate::runtime::FocusState::intent([ChildId::Static("other")]),
..RoutedState::default()
};
let mut ratcn = Ratcn::new().focus(|state: &RoutedState| &state.focus, RoutedMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(20, 3)).expect("terminal");
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("other"),
crate::Button::<RoutedMsg>::new("Other"),
Rect::new(0, 0, 20, 1),
);
ctx.render_component(
ChildId::Static("list"),
List::new([
item(Task::A, "Alpha"),
item(Task::B, "Bravo").disabled(true),
])
.selection(|state: &RoutedState| state.selected, RoutedMsg::Selected),
Rect::new(0, 1, 20, 2),
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 2), &state),
EventResult::Consumed
);
assert_eq!(
state.focus,
crate::runtime::FocusState::intent([ChildId::Static("other")])
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 2), &state),
EventResult::Ignored
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 1, 1), &state),
EventResult::Ignored
);
assert_eq!(
state.focus,
crate::runtime::FocusState::intent([ChildId::Static("other")])
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 1, 1), &state),
EventResult::Emit(RoutedMsg::Selected(Task::A))
);
}
fn six_items() -> [ListItem<Task>; 6] {
[
item(Task::A, "Alpha"),
item(Task::B, "Bravo"),
item(Task::C, "Charlie"),
item(Task::D, "Delta"),
item(Task::E, "Echo"),
item(Task::F, "Foxtrot"),
]
}
#[test]
fn the_wheel_scrolls_the_view_and_leaves_the_cursor_behind() {
let mut driver = TestBackendDriver::new(20, 3);
let mut state = State {
focused: Some(Task::A),
..State::default()
};
driver.render(&state, six_items());
assert!(driver.row(0).contains("Alpha"), "{}", driver.row(0));
assert_eq!(
driver.event(
mouse(MouseKind::Scroll(ScrollDirection::Down), 2, 0),
&state
),
EventResult::Emit(Msg::Scrolled(3))
);
state.scroll = 3;
driver.render(&state, six_items());
assert!(
driver.row(0).contains("Delta"),
"the wheeled offset survives the redraw: {}",
driver.row(0)
);
assert_eq!(state.focused, Some(Task::A), "the wheel never moves it");
assert_eq!(
driver.event(key(KeyCode::Down), &state),
EventResult::Emit(Msg::Focused(Task::B, 1))
);
state.focused = Some(Task::B);
state.scroll = 1;
driver.render(&state, six_items());
assert!(driver.row(0).contains("Bravo"), "{}", driver.row(0));
}
#[test]
fn wheeling_against_a_stale_app_offset_synchronizes_it() {
let mut driver = TestBackendDriver::new(20, 3);
let state = State {
focused: Some(Task::F),
..State::default()
};
driver.render(&state, six_items());
assert!(driver.row(0).contains("Delta"), "{}", driver.row(0));
assert_eq!(
driver.event(mouse(MouseKind::Scroll(ScrollDirection::Up), 2, 0), &state),
EventResult::Consumed
);
driver.render(&state, six_items());
assert!(
driver.row(0).contains("Alpha"),
"the parked view is painted even though the cursor is off-screen: {}",
driver.row(0)
);
assert_eq!(
driver.event(
mouse(MouseKind::Scroll(ScrollDirection::Down), 2, 0),
&state
),
EventResult::Emit(Msg::Scrolled(3))
);
}
#[test]
fn focus_and_scroll_move_atomically_across_the_viewport_without_redraw() {
let mut driver = TestBackendDriver::new(20, 3);
let mut state = State {
focused: Some(Task::C),
..State::default()
};
driver.render(
&state,
[
item(Task::A, "Alpha"),
item(Task::B, "Bravo"),
item(Task::C, "Charlie"),
item(Task::D, "Delta"),
item(Task::E, "Echo"),
item(Task::F, "Foxtrot"),
],
);
assert_eq!(
driver.event(key(KeyCode::Down), &state),
EventResult::Emit(Msg::Focused(Task::D, 1))
);
state.focused = Some(Task::D);
state.scroll = 1;
assert_eq!(
driver.event(key(KeyCode::Down), &state),
EventResult::Emit(Msg::Focused(Task::E, 2)),
"the retained component reads focus and scroll from current app state"
);
state.focused = Some(Task::E);
state.scroll = 2;
assert_eq!(
driver.event(key(KeyCode::Home), &state),
EventResult::Emit(Msg::Focused(Task::A, 0))
);
}
#[test]
fn an_unbound_list_still_scrolls_on_the_wheel() {
let state = State {
focused: Some(Task::A),
..State::default()
};
let theme = Theme::default_dark();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 3)).expect("terminal");
let draw = |terminal: &mut Terminal<TestBackend>, ratcn: &mut Ratcn<State, Msg>| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("list"),
List::new(six_items())
.item_focus(|state: &State| state.focused, Msg::Focused),
area,
);
});
})
.expect("draw");
};
let row = |terminal: &Terminal<TestBackend>, row: u16| -> String {
let buffer = terminal.backend().buffer();
(0..buffer.area.width)
.map(|column| buffer.cell((column, row)).expect("cell").symbol())
.collect()
};
draw(&mut terminal, &mut ratcn);
assert!(row(&terminal, 0).contains("Alpha"), "{}", row(&terminal, 0));
assert_eq!(
ratcn.handle_event(
mouse(MouseKind::Scroll(ScrollDirection::Down), 2, 0),
&state
),
EventResult::Consumed,
"there is no scroll binding, so nothing is emitted"
);
draw(&mut terminal, &mut ratcn);
assert!(
row(&terminal, 0).contains("Delta"),
"the wheel scrolled the view and the offset survived the redraw: {}",
row(&terminal, 0)
);
}
#[test]
fn a_released_wheel_park_never_revives() {
let mut state = State {
focused: Some(Task::A),
..State::default()
};
let theme = Theme::default_dark();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 3)).expect("terminal");
let draw =
|terminal: &mut Terminal<TestBackend>, ratcn: &mut Ratcn<State, Msg>, state: &State| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("list"),
List::new(six_items())
.item_focus(|state: &State| state.focused, Msg::Focused),
area,
);
});
})
.expect("draw");
};
let row = |terminal: &Terminal<TestBackend>, row: u16| -> String {
let buffer = terminal.backend().buffer();
(0..buffer.area.width)
.map(|column| buffer.cell((column, row)).expect("cell").symbol())
.collect()
};
draw(&mut terminal, &mut ratcn, &state);
ratcn.handle_event(
mouse(MouseKind::Scroll(ScrollDirection::Down), 2, 0),
&state,
);
draw(&mut terminal, &mut ratcn, &state);
assert!(row(&terminal, 0).contains("Delta"), "{}", row(&terminal, 0));
assert_eq!(
ratcn.handle_event(key(KeyCode::Down), &state),
EventResult::Emit(Msg::Focused(Task::B, 1))
);
state.focused = Some(Task::B);
draw(&mut terminal, &mut ratcn, &state);
assert!(row(&terminal, 0).contains("Bravo"), "{}", row(&terminal, 0));
assert_eq!(
ratcn.handle_event(key(KeyCode::Up), &state),
EventResult::Emit(Msg::Focused(Task::A, 0))
);
state.focused = Some(Task::A);
draw(&mut terminal, &mut ratcn, &state);
assert!(
row(&terminal, 0).contains("Alpha"),
"returning to the wheel anchor must not re-park the view: {}",
row(&terminal, 0)
);
}
#[test]
fn the_first_hover_motion_moves_the_cursor() {
let mut driver = TestBackendDriver::new(20, 3);
let state = State {
focused: Some(Task::A),
..State::default()
};
driver.render(&state, six_items());
assert_eq!(
driver.event(mouse(MouseKind::Moved, 2, 1), &state),
EventResult::Emit(Msg::Focused(Task::B, 0)),
"an unfocused, not-yet-hovered list still tracks the pointer"
);
}
#[test]
fn horizontal_wheel_directions_are_ignored() {
let mut list =
List::new([item(Task::A, "Alpha")]).scroll(|state: &State| state.scroll, Msg::Scrolled);
let state = State::default();
for direction in [ScrollDirection::Left, ScrollDirection::Right] {
assert_eq!(
list.handle_event(
&mouse(MouseKind::Scroll(direction), 0, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Ignored
);
}
}
#[test]
fn cursorless_list_is_not_a_keyboard_stop_and_ignores_keys() {
let theme = Theme::default_dark();
let state = State::default();
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
"list",
List::new([item(Task::A, "Alpha")])
.selection(|state: &State| state.selected, Msg::Selected),
Rect::new(0, 0, 20, 1),
);
ctx.render_component(
"button",
crate::Button::new("Next").on_press(|| Msg::Pressed),
Rect::new(0, 1, 20, 1),
);
});
})
.expect("draw");
assert_eq!(ratcn.focus_path(&[ChildId::Static("list")]), None);
assert_eq!(
ratcn.handle_event(key(KeyCode::Enter), &state),
EventResult::Emit(Msg::Pressed)
);
}
#[test]
fn no_selection_mode_draws_neutral_rows_and_reports_unselected_custom_state() {
let theme = Theme::default_dark();
let state = State {
focused: Some(Task::A),
..State::default()
};
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
"plain",
List::new([item(Task::A, "Alpha")])
.item_focus(|state: &State| state.focused, Msg::Focused),
Rect::new(0, 0, 20, 1),
);
ctx.render_component(
"custom",
List::new([item(Task::B, "Bravo")]).render_item(|_, row| {
Line::from(format!("{} selected={}", row.label, row.selected))
}),
Rect::new(0, 1, 20, 1),
);
});
})
.expect("draw");
let buffer = terminal.backend().buffer();
let row = |y: usize| {
buffer.content[y * 20..(y + 1) * 20]
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
};
assert!(row(0).starts_with("Alpha"));
assert!(!row(0).contains(['○', '●', '□', '■']));
assert!(row(1).starts_with("Bravo selected=false"));
}
#[test]
fn hovered_list_paints_pointer_moved_item_focus_without_keyboard_focus() {
let theme = Theme::default_dark();
let mut state = State {
focused: Some(Task::A),
component_focus: crate::runtime::FocusState::intent(["other"]),
..State::default()
};
let mut ratcn = Ratcn::new()
.focus(|state: &State| &state.component_focus, Msg::ComponentFocus)
.hover(|state: &State| &state.hover, Msg::Hover);
let mut terminal = Terminal::new(TestBackend::new(20, 3)).expect("terminal");
let render =
|terminal: &mut Terminal<TestBackend>, ratcn: &mut Ratcn<State, Msg>, state: &State| {
terminal
.draw(|frame| {
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
"other",
crate::Button::<Msg>::new("Other"),
Rect::new(0, 0, 20, 1),
);
ctx.render_component(
"list",
List::new([item(Task::A, "Alpha"), item(Task::B, "Bravo")])
.item_focus(|state: &State| state.focused, Msg::Focused)
.render_item(|_, row| {
Line::from(format!(
"{} {}",
if row.focused { "focused" } else { "idle" },
row.label
))
})
.focus_symbol(">"),
Rect::new(0, 1, 20, 2),
);
});
})
.expect("draw");
};
render(&mut terminal, &mut ratcn, &state);
let EventResult::Emit(Msg::Hover(hover)) =
ratcn.handle_event(mouse(MouseKind::Moved, 2, 2), &state)
else {
panic!("entering the list should update hover first");
};
state.hover = hover;
render(&mut terminal, &mut ratcn, &state);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Moved, 2, 2), &state),
EventResult::Emit(Msg::Focused(Task::B, 0))
);
state.focused = Some(Task::B);
render(&mut terminal, &mut ratcn, &state);
assert!(
terminal
.backend()
.buffer()
.cell((0, 2))
.is_some_and(|cell| cell.symbol() == ">")
);
let buffer = terminal.backend().buffer();
let painted_row: String = buffer.content[40..60]
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(painted_row.contains("focused Bravo"));
assert_eq!(
state.component_focus,
crate::runtime::FocusState::intent(["other"])
);
}
#[test]
fn home_end_and_page_keys_skip_disabled_items_using_rendered_height() {
let mut driver = TestBackendDriver::new(20, 3);
let state = State {
focused: Some(Task::C),
..State::default()
};
let items = [
item(Task::A, "A").disabled(true),
item(Task::B, "B"),
item(Task::C, "C"),
item(Task::D, "D"),
item(Task::E, "E").disabled(true),
item(Task::F, "F"),
];
driver.render(&state, items.clone());
assert_eq!(
driver.event(key(KeyCode::Home), &state),
EventResult::Emit(Msg::Focused(Task::B, 0))
);
assert_eq!(
driver.event(key(KeyCode::End), &state),
EventResult::Emit(Msg::Focused(Task::F, 3))
);
assert_eq!(
driver.event(key(KeyCode::PageDown), &state),
EventResult::Emit(Msg::Focused(Task::F, 3))
);
assert_eq!(
driver.event(key(KeyCode::PageUp), &state),
EventResult::Emit(Msg::Focused(Task::B, 0))
);
let leap_items = [
item(Task::A, "A"),
item(Task::B, "B"),
item(Task::C, "C"),
item(Task::D, "D").disabled(true),
item(Task::E, "E").disabled(true),
item(Task::F, "F"),
];
driver.render(&state, leap_items);
assert_eq!(
driver.event(key(KeyCode::Down), &state),
EventResult::Emit(Msg::Focused(Task::F, 3)),
"a disabled leap scrolls far enough to expose its enabled target"
);
}
#[test]
fn multi_selection_toggles_the_clicked_value() {
let mut list = List::new([item(Task::A, "Alpha")]).multi_selection(
|state: &State, value| state.toggled.contains(value),
Msg::Toggled,
);
assert_eq!(
list.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 1, 0),
&State::default(),
&mut EventCtx::default().with_area(Rect::new(0, 0, 10, 1))
),
EventResult::Emit(Msg::Toggled(Task::A))
);
}
#[test]
fn a_list_without_selection_bubbles_enter_and_clicks_move_focus() {
let mut list = List::new([item(Task::A, "Alpha"), item(Task::B, "Bravo")])
.item_focus(|state: &State| state.focused, Msg::Focused);
let state = State {
focused: Some(Task::A),
..State::default()
};
let area = Rect::new(0, 0, 10, 2);
assert_eq!(
list.handle_event(
&key(KeyCode::Enter),
&state,
&mut EventCtx::default().with_area(area)
),
EventResult::Ignored
);
assert_eq!(
list.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 1, 1),
&state,
&mut EventCtx::default().with_area(area)
),
EventResult::Emit(Msg::Focused(Task::B, 0))
);
}
#[test]
fn conflicting_selection_modes_fail_during_declaration() {
let mut driver = TestBackendDriver::new(20, 1);
let state = State::default();
let panic = catch_unwind(AssertUnwindSafe(|| {
let theme = Theme::default_dark();
driver
.terminal
.draw(|frame| {
let area = frame.area();
driver.ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
"list",
List::new([item(Task::A, "Alpha")])
.selection(|_: &State| None, Msg::Selected)
.multi_selection(|_, _| false, Msg::Toggled),
area,
);
});
})
.expect("draw");
}))
.expect_err("conflicting modes must panic");
let message = panic.downcast_ref::<String>().map_or_else(
|| {
panic
.downcast_ref::<&str>()
.copied()
.unwrap_or_default()
.to_owned()
},
Clone::clone,
);
assert!(message.contains("List::selection(...)"));
assert!(message.contains("List::multi_selection(...)"));
}
#[test]
fn duplicate_item_values_fail_declaration_with_the_documented_panic() {
let mut list: List<Task, State, Msg> =
List::new([item(Task::A, "First"), item(Task::A, "Second")]);
let panic = catch_unwind(AssertUnwindSafe(|| {
Component::prepare(&mut list, &State::default());
}))
.expect_err("duplicate item values must panic");
let message = panic.downcast_ref::<String>().map_or_else(
|| {
panic
.downcast_ref::<&str>()
.copied()
.unwrap_or_default()
.to_owned()
},
Clone::clone,
);
assert_eq!(
message,
"List item values must be unique within a List declaration"
);
}
#[test]
fn vim_and_readline_keys_step_the_cursor_like_the_arrows() {
let mut driver = TestBackendDriver::new(20, 6);
let state = State {
focused: Some(Task::B),
..State::default()
};
let items = || {
[
item(Task::A, "Alpha"),
item(Task::B, "Bravo"),
item(Task::C, "Charlie"),
]
};
driver.render(&state, items());
for down in [key(KeyCode::Char('j')), ctrl_key('n')] {
assert_eq!(
driver.event(down, &state),
EventResult::Emit(Msg::Focused(Task::C, 0)),
"j and Ctrl+N step forward like Down"
);
}
for up in [key(KeyCode::Char('k')), ctrl_key('p')] {
assert_eq!(
driver.event(up, &state),
EventResult::Emit(Msg::Focused(Task::A, 0)),
"k and Ctrl+P step backward like Up"
);
}
}
#[test]
fn a_letter_that_is_not_a_navigation_key_bubbles_as_an_app_hotkey() {
let mut driver = TestBackendDriver::new(20, 6);
let state = State {
focused: Some(Task::A),
..State::default()
};
driver.render(&state, [item(Task::A, "Alpha"), item(Task::B, "Bravo")]);
assert_eq!(
driver.event(key(KeyCode::Char('a')), &state),
EventResult::Ignored,
"there is no typeahead: plain letters belong to the app"
);
assert_eq!(
driver.event(key(KeyCode::Char('J')), &state),
EventResult::Ignored,
"Shift+j is not j, so it is not navigation either"
);
}
#[test]
fn space_commits_single_selection_before_typeahead() {
let mut list = List::new([item(Task::A, "Alpha"), item(Task::B, " Bravo")])
.item_focus(|state: &State| state.focused, Msg::Focused)
.selection(|state: &State| state.selected, Msg::Selected);
let state = State {
focused: Some(Task::A),
..State::default()
};
assert_eq!(
list.handle_event(
&key(KeyCode::Char(' ')),
&state,
&mut EventCtx::default().with_area(Rect::new(0, 0, 20, 2)),
),
EventResult::Emit(Msg::Selected(Task::A)),
"Space commits the cursor instead of typeahead-moving to the space-prefixed label"
);
}
#[test]
fn space_commits_multi_selection_before_typeahead() {
let mut list = List::new([item(Task::A, "Alpha"), item(Task::B, " Bravo")])
.item_focus(|state: &State| state.focused, Msg::Focused)
.multi_selection(
|state: &State, value| state.toggled.contains(value),
Msg::Toggled,
);
let state = State {
focused: Some(Task::A),
..State::default()
};
assert_eq!(
list.handle_event(
&key(KeyCode::Char(' ')),
&state,
&mut EventCtx::default().with_area(Rect::new(0, 0, 20, 2)),
),
EventResult::Emit(Msg::Toggled(Task::A)),
"Space toggles the cursor instead of typeahead-moving to the space-prefixed label"
);
}
#[test]
fn events_before_the_first_render_are_ignored() {
let mut driver = TestBackendDriver::new(20, 2);
assert_eq!(
driver.event(key(KeyCode::PageDown), &State::default()),
EventResult::Ignored
);
}
}