use std::{fmt, rc::Rc};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, 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, MouseEvent, MouseKind,
PopupOptions, RenderCtx, ScrollDirection,
};
use crate::selection_indicator;
const ROW_FOCUS_LIGHTEN: u16 = 15;
const INDICATOR_CLOSED: &str = "∨";
const INDICATOR_OPEN: &str = "∧";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectStyle {
pub value_foreground: Color,
pub placeholder_foreground: Color,
pub trigger_background: Color,
pub focused_trigger_background: Color,
pub hovered_trigger_background: Color,
pub indicator: Color,
pub border: Color,
pub panel_background: Color,
pub option_foreground: Color,
pub focused_option_foreground: Color,
pub focused_option_background: Color,
pub selected_foreground: 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,
pub selected_disabled_foreground: Color,
pub selected_disabled_background: Color,
}
impl SelectStyle {
#[must_use]
pub const fn fallback() -> Self {
Self {
value_foreground: Color::White,
placeholder_foreground: Color::DarkGray,
trigger_background: Color::Reset,
focused_trigger_background: Color::Reset,
hovered_trigger_background: Color::DarkGray,
indicator: Color::DarkGray,
border: Color::DarkGray,
panel_background: Color::Reset,
option_foreground: Color::Reset,
focused_option_foreground: Color::Black,
focused_option_background: Color::Cyan,
selected_foreground: Color::White,
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,
selected_disabled_foreground: Color::DarkGray,
selected_disabled_background: Color::Reset,
}
}
#[must_use]
pub const fn from_theme(theme: &Theme) -> Self {
let panel_background = lighten(theme.field, FIELD_FOCUS_LIGHTEN);
Self {
value_foreground: theme.foreground,
placeholder_foreground: theme.muted_foreground,
trigger_background: theme.field,
focused_trigger_background: panel_background,
hovered_trigger_background: lighten(theme.field, FIELD_HOVER_LIGHTEN),
indicator: theme.muted_foreground,
border: theme.border,
panel_background,
option_foreground: theme.muted_foreground,
focused_option_foreground: theme.foreground,
focused_option_background: lighten(panel_background, ROW_FOCUS_LIGHTEN),
selected_foreground: theme.foreground,
selected_focused_foreground: theme.foreground,
selected_focused_background: lighten(panel_background, ROW_FOCUS_LIGHTEN),
selected_marker: theme.primary,
unselected_marker: theme.muted_foreground,
disabled_foreground: theme.muted_foreground,
disabled_background: dim(theme.field, theme.surface, DISABLED_DIM),
selected_disabled_foreground: theme.muted_foreground,
selected_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_trigger_background
} else if focused {
self.focused_trigger_background
} else {
self.trigger_background
}
}
const fn resolve_row(self, focused: bool, selected: bool, disabled: bool) -> Style {
let (foreground, background) = if selected && disabled {
(
self.selected_disabled_foreground,
self.selected_disabled_background,
)
} else if disabled {
(self.disabled_foreground, self.disabled_background)
} else if selected && focused {
(
self.selected_focused_foreground,
self.selected_focused_background,
)
} else if focused {
(
self.focused_option_foreground,
self.focused_option_background,
)
} else if selected {
(self.selected_foreground, self.panel_background)
} else {
(self.option_foreground, self.panel_background)
};
Style::new().fg(foreground).bg(background)
}
}
#[expect(clippy::struct_excessive_bools, reason = "independent paint states")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectWidget<'a> {
value: Option<&'a str>,
placeholder: &'a str,
open: bool,
options: &'a [&'a str],
option_rows: Option<&'a [Text<'static>]>,
row_height: u16,
focused_option: Option<usize>,
selected_option: Option<usize>,
disabled_options: &'a [bool],
scroll_offset: usize,
focused: bool,
hovered: bool,
disabled: bool,
style: SelectStyle,
}
impl<'a> SelectWidget<'a> {
pub const TRIGGER_HEIGHT: u16 = 1;
#[must_use]
pub const fn new(value: Option<&'a str>) -> Self {
Self {
value,
placeholder: "",
open: false,
options: &[],
option_rows: None,
row_height: 1,
focused_option: None,
selected_option: None,
disabled_options: &[],
scroll_offset: 0,
focused: false,
hovered: false,
disabled: false,
style: SelectStyle::fallback(),
}
}
#[must_use]
pub const fn themed(mut self, theme: &Theme) -> Self {
self.style = SelectStyle::from_theme(theme);
self
}
#[must_use]
pub const fn style(mut self, style: SelectStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub const fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = placeholder;
self
}
#[must_use]
pub const fn open(mut self, options: &'a [&'a str]) -> Self {
self.open = true;
self.options = options;
self
}
#[must_use]
pub const fn option_rows(mut self, option_rows: &'a [Text<'static>]) -> Self {
self.option_rows = Some(option_rows);
self
}
#[must_use]
pub const fn row_height(mut self, rows: u16) -> Self {
self.row_height = if rows == 0 { 1 } else { rows };
self
}
#[must_use]
pub const fn focused_option(mut self, focused: Option<usize>) -> Self {
self.focused_option = focused;
self
}
#[must_use]
pub const fn selected_option(mut self, selected: Option<usize>) -> Self {
self.selected_option = selected;
self
}
#[must_use]
pub const fn disabled_options(mut self, disabled: &'a [bool]) -> Self {
self.disabled_options = disabled;
self
}
#[must_use]
pub const fn scroll_offset(mut self, offset: usize) -> Self {
self.scroll_offset = offset;
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 height(&self, max_visible: u16, area_height: u16) -> u16 {
let visible = self.visible_options(max_visible, area_height);
if area_height == 0 {
0
} else if visible == 0 {
Self::TRIGGER_HEIGHT
} else {
Self::TRIGGER_HEIGHT + visible * self.row_height + 2
}
}
#[must_use]
#[expect(
clippy::cast_possible_truncation,
reason = "the option count is clamped before conversion"
)]
pub const fn visible_options(&self, max_visible: u16, area_height: u16) -> u16 {
if !self.open || self.disabled {
return 0;
}
let count = if self.options.len() > u16::MAX as usize {
u16::MAX
} else {
self.options.len() as u16
};
min_u16(
count,
min_u16(
max_visible,
area_height.saturating_sub(Self::TRIGGER_HEIGHT + 2) / self.row_height,
),
)
}
}
const fn min_u16(left: u16, right: u16) -> u16 {
if left < right { left } else { right }
}
impl Widget for SelectWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
render_trigger(self, area, buf);
let visible = self.visible_options(u16::MAX, area.height);
if self.open && !self.disabled && visible > 0 {
let panel = Rect::new(
area.x,
area.y + 1,
area.width,
visible * self.row_height + 2,
);
render_panel(self, panel, buf);
}
}
}
fn render_trigger(widget: SelectWidget<'_>, area: Rect, buf: &mut Buffer) {
let area = Rect { height: 1, ..area }.intersection(buf.area);
let background = widget
.style
.resolve_surface(widget.focused, widget.hovered, widget.disabled);
let (text, foreground) = if widget.disabled {
(
widget.value.unwrap_or(widget.placeholder),
widget.style.disabled_foreground,
)
} else {
widget.value.map_or(
(widget.placeholder, widget.style.placeholder_foreground),
|value| (value, widget.style.value_foreground),
)
};
buf.set_style(area, Style::new().bg(background));
buf.set_stringn(
area.x.saturating_add(1),
area.y,
text,
usize::from(area.width.saturating_sub(4)),
Style::new().fg(foreground),
);
if area.width >= 2 {
let indicator = if widget.open && !widget.disabled {
INDICATOR_OPEN
} else {
INDICATOR_CLOSED
};
let color = if widget.disabled {
widget.style.disabled_foreground
} else {
widget.style.indicator
};
buf.set_string(area.right() - 2, area.y, indicator, Style::new().fg(color));
}
}
fn render_panel(widget: SelectWidget<'_>, area: Rect, buf: &mut Buffer) {
let block = Block::new()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(
Style::new()
.fg(widget.style.border)
.bg(widget.style.panel_background),
)
.style(Style::new().bg(widget.style.panel_background));
let inner = block.inner(area);
block.render(area, buf);
let row_height = widget.row_height.max(1);
for (row, index) in (widget.scroll_offset..widget.options.len())
.take(usize::from(inner.height / row_height))
.enumerate()
{
let disabled = widget.disabled_options.get(index).copied().unwrap_or(false);
let selected = widget.selected_option == Some(index);
let row_area = Rect::new(
inner.x,
inner.y + u16::try_from(row).expect("visible rows fit in u16") * row_height,
inner.width,
row_height,
);
buf.set_style(
row_area,
widget
.style
.resolve_row(widget.focused_option == Some(index), selected, disabled),
);
if let Some(rows) = widget.option_rows {
if let Some(text) = rows.get(index) {
text.render(row_area, buf);
}
continue;
}
let marker = selection_indicator::marker(selected, false);
let marker_color = selection_indicator::color(
disabled,
selected,
selection_indicator::MarkerColors {
disabled: widget.style.disabled_foreground,
selected: widget.style.selected_marker,
unselected: widget.style.unselected_marker,
},
);
let line = Line::from(vec![
Span::styled(format!(" {marker}"), Style::new().fg(marker_color)),
Span::raw(" "),
Span::raw(widget.options[index].to_owned()),
]);
line.render(row_area, buf);
}
}
struct SelectPanelWidget<'a>(SelectWidget<'a>);
impl Widget for SelectPanelWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
render_panel(self.0, area, buf);
}
}
type ReadFn<S, T> = Rc<dyn Fn(&S) -> Option<T>>;
type ReadOpenFn<S> = Rc<dyn Fn(&S) -> bool>;
type OnOpenChangeFn<M> = Rc<dyn Fn(bool) -> M>;
type OpenBinding<S, M> = (ReadOpenFn<S>, OnOpenChangeFn<M>);
type OnChangeFn<T, M> = Rc<dyn Fn(T) -> M>;
type RenderItemFn<S, T> = Rc<dyn for<'a> Fn(&S, ListItemState<'a, T>) -> Text<'static>>;
type StyleFn = Rc<dyn Fn(&Theme) -> SelectStyle>;
fn bound_index<T: PartialEq, S>(
items: &[ListItem<T>],
state: &S,
read: Option<&ReadFn<S, T>>,
) -> Option<usize> {
list_core::index_of(items, &(read?)(state)?)
}
fn resolved_cursor_index<T: PartialEq, S>(
items: &[ListItem<T>],
state: &S,
focused: Option<&ReadFn<S, T>>,
selected: Option<&ReadFn<S, T>>,
) -> Option<usize> {
bound_index(items, state, focused)
.or_else(|| bound_index(items, state, selected))
.or_else(|| linear_nav::first_enabled(items.len(), |i| list_core::disabled_at(items, i)))
}
fn emit_item<T: Clone, M>(
handler: Option<&OnChangeFn<T, M>>,
items: &[ListItem<T>],
index: usize,
) -> EventResult<M> {
handler.map_or(EventResult::Ignored, |handler| {
EventResult::Emit(handler(items[index].value().clone()))
})
}
#[expect(
clippy::struct_field_names,
reason = "on_select matches the public selection binding vocabulary"
)]
pub struct Select<T, S, M> {
items: Vec<ListItem<T>>,
placeholder: String,
open: Option<OpenBinding<S, M>>,
focused_item: Option<ReadFn<S, T>>,
on_focus_change: Option<OnChangeFn<T, M>>,
selected: Option<ReadFn<S, T>>,
on_select: Option<OnChangeFn<T, M>>,
max_visible: u16,
disabled: bool,
render_item: Option<RenderItemFn<S, T>>,
row_height: u16,
style: Option<StyleFn>,
resolved_open: bool,
page_size: usize,
}
impl<T: fmt::Debug, S, M> fmt::Debug for Select<T, S, M> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Select")
.field("items", &self.items)
.field("open", &self.open.is_some())
.field("item_focus", &self.focused_item.is_some())
.field("selection", &self.selected.is_some())
.field("disabled", &self.disabled)
.finish_non_exhaustive()
}
}
impl<T, S, M> Select<T, S, M> {
pub const DEFAULT_MAX_VISIBLE_OPTIONS: u16 = 8;
#[must_use]
pub fn new(items: impl IntoIterator<Item = impl Into<ListItem<T>>>) -> Self {
Self {
items: items.into_iter().map(Into::into).collect(),
placeholder: String::new(),
open: None,
focused_item: None,
on_focus_change: None,
selected: None,
on_select: None,
max_visible: Self::DEFAULT_MAX_VISIBLE_OPTIONS,
disabled: false,
render_item: None,
row_height: 1,
style: None,
resolved_open: false,
page_size: 1,
}
}
#[must_use]
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
#[must_use]
pub fn open(
mut self,
read: impl Fn(&S) -> bool + 'static,
on_open_change: impl Fn(bool) -> M + 'static,
) -> Self {
self.open = Some((Rc::new(read), Rc::new(on_open_change)));
self
}
#[must_use]
pub fn item_focus(
mut self,
read: impl Fn(&S) -> Option<T> + 'static,
on_change: impl Fn(T) -> M + 'static,
) -> Self {
self.focused_item = Some(Rc::new(read));
self.on_focus_change = Some(Rc::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(Rc::new(read));
self.on_select = Some(Rc::new(on_select));
self
}
#[must_use]
pub const fn max_visible_options(mut self, max_visible: u16) -> Self {
self.max_visible = if max_visible == 0 { 1 } else { max_visible };
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(Rc::new(move |state, row| f(state, row).into()));
self
}
#[must_use]
pub const fn row_height(mut self, rows: u16) -> Self {
self.row_height = if rows == 0 { 1 } else { rows };
self
}
#[must_use]
pub const fn height(&self) -> u16 {
SelectWidget::TRIGGER_HEIGHT
}
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> SelectStyle + 'static) -> Self {
self.style = Some(Rc::new(style));
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
fn is_traversal(key: KeyEvent) -> bool {
match key.code {
KeyCode::Tab => !key.modifiers.any(),
KeyCode::BackTab => !key.modifiers.ctrl && !key.modifiers.alt,
_ => false,
}
}
impl<T: Clone + PartialEq, S, M> Select<T, S, M> {
fn disabled_at(&self, index: usize) -> bool {
list_core::disabled_at(&self.items, index)
}
fn selected_index(&self, state: &S) -> Option<usize> {
bound_index(&self.items, state, self.selected.as_ref())
}
fn cursor_index(&self, state: &S) -> Option<usize> {
resolved_cursor_index(
&self.items,
state,
self.focused_item.as_ref(),
self.selected.as_ref(),
)
}
fn is_open(&self, state: &S) -> bool {
self.open.as_ref().is_some_and(|(read, _)| read(state))
}
fn toggle(&self, open: bool) -> EventResult<M> {
self.open
.as_ref()
.map_or(EventResult::Ignored, |(_, toggle)| {
EventResult::Emit(toggle(open))
})
}
fn move_cursor(&self, index: usize) -> EventResult<M> {
emit_item(self.on_focus_change.as_ref(), &self.items, index)
}
fn select(&self, index: usize) -> EventResult<M> {
emit_item(self.on_select.as_ref(), &self.items, index)
}
fn keyboard_enabled(&self) -> bool {
self.open.is_some() && self.focused_item.is_some() && self.selected.is_some()
}
fn has_enabled_item(&self) -> bool {
linear_nav::first_enabled(self.items.len(), |i| self.disabled_at(i)).is_some()
}
fn handle_key(&self, key: KeyEvent, state: &S, page_size: usize) -> EventResult<M> {
if !self.keyboard_enabled() {
return EventResult::Ignored;
}
let open = self.is_open(state);
if open && is_traversal(key) {
return self.toggle(false);
}
if open {
self.handle_open_key(key, state, page_size)
} else {
self.handle_closed_key(key)
}
}
fn handle_closed_key(&self, key: KeyEvent) -> EventResult<M> {
if !self.has_enabled_item() {
return EventResult::Ignored;
}
if linear_nav::is_step_key(key) {
return self.toggle(true);
}
if linear_nav::has_reserved_modifier(key) {
return EventResult::Ignored;
}
match key.code {
KeyCode::Enter | KeyCode::Char(' ') => self.toggle(true),
_ => EventResult::Ignored,
}
}
fn handle_open_key(&self, key: KeyEvent, state: &S, page_size: usize) -> EventResult<M> {
if key.code == KeyCode::Esc && !linear_nav::has_reserved_modifier(key) {
return self.toggle(false);
}
if !self.has_enabled_item() {
return EventResult::Ignored;
}
let cursor = self.cursor_index(state);
if let Some(outcome) =
linear_nav::nav_key_target(key, self.items.len(), cursor, page_size.max(1), |i| {
self.disabled_at(i)
})
{
return match outcome {
NavOutcome::Move(index) => self.move_cursor(index),
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 + 'static, S: 'static, M: 'static> Component<S, M> for Select<T, S, M> {
fn prepare(&mut self, state: &S) {
list_core::assert_unique_values(self.items.iter().map(ListItem::value), "Select");
self.resolved_open = !self.disabled && self.is_open(state);
}
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, S, M>) {
let area = Rect {
height: ctx.area().height.min(1),
..ctx.area()
};
let state = ctx.state();
let style = self.style.as_ref().map_or_else(
|| SelectStyle::from_theme(ctx.theme),
|style| style(ctx.theme),
);
let selected = self.selected_index(state);
let value = selected.map(|index| self.items[index].label());
let labels: Vec<&str> = self.items.iter().map(ListItem::label).collect();
let mut trigger = SelectWidget::new(value)
.placeholder(&self.placeholder)
.focused(ctx.focused)
.hovered(ctx.hovered)
.disabled(self.disabled)
.style(style);
if self.resolved_open {
trigger = trigger.open(&labels);
}
ctx.render_widget(trigger, area);
let Some((panel_area, viewport)) = self
.resolved_open
.then(|| {
panel_layout(
area,
ctx.frame_area(),
self.items.len(),
self.max_visible,
self.row_height,
)
})
.flatten()
else {
return;
};
let inner = Block::new().borders(Borders::ALL).inner(panel_area);
self.page_size = viewport.visible_items(inner).max(1);
let Some((open, on_open_change)) = &self.open else {
return;
};
let panel = SelectPanel {
items: self.items.clone(),
open: Rc::clone(open),
focused_item: self.focused_item.clone(),
on_focus_change: self.on_focus_change.clone(),
selected: self.selected.clone(),
on_select: self.on_select.clone(),
render_item: self.render_item.clone(),
style,
panel_area,
viewport,
};
let on_open_change = Rc::clone(on_open_change);
ctx.popup(
"panel",
PopupOptions::default().on_dismiss(move || on_open_change(false)),
panel_area,
move |ctx| ctx.render_component("options", panel, panel_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;
}
match event {
Event::Mouse(mouse) => match mouse.kind {
MouseKind::Click(MouseButton::Left) => self.toggle(!self.is_open(state)),
_ => EventResult::Ignored,
},
Event::Key(key) => self.handle_key(*key, state, self.page_size),
_ => EventResult::Ignored,
}
}
fn is_focusable(&self, _state: &S) -> bool {
self.keyboard_enabled()
&& !self.disabled
&& linear_nav::first_enabled(self.items.len(), |i| self.disabled_at(i)).is_some()
}
fn interaction_area(&self, area: Rect) -> Rect {
Rect {
height: area.height.min(1),
..area
}
}
}
struct SelectPanel<T, S, M> {
items: Vec<ListItem<T>>,
open: ReadOpenFn<S>,
focused_item: Option<ReadFn<S, T>>,
on_focus_change: Option<OnChangeFn<T, M>>,
selected: Option<ReadFn<S, T>>,
on_select: Option<OnChangeFn<T, M>>,
render_item: Option<RenderItemFn<S, T>>,
style: SelectStyle,
panel_area: Rect,
viewport: RowViewport,
}
impl<T: Clone + PartialEq, S, M> SelectPanel<T, S, M> {
fn cursor(&self, state: &S) -> Option<usize> {
resolved_cursor_index(
&self.items,
state,
self.focused_item.as_ref(),
self.selected.as_ref(),
)
}
fn option_at(&self, mouse: &MouseEvent) -> Option<usize> {
let inner = Block::new().borders(Borders::ALL).inner(self.panel_area);
self.viewport
.row_at(inner, self.items.len(), mouse.column, mouse.row)
}
}
impl<T: Clone + PartialEq, S, M> Component<S, M> for SelectPanel<T, S, M> {
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, S, M>) {
let state = ctx.state();
let labels: Vec<&str> = self.items.iter().map(ListItem::label).collect();
let disabled: Vec<bool> = self.items.iter().map(ListItem::is_disabled).collect();
let cursor = self.cursor(state);
let inner = Block::new().borders(Borders::ALL).inner(self.panel_area);
let len = self.items.len();
let mut park = ctx
.transient_mut::<WheelPark>()
.map_or_else(WheelPark::default, |park| {
park.settle(cursor);
*park
});
let offset = self.viewport.cursor_visible_offset(
inner,
len,
park.offset(),
park.cursor_to_show(cursor),
);
park.record(offset);
if let Some(stored) = ctx.transient_mut::<WheelPark>() {
*stored = park;
}
self.viewport.record_painted_offset(offset);
let selected = bound_index(&self.items, state, self.selected.as_ref());
let rows_per_item = self.viewport.rows_per_item();
let rows: Option<Vec<Text<'static>>> = self.render_item.as_ref().map(|render_item| {
self.items
.iter()
.enumerate()
.map(|(index, item)| {
let row = ListItemState {
index,
value: item.value(),
label: item.label(),
focused: cursor == Some(index),
selected: selected == Some(index),
disabled: item.is_disabled(),
};
fit_to_height(render_item(state, row), rows_per_item)
})
.collect()
});
let mut widget = SelectWidget::new(None)
.open(&labels)
.row_height(rows_per_item)
.focused_option(cursor)
.selected_option(selected)
.disabled_options(&disabled)
.scroll_offset(self.viewport.painted_offset())
.style(self.style);
if let Some(rows) = &rows {
widget = widget.option_rows(rows);
}
ctx.render_widget(SelectPanelWidget(widget), self.panel_area);
}
fn handle_event(&mut self, event: &Event, state: &S, ctx: &mut EventCtx<'_>) -> EventResult<M> {
if !(self.open)(state) {
return EventResult::Ignored;
}
let Event::Mouse(mouse) = event else {
return EventResult::Ignored;
};
let cursor = self.cursor(state);
let option = self.option_at(mouse);
match mouse.kind {
MouseKind::Down(MouseButton::Left)
if option.is_some_and(|i| list_core::disabled_at(&self.items, i)) =>
{
EventResult::Consumed
}
MouseKind::Click(MouseButton::Left) => match option {
Some(index) if list_core::disabled_at(&self.items, index) => EventResult::Ignored,
Some(index) if self.on_select.is_none() => {
emit_item(self.on_focus_change.as_ref(), &self.items, index)
}
Some(index) => emit_item(self.on_select.as_ref(), &self.items, index),
None => EventResult::Consumed,
},
MouseKind::Moved => match option {
_ if self.focused_item.is_none() => EventResult::Ignored,
Some(index) if list_core::disabled_at(&self.items, index) => EventResult::Ignored,
Some(index) if Some(index) != cursor => {
emit_item(self.on_focus_change.as_ref(), &self.items, index)
}
Some(_) => EventResult::Consumed,
None => EventResult::Ignored,
},
MouseKind::Scroll(direction) => {
let step = match direction {
ScrollDirection::Up => ScrollStep::Up,
ScrollDirection::Down => ScrollStep::Down,
ScrollDirection::Left | ScrollDirection::Right => {
return EventResult::Ignored;
}
};
let inner = Block::new().borders(Borders::ALL).inner(self.panel_area);
let offset = linear_nav::wheel_offset(
self.items.len(),
self.viewport.visible_items(inner),
self.viewport.painted_offset(),
step,
SCROLL_STEP,
);
ctx.transient::<WheelPark>().park(offset, cursor);
self.viewport.record_painted_offset(offset);
EventResult::Consumed
}
_ => EventResult::Ignored,
}
}
}
fn visible_count(len: usize, max_visible: u16, available: u16) -> u16 {
u16::try_from(len)
.unwrap_or(u16::MAX)
.min(max_visible)
.min(available)
}
fn panel_layout(
trigger: Rect,
bounds: Rect,
len: usize,
max_visible: u16,
row_height: u16,
) -> Option<(Rect, RowViewport)> {
let row_height = row_height.max(1);
let visible = visible_count(
len,
max_visible,
bounds.height.saturating_sub(2) / row_height,
);
if visible == 0 || trigger.width == 0 {
return None;
}
let height = visible.saturating_mul(row_height).saturating_add(2);
let desired_y = trigger.y.saturating_sub(1);
let max_y = bounds.bottom().saturating_sub(height);
let y = desired_y.clamp(bounds.y, max_y);
Some((
Rect::new(trigger.x, y, trigger.width, height),
RowViewport::new(row_height),
))
}
#[cfg(test)]
const fn frame_area() -> Rect {
Rect::new(0, 0, 20, 8)
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::{
ListStyle,
runtime::{FocusState, Modifiers, Ratcn, ScopeOptions},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Fruit {
Mango,
Papaya,
Lychee,
Durian,
}
#[derive(Debug, PartialEq)]
enum Msg {
Open(bool),
Focused(Fruit),
Selected(Fruit),
Focus(FocusState),
}
#[derive(Default)]
struct State {
open: bool,
cursor: Option<Fruit>,
selected: Option<Fruit>,
focus: FocusState,
}
fn items() -> Vec<ListItem<Fruit>> {
[
(Fruit::Mango, "Mango"),
(Fruit::Papaya, "Papaya"),
(Fruit::Lychee, "Lychee"),
(Fruit::Durian, "Durian"),
]
.into_iter()
.map(|(value, label)| ListItem::new(value, label))
.collect()
}
#[test]
fn list_and_select_share_themed_control_surfaces() {
for theme in Theme::presets() {
let list = ListStyle::from_theme(theme);
let select = SelectStyle::from_theme(theme);
assert_eq!(
select.trigger_background, list.background,
"{} base",
theme.name
);
assert_eq!(
select.focused_trigger_background, list.focused_background,
"{} focus",
theme.name
);
assert_eq!(
select.hovered_trigger_background, list.hovered_background,
"{} hover",
theme.name
);
assert_eq!(
select.panel_background, list.focused_background,
"{} panel",
theme.name
);
assert_eq!(
select.focused_option_background, list.focused_row_background,
"{} row",
theme.name
);
assert_eq!(
select.selected_focused_foreground, list.selected_focused_foreground,
"{} selected focused foreground",
theme.name
);
assert_eq!(
select.selected_focused_background, list.selected_focused_background,
"{} selected focused background",
theme.name
);
assert_eq!(
select.disabled_background, list.disabled_background,
"{} disabled",
theme.name
);
assert_eq!(
select.selected_disabled_foreground, list.disabled_foreground,
"{} selected disabled foreground",
theme.name
);
assert_eq!(
select.selected_disabled_background, list.disabled_background,
"{} selected disabled background",
theme.name
);
assert_ne!(
list.background, list.focused_background,
"{} focus",
theme.name
);
assert_ne!(
list.focused_background, list.hovered_background,
"{} hover",
theme.name
);
assert_ne!(
list.focused_background, list.focused_row_background,
"{} focused row",
theme.name
);
assert_ne!(
list.foreground, list.selected_foreground,
"{} selected text",
theme.name
);
assert_ne!(
list.unselected_marker, list.selected_marker,
"{} selected marker",
theme.name
);
}
}
#[test]
fn compound_option_states_use_their_exact_style_fields() {
let mut style = SelectStyle::fallback();
style.selected_focused_foreground = Color::Yellow;
style.selected_focused_background = Color::Blue;
style.selected_disabled_foreground = Color::Magenta;
style.selected_disabled_background = Color::Green;
assert_eq!(
style.resolve_row(true, true, false),
Style::new().fg(Color::Yellow).bg(Color::Blue)
);
assert_eq!(
style.resolve_row(true, true, true),
Style::new().fg(Color::Magenta).bg(Color::Green),
"disabled wins over focus while preserving selected identity"
);
}
#[test]
fn disabled_open_widget_measures_and_paints_only_the_closed_trigger() {
let options = ["Mango", "Papaya"];
let widget = SelectWidget::new(None).open(&options).disabled(true);
let area = Rect::new(0, 0, 12, 5);
let mut buffer = Buffer::empty(area);
assert_eq!(widget.visible_options(8, area.height), 0);
assert_eq!(widget.height(8, area.height), SelectWidget::TRIGGER_HEIGHT);
widget.render(area, &mut buffer);
assert_eq!(
buffer.cell((10, 0)).expect("indicator").symbol(),
INDICATOR_CLOSED
);
assert!(
(1..area.height).all(|row| buffer
.cell((0, row))
.is_some_and(|cell| cell.symbol() == " ")),
"a disabled open widget must not paint panel rows"
);
}
fn select(items: Vec<ListItem<Fruit>>) -> Select<Fruit, State, Msg> {
Select::new(items)
.placeholder("Pick a fruit")
.open(|state: &State| state.open, Msg::Open)
.item_focus(|state: &State| state.cursor, Msg::Focused)
.selection(|state: &State| state.selected, Msg::Selected)
}
struct Driver {
terminal: Terminal<TestBackend>,
ratcn: Ratcn<State, Msg>,
}
impl Driver {
fn new(width: u16, height: u16) -> Self {
Self {
terminal: Terminal::new(TestBackend::new(width, height)).expect("terminal"),
ratcn: Ratcn::new().focus(|state: &State| &state.focus, Msg::Focus),
}
}
fn render(&mut self, state: &State, area: Rect, items: &[ListItem<Fruit>]) {
let theme = Theme::default_dark();
self.terminal
.draw(|frame| {
self.ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component("fruit", select(items.to_vec()), area);
ctx.render_widget(Line::from("later sibling"), Rect::new(0, 4, 20, 1));
});
})
.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();
(0..buffer.area.width)
.map(|column| buffer.cell((column, row)).expect("cell").symbol())
.collect()
}
}
fn mouse(kind: MouseKind, column: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind,
column,
row,
modifiers: Modifiers::NONE,
})
}
#[test]
fn popup_starts_above_the_trigger_and_layers_above_later_paint() {
let mut driver = Driver::new(20, 10);
let state = State {
open: true,
selected: Some(Fruit::Papaya),
..State::default()
};
driver.render(&state, Rect::new(0, 4, 20, 1), &items());
assert!(driver.row(3).contains("â•"), "{}", driver.row(3));
assert!(driver.row(4).contains("Mango"), "{}", driver.row(4));
assert!(!driver.row(4).contains("later sibling"));
assert!(driver.row(5).contains("Papaya"));
}
#[test]
fn popup_composites_only_the_panel_area() {
let mut terminal = Terminal::new(TestBackend::new(20, 8)).expect("terminal");
let mut ratcn = Ratcn::<State, Msg>::new();
let state = State {
open: true,
..State::default()
};
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component("fruit", select(items()), Rect::new(0, 0, 20, 1));
ctx.render_widget(Line::from("outside panel"), Rect::new(0, 7, 20, 1));
});
})
.expect("draw");
let row: String = (0..20)
.map(|column| {
terminal
.backend()
.buffer()
.cell((column, 7))
.expect("cell")
.symbol()
})
.collect();
assert!(row.contains("outside panel"), "{row}");
}
#[test]
fn panel_shifts_inside_the_frame_when_default_position_does_not_fit() {
let mut driver = Driver::new(20, 8);
let state = State {
open: true,
..State::default()
};
driver.render(&state, Rect::new(0, 7, 20, 1), &items());
assert!(driver.row(3).contains("Mango"), "{}", driver.row(3));
assert!(driver.row(7).contains("╯"), "{}", driver.row(7));
}
#[test]
fn panel_position_depends_only_on_geometry() {
let trigger = Rect::new(0, 4, 20, 1);
let bounds = Rect::new(0, 0, 20, 10);
let (panel, viewport) = panel_layout(trigger, bounds, 10, 4, 1).expect("panel");
assert_eq!(panel.y, trigger.y - 1);
assert_eq!(viewport.painted_offset(), 0, "the panel resolves scrolling");
}
#[test]
fn keys_navigate_select_and_first_tab_closes() {
let mut driver = Driver::new(20, 8);
let state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
driver.render(&state, Rect::new(0, 0, 20, 1), &items());
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Down)), &state),
EventResult::Emit(Msg::Focused(Fruit::Papaya))
);
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(Msg::Open(false))
);
}
#[test]
fn open_close_commit_and_modified_key_contracts_are_explicit() {
let closed = State::default();
for code in [
KeyCode::Enter,
KeyCode::Char(' '),
KeyCode::Up,
KeyCode::Down,
] {
let mut component = select(items());
component.prepare(&closed);
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(code)),
&closed,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Open(true)),
"{code:?} opens a closed Select"
);
}
let open = State {
open: true,
cursor: Some(Fruit::Papaya),
..State::default()
};
let mut component = select(items());
component.prepare(&open);
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Esc)),
&open,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Open(false))
);
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Char(' '))),
&open,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Selected(Fruit::Papaya))
);
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent {
code: KeyCode::Char('k'),
modifiers: Modifiers {
ctrl: true,
..Modifiers::NONE
},
}),
&open,
&mut EventCtx::default(),
),
EventResult::Ignored
);
assert_eq!(
component.handle_event(
&Event::Paste("fruit".to_owned()),
&open,
&mut EventCtx::default(),
),
EventResult::Ignored
);
assert_eq!(
component.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 0, 0),
&closed,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Open(true))
);
}
#[test]
fn paging_moves_the_cursor_by_the_painted_viewport() {
let mut driver = Driver::new(20, 4);
let state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
driver.render(&state, Rect::new(0, 0, 20, 1), &items());
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::PageDown)), &state),
EventResult::Emit(Msg::Focused(Fruit::Lychee)),
"only two option rows fit in the painted panel"
);
}
#[test]
fn wheel_scrolls_the_view_and_the_offset_survives_redraw() {
let mut driver = Driver::new(20, 4);
let mut state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
driver.render(&state, Rect::new(0, 0, 20, 1), &items());
assert!(driver.row(1).contains("Mango"), "{}", driver.row(1));
assert_eq!(
driver.event(
mouse(MouseKind::Scroll(ScrollDirection::Down), 3, 1),
&state
),
EventResult::Consumed,
"the wheel scrolls the view without emitting a cursor move"
);
driver.render(&state, Rect::new(0, 0, 20, 1), &items());
assert!(
driver.row(1).contains("Lychee") && driver.row(2).contains("Durian"),
"the wheeled offset survives the redraw, cursor off-screen: {} / {}",
driver.row(1),
driver.row(2)
);
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Down)), &state),
EventResult::Emit(Msg::Focused(Fruit::Papaya))
);
state.cursor = Some(Fruit::Papaya);
driver.render(&state, Rect::new(0, 0, 20, 1), &items());
assert!(
driver.row(1).contains("Papaya"),
"the cursor scrolls back into view: {}",
driver.row(1)
);
}
#[test]
fn hover_moves_the_cursor_and_reopening_reanchors_the_view() {
let mut driver = Driver::new(20, 4);
let mut state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
let area = Rect::new(0, 0, 20, 1);
driver.render(&state, area, &items());
assert_eq!(
driver.event(mouse(MouseKind::Moved, 3, 2), &state),
EventResult::Emit(Msg::Focused(Fruit::Papaya)),
"hover moves the one cursor, as in List"
);
assert_eq!(
driver.event(
mouse(MouseKind::Scroll(ScrollDirection::Down), 3, 1),
&state
),
EventResult::Consumed
);
driver.render(&state, area, &items());
assert!(driver.row(1).contains("Lychee"), "{}", driver.row(1));
state.open = false;
driver.render(&state, area, &items());
state.open = true;
driver.render(&state, area, &items());
assert!(
driver.row(1).contains("Mango"),
"a reopened panel anchors to the cursor: {}",
driver.row(1)
);
}
#[test]
fn custom_option_rows_preserve_explicit_span_colors() {
let mut terminal = Terminal::new(TestBackend::new(20, 6)).expect("terminal");
let mut ratcn = Ratcn::<State, Msg>::new();
let state = State {
open: true,
selected: Some(Fruit::Mango),
..State::default()
};
let theme = Theme::default_dark();
let mut style = SelectStyle::fallback();
style.option_foreground = Color::Yellow;
style.panel_background = Color::Blue;
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
"fruit",
select(items())
.render_item(|_: &State, row| {
if row.selected {
Text::from(Span::styled(
row.label.to_string(),
Style::new().fg(Color::Magenta).bg(Color::Green),
))
} else {
Text::from(row.label.to_string())
}
})
.style(move |_| style),
Rect::new(0, 0, 20, 1),
);
});
})
.expect("draw");
let buffer = terminal.backend().buffer();
let explicit = buffer.cell((1, 1)).expect("explicitly colored span");
assert_eq!(explicit.fg, Color::Magenta, "explicit colors win");
assert_eq!(explicit.bg, Color::Green);
let inherited = buffer.cell((1, 2)).expect("unstyled span");
assert_eq!(
inherited.fg,
Color::Yellow,
"unstyled text inherits the option-state colors"
);
assert_eq!(inherited.bg, Color::Blue);
}
#[test]
fn a_released_wheel_park_never_revives() {
let mut driver = Driver::new(20, 4);
let mut state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
let area = Rect::new(0, 0, 20, 1);
driver.render(&state, area, &items());
driver.event(
mouse(MouseKind::Scroll(ScrollDirection::Down), 3, 1),
&state,
);
driver.render(&state, area, &items());
assert!(driver.row(1).contains("Lychee"), "{}", driver.row(1));
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Down)), &state),
EventResult::Emit(Msg::Focused(Fruit::Papaya))
);
state.cursor = Some(Fruit::Papaya);
driver.render(&state, area, &items());
assert!(driver.row(1).contains("Papaya"), "{}", driver.row(1));
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Up)), &state),
EventResult::Emit(Msg::Focused(Fruit::Mango))
);
state.cursor = Some(Fruit::Mango);
driver.render(&state, area, &items());
assert!(
driver.row(1).contains("Mango"),
"returning to the wheel anchor must not re-park the view: {}",
driver.row(1)
);
}
#[test]
fn a_disabled_cursor_is_neither_retargeted_nor_committed() {
let mut items = items();
items[1] = ListItem::new(Fruit::Papaya, "Papaya").disabled(true);
let state = State {
open: true,
cursor: Some(Fruit::Papaya),
..State::default()
};
let mut component = select(items);
component.prepare(&state);
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Up)),
&state,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Focused(Fruit::Mango))
);
for code in [KeyCode::Enter, KeyCode::Char(' ')] {
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(code)),
&state,
&mut EventCtx::default(),
),
EventResult::Ignored,
"{code:?} must not commit a disabled option"
);
}
}
#[test]
fn an_all_disabled_select_ignores_keys_instead_of_opening() {
let items: Vec<ListItem<Fruit>> = items()
.into_iter()
.map(|item| {
let value = *item.value();
ListItem::new(value, item.label()).disabled(true)
})
.collect();
let closed = State::default();
let mut component = select(items);
component.prepare(&closed);
for code in [
KeyCode::Enter,
KeyCode::Char(' '),
KeyCode::Up,
KeyCode::Down,
] {
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(code)),
&closed,
&mut EventCtx::default(),
),
EventResult::Ignored,
"{code:?} must not open a Select with nothing to choose"
);
}
}
#[test]
fn clicking_without_a_selection_binding_moves_the_cursor() {
let mut driver = Driver::new(20, 6);
let state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
let theme = Theme::default_dark();
let area = Rect::new(0, 0, 20, 1);
driver
.terminal
.draw(|frame| {
driver.ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
"fruit",
Select::new(items())
.open(|state: &State| state.open, Msg::Open)
.item_focus(|state: &State| state.cursor, Msg::Focused),
area,
);
});
})
.expect("draw");
assert_eq!(
driver.event(mouse(MouseKind::Click(MouseButton::Left), 3, 2), &state),
EventResult::Emit(Msg::Focused(Fruit::Papaya))
);
}
#[test]
fn an_open_select_does_not_swallow_app_hotkeys() {
let state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
let mut component = select(items());
component.prepare(&state);
for code in [KeyCode::Delete, KeyCode::Backspace, KeyCode::F(5)] {
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(code)),
&state,
&mut EventCtx::default(),
),
EventResult::Ignored,
"{code:?} must bubble to the app"
);
}
}
#[test]
fn panel_pointer_hit_testing_uses_painted_viewport() {
let mut driver = Driver::new(20, 6);
let state = State {
open: true,
cursor: Some(Fruit::Durian),
..State::default()
};
driver.render(&state, Rect::new(0, 0, 20, 1), &items());
assert!(driver.row(2).contains("Papaya"), "{}", driver.row(2));
assert_eq!(
driver.event(mouse(MouseKind::Click(MouseButton::Left), 3, 2), &state),
EventResult::Emit(Msg::Selected(Fruit::Papaya))
);
}
#[test]
fn outside_press_uses_the_open_bindings_close_message() {
let mut driver = Driver::new(20, 8);
let state = State {
open: true,
..State::default()
};
driver.render(&state, Rect::new(0, 0, 20, 1), &items());
assert_eq!(
driver.event(mouse(MouseKind::Down(MouseButton::Left), 19, 7), &state),
EventResult::Emit(Msg::Open(false))
);
}
#[test]
fn clicking_the_first_option_selects_it_where_the_panel_overlays_the_trigger() {
let mut driver = Driver::new(20, 12);
let state = State {
open: true,
..State::default()
};
driver.render(&state, Rect::new(0, 4, 20, 1), &items());
assert_eq!(
driver.event(mouse(MouseKind::Down(MouseButton::Left), 3, 4), &state),
EventResult::Consumed,
"pressing the overlaid option must not dismiss the popup"
);
assert_eq!(
driver.event(mouse(MouseKind::Click(MouseButton::Left), 3, 4), &state),
EventResult::Emit(Msg::Selected(Fruit::Mango))
);
}
#[test]
fn closing_selection_cannot_commit_twice_before_redraw() {
let mut driver = Driver::new(20, 12);
let mut state = State {
open: true,
..State::default()
};
driver.render(&state, Rect::new(0, 4, 20, 1), &items());
let click = mouse(MouseKind::Click(MouseButton::Left), 3, 4);
assert_eq!(
driver.event(click.clone(), &state),
EventResult::Emit(Msg::Selected(Fruit::Mango))
);
state.open = false;
assert_eq!(
driver.event(click, &state),
EventResult::Consumed,
"the retained popup reads current open state before handling the second click"
);
}
#[test]
fn popup_layers_above_content_inside_a_modal() {
let mut terminal = Terminal::new(TestBackend::new(20, 8)).expect("terminal");
let mut ratcn = Ratcn::<State, Msg>::new();
let state = State {
open: true,
..State::default()
};
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.modal_scope("modal", frame_area(), ScopeOptions::default(), |ctx| {
ctx.render_component("fruit", select(items()), Rect::new(0, 0, 20, 1));
ctx.render_widget(Line::from("modal sibling"), Rect::new(0, 2, 20, 1));
});
});
})
.expect("draw");
let row: String = (0..20)
.map(|column| {
terminal
.backend()
.buffer()
.cell((column, 2))
.expect("cell")
.symbol()
})
.collect();
assert!(row.contains("Papaya"), "{row}");
assert!(!row.contains("modal sibling"));
}
#[test]
fn vim_and_readline_keys_move_item_focus_only_while_open() {
let open = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
let mut component = select(items());
component.prepare(&open);
for down in [
Event::Key(KeyEvent::new(KeyCode::Char('j'))),
Event::Key(KeyEvent {
code: KeyCode::Char('n'),
modifiers: Modifiers {
ctrl: true,
..Modifiers::NONE
},
}),
] {
assert_eq!(
component.handle_event(&down, &open, &mut EventCtx::default()),
component.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Down)),
&open,
&mut EventCtx::default(),
),
"j and Ctrl+N do exactly what Down does"
);
}
let closed = State {
cursor: Some(Fruit::Mango),
..State::default()
};
let mut component = select(items());
component.prepare(&closed);
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Char('j'))),
&closed,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Open(true)),
"a step key on a closed Select opens it, as Down does"
);
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Char('x'))),
&closed,
&mut EventCtx::default(),
),
EventResult::Ignored,
"any other letter bubbles: there is no typeahead"
);
}
#[test]
fn open_selection_without_item_focus_is_pointer_only() {
let state = State::default();
let mut component = Select::new(items())
.open(|state: &State| state.open, Msg::Open)
.selection(|state: &State| state.selected, Msg::Selected);
component.prepare(&state);
assert!(!component.is_focusable(&state));
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Down)),
&state,
&mut EventCtx::default(),
),
EventResult::Ignored
);
assert_eq!(
component.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 0, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Open(true))
);
}
#[test]
fn incomplete_binding_combinations_never_claim_keyboard_focus() {
let state = State::default();
let mut component = Select::new(items())
.open(|state: &State| state.open, Msg::Open)
.item_focus(|state: &State| state.cursor, Msg::Focused);
component.prepare(&state);
assert!(!component.is_focusable(&state));
assert_eq!(
component.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Down)),
&state,
&mut EventCtx::default(),
),
EventResult::Ignored,
"a Select without a selection commit channel must not consume keyboard navigation"
);
}
fn tall_select(items: Vec<ListItem<Fruit>>) -> Select<Fruit, State, Msg> {
select(items)
.render_item(|_state: &State, row| {
Text::from(vec![
Line::from(row.label.to_string()),
Line::from(format!(" focused={}", row.focused)),
])
})
.row_height(2)
}
#[test]
fn custom_multi_row_options_render_at_the_declared_row_height() {
let mut terminal = Terminal::new(TestBackend::new(20, 12)).expect("terminal");
let mut ratcn = Ratcn::<State, Msg>::new();
let state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component("fruit", tall_select(items()), Rect::new(0, 4, 20, 1));
});
})
.expect("draw");
let row = |y: u16| -> String {
let buffer = terminal.backend().buffer();
(0..20)
.map(|x| buffer.cell((x, y)).expect("cell").symbol())
.collect()
};
assert!(row(3).contains("Mango"), "{}", row(3));
assert!(row(4).contains("focused=true"), "{}", row(4));
assert!(row(5).contains("Papaya"), "{}", row(5));
assert!(row(6).contains("focused=false"), "{}", row(6));
}
#[test]
fn multi_row_click_hit_testing_divides_by_row_height() {
let mut terminal = Terminal::new(TestBackend::new(20, 12)).expect("terminal");
let mut ratcn = Ratcn::<State, Msg>::new();
let state = State {
open: true,
cursor: Some(Fruit::Mango),
..State::default()
};
let theme = Theme::default_dark();
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component("fruit", tall_select(items()), Rect::new(0, 4, 20, 1));
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Click(MouseButton::Left), 3, 4), &state),
EventResult::Emit(Msg::Selected(Fruit::Mango)),
"the second line belongs to the first option"
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Click(MouseButton::Left), 3, 5), &state),
EventResult::Emit(Msg::Selected(Fruit::Papaya))
);
}
#[test]
fn disabled_and_duplicate_item_behavior_is_preserved() {
let state = State::default();
let mut disabled = select(items()).disabled(true);
disabled.prepare(&state);
assert!(!disabled.is_focusable(&state));
assert_eq!(
disabled.handle_event(
&Event::Key(KeyEvent::new(KeyCode::Enter)),
&state,
&mut EventCtx::default()
),
EventResult::Ignored
);
let mut duplicate = select(vec![
ListItem::new(Fruit::Mango, "Mango"),
ListItem::new(Fruit::Mango, "Again"),
]);
assert!(
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| duplicate.prepare(&state)))
.is_err()
);
}
}