#![forbid(unsafe_code)]
use crate::block::Block;
use crate::measurable::{MeasurableWidget, SizeConstraints};
use crate::mouse::MouseResult;
use crate::stateful::{StateKey, Stateful};
use crate::undo_support::{ListUndoExt, UndoSupport, UndoWidgetId};
use crate::{
StatefulWidget, Widget, clear_text_area, clear_text_row, draw_text_span,
draw_text_span_with_link,
};
use ftui_core::event::{KeyCode, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
use ftui_core::geometry::{Rect, Size};
use ftui_render::frame::{Frame, HitId, HitRegion};
use ftui_style::Style;
use ftui_text::{Line, Span, Text as FtuiText, display_width};
use std::collections::BTreeSet;
#[cfg(feature = "tracing")]
use web_time::Instant;
type Text = FtuiText<'static>;
fn text_into_owned(text: FtuiText<'_>) -> FtuiText<'static> {
FtuiText::from_lines(
text.into_iter()
.map(|line| Line::from_spans(line.into_iter().map(Span::into_owned))),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListItem<'a> {
content: Text,
style: Style,
marker: &'a str,
}
impl<'a> ListItem<'a> {
#[must_use]
pub fn new<'t>(content: impl Into<FtuiText<'t>>) -> Self {
Self {
content: text_into_owned(content.into()),
style: Style::default(),
marker: "",
}
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn marker(mut self, marker: &'a str) -> Self {
self.marker = marker;
self
}
}
impl<'a> From<&'a str> for ListItem<'a> {
fn from(s: &'a str) -> Self {
Self::new(s)
}
}
#[derive(Debug, Clone, Default)]
pub struct List<'a> {
block: Option<Block<'a>>,
items: Vec<ListItem<'a>>,
style: Style,
highlight_style: Style,
hover_style: Style,
highlight_symbol: Option<&'a str>,
hit_id: Option<HitId>,
data_hash: Option<u64>,
}
impl<'a> List<'a> {
#[must_use]
pub fn new(items: impl IntoIterator<Item = impl Into<ListItem<'a>>>) -> Self {
Self {
block: None,
items: items.into_iter().map(|i| i.into()).collect(),
style: Style::default(),
highlight_style: Style::default(),
hover_style: Style::default(),
highlight_symbol: None,
hit_id: None,
data_hash: None,
}
}
#[must_use]
pub fn data_hash(mut self, hash: u64) -> Self {
self.data_hash = Some(hash);
self
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn highlight_style(mut self, style: Style) -> Self {
self.highlight_style = style;
self
}
#[must_use]
pub fn hover_style(mut self, style: Style) -> Self {
self.hover_style = style;
self
}
#[must_use]
pub fn highlight_symbol(mut self, symbol: &'a str) -> Self {
self.highlight_symbol = Some(symbol);
self
}
#[must_use]
pub fn hit_id(mut self, id: HitId) -> Self {
self.hit_id = Some(id);
self
}
fn filtered_indices(&self, state: &mut ListState) -> std::sync::Arc<[usize]> {
let query_str = state.filter_query();
if let Some(hash) = self.data_hash
&& let Some((cached_hash, ref cached_query, ref indices)) = state.cached_display_indices
&& cached_hash == hash
&& cached_query == query_str
{
return std::sync::Arc::clone(indices);
}
let query = query_str.trim();
let indices: Vec<usize> = if query.is_empty() {
(0..self.items.len()).collect()
} else {
let query_lower = query.to_lowercase();
self.items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
let line_text_cow;
let line_text_ref = if let Some(line) = item.content.lines().first() {
if line.spans().len() == 1 {
&line.spans()[0].content
} else {
line_text_cow = std::borrow::Cow::Owned(line.to_plain_text());
&line_text_cow
}
} else {
""
};
let marker_matches = !item.marker.is_empty()
&& crate::contains_ignore_case(item.marker, &query_lower);
if marker_matches || crate::contains_ignore_case(line_text_ref, &query_lower) {
Some(idx)
} else {
None
}
})
.collect()
};
let arc_indices: std::sync::Arc<[usize]> = indices.into();
if let Some(hash) = self.data_hash {
state.cached_display_indices = Some((
hash,
query_str.to_string(),
std::sync::Arc::clone(&arc_indices),
));
}
arc_indices
}
fn apply_filtered_selection_guard(
&self,
state: &mut ListState,
filtered: &[usize],
force_select_first: bool,
) {
if filtered.is_empty() {
state.selected = None;
state.hovered = None;
state.offset = 0;
state.multi_selected.clear();
return;
}
if let Some(selected) = state.selected {
if filtered.binary_search(&selected).is_err() {
state.selected = filtered.first().copied();
}
} else if force_select_first {
state.selected = filtered.first().copied();
}
state
.multi_selected
.retain(|idx| filtered.binary_search(idx).is_ok());
}
fn move_selection_in_filtered(
&self,
state: &mut ListState,
filtered: &[usize],
direction: isize,
) -> bool {
if filtered.is_empty() {
if state.selected.is_some() {
state.select(None);
return true;
}
return false;
}
let max_pos = filtered.len().saturating_sub(1) as isize;
let next_pos = if let Some(selected) = state.selected {
let current_pos = filtered.binary_search(&selected).unwrap_or_else(|pos| pos);
(current_pos as isize + direction).clamp(0, max_pos) as usize
} else if direction > 0 {
0
} else {
max_pos as usize
};
let next_index = filtered[next_pos];
if state.selected == Some(next_index) {
return false;
}
state.selected = Some(next_index);
if !state.multi_select_enabled {
state.multi_selected.clear();
state.multi_selected.insert(next_index);
}
state.scroll_into_view_requested = true;
#[cfg(feature = "tracing")]
state.log_selection_change("keyboard_move");
true
}
pub fn handle_key(&self, state: &mut ListState, key: &KeyEvent) -> bool {
let nav_modifiers = key
.modifiers
.intersects(Modifiers::CTRL | Modifiers::ALT | Modifiers::SUPER);
match key.code {
KeyCode::Up if !nav_modifiers => {
let filtered = self.filtered_indices(state);
self.move_selection_in_filtered(state, &filtered, -1)
}
KeyCode::Down if !nav_modifiers => {
let filtered = self.filtered_indices(state);
self.move_selection_in_filtered(state, &filtered, 1)
}
KeyCode::Char('k') if !nav_modifiers => {
let filtered = self.filtered_indices(state);
self.move_selection_in_filtered(state, &filtered, -1)
}
KeyCode::Char('j') if !nav_modifiers => {
let filtered = self.filtered_indices(state);
self.move_selection_in_filtered(state, &filtered, 1)
}
KeyCode::Char(' ') if state.multi_select_enabled() => {
if let Some(selected) = state.selected {
state.toggle_multi_selected(selected);
true
} else {
false
}
}
KeyCode::Backspace => {
if state.filter_query.is_empty() {
return false;
}
state.filter_query.pop();
state.offset = 0;
state.scroll_into_view_requested = true;
let filtered = self.filtered_indices(state);
self.apply_filtered_selection_guard(state, &filtered, true);
#[cfg(feature = "tracing")]
state.log_selection_change("filter_backspace");
true
}
KeyCode::Escape => {
if state.filter_query.is_empty() {
return false;
}
state.filter_query.clear();
state.offset = 0;
state.scroll_into_view_requested = true;
let filtered = self.filtered_indices(state);
self.apply_filtered_selection_guard(state, &filtered, false);
#[cfg(feature = "tracing")]
state.log_selection_change("filter_clear");
true
}
KeyCode::Char(ch)
if !ch.is_control() && !key.ctrl() && !key.alt() && !key.super_key() =>
{
state.filter_query.push(ch);
state.offset = 0;
state.scroll_into_view_requested = true;
let filtered = self.filtered_indices(state);
self.apply_filtered_selection_guard(state, &filtered, true);
#[cfg(feature = "tracing")]
state.log_selection_change("filter_append");
true
}
_ => false,
}
}
}
#[derive(Debug, Clone)]
pub struct ListState {
undo_id: UndoWidgetId,
pub selected: Option<usize>,
pub hovered: Option<usize>,
pub offset: usize,
persistence_id: Option<String>,
scroll_into_view_requested: bool,
filter_query: String,
multi_select_enabled: bool,
multi_selected: BTreeSet<usize>,
#[doc(hidden)]
pub cached_display_indices: Option<(u64, String, std::sync::Arc<[usize]>)>,
}
impl Default for ListState {
fn default() -> Self {
Self {
undo_id: UndoWidgetId::default(),
selected: None,
hovered: None,
offset: 0,
persistence_id: None,
scroll_into_view_requested: true,
filter_query: String::new(),
multi_select_enabled: false,
multi_selected: BTreeSet::new(),
cached_display_indices: None,
}
}
}
impl ListState {
pub fn select(&mut self, index: Option<usize>) {
self.selected = index;
if index.is_none() {
self.offset = 0;
self.multi_selected.clear();
} else if !self.multi_select_enabled
&& let Some(selected) = index
{
self.multi_selected.clear();
self.multi_selected.insert(selected);
}
self.scroll_into_view_requested = true;
#[cfg(feature = "tracing")]
self.log_selection_change("select");
}
#[inline]
#[must_use = "use the selected index (if any)"]
pub fn selected(&self) -> Option<usize> {
self.selected
}
#[must_use]
pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
self.persistence_id = Some(id.into());
self
}
#[inline]
#[must_use = "use the persistence id (if any)"]
pub fn persistence_id(&self) -> Option<&str> {
self.persistence_id.as_deref()
}
pub fn set_multi_select(&mut self, enabled: bool) {
if self.multi_select_enabled == enabled {
return;
}
self.multi_select_enabled = enabled;
if !enabled {
self.multi_selected.clear();
if let Some(selected) = self.selected {
self.multi_selected.insert(selected);
}
}
}
#[must_use]
pub const fn multi_select_enabled(&self) -> bool {
self.multi_select_enabled
}
#[must_use]
pub fn filter_query(&self) -> &str {
&self.filter_query
}
pub fn set_filter_query(&mut self, query: impl Into<String>) {
self.filter_query = query.into();
self.offset = 0;
self.scroll_into_view_requested = true;
}
pub fn clear_filter_query(&mut self) {
if !self.filter_query.is_empty() {
self.filter_query.clear();
self.offset = 0;
self.scroll_into_view_requested = true;
}
}
#[must_use]
pub fn selected_count(&self) -> usize {
if self.multi_select_enabled {
self.multi_selected.len()
} else {
usize::from(self.selected.is_some())
}
}
#[must_use]
pub fn selected_indices(&self) -> &BTreeSet<usize> {
&self.multi_selected
}
fn toggle_multi_selected(&mut self, index: usize) {
if !self.multi_select_enabled {
self.select(Some(index));
return;
}
if !self.multi_selected.insert(index) {
self.multi_selected.remove(&index);
}
self.selected = Some(index);
self.scroll_into_view_requested = true;
#[cfg(feature = "tracing")]
self.log_selection_change("toggle_multi");
}
#[cfg(feature = "tracing")]
fn log_selection_change(&self, action: &str) {
tracing::debug!(
message = "list.selection",
action,
selected = self.selected,
selected_count = self.selected_count(),
filter_active = !self.filter_query.trim().is_empty()
);
}
pub fn handle_mouse(
&mut self,
event: &MouseEvent,
hit: Option<(HitId, HitRegion, u64)>,
expected_id: HitId,
item_count: usize,
) -> MouseResult {
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some((id, HitRegion::Content, data)) = hit
&& id == expected_id
{
let index = data as usize;
if index < item_count {
if self.multi_select_enabled && event.modifiers.contains(Modifiers::CTRL) {
self.toggle_multi_selected(index);
return MouseResult::Selected(index);
}
if self.multi_select_enabled {
self.multi_selected.clear();
self.multi_selected.insert(index);
}
if !self.multi_select_enabled && self.selected == Some(index) {
#[cfg(feature = "tracing")]
self.log_selection_change("activate");
return MouseResult::Activated(index);
}
self.select(Some(index));
return MouseResult::Selected(index);
}
}
MouseResult::Ignored
}
MouseEventKind::Moved => {
if let Some((id, HitRegion::Content, data)) = hit
&& id == expected_id
{
let index = data as usize;
if index < item_count {
let changed = self.hovered != Some(index);
self.hovered = Some(index);
return if changed {
MouseResult::HoverChanged
} else {
MouseResult::Ignored
};
}
}
if self.hovered.is_some() {
self.hovered = None;
MouseResult::HoverChanged
} else {
MouseResult::Ignored
}
}
MouseEventKind::ScrollUp => {
self.scroll_up(3);
MouseResult::Scrolled
}
MouseEventKind::ScrollDown => {
self.scroll_down(3, item_count);
MouseResult::Scrolled
}
_ => MouseResult::Ignored,
}
}
pub fn scroll_up(&mut self, lines: usize) {
self.offset = self.offset.saturating_sub(lines);
}
pub fn scroll_down(&mut self, lines: usize, item_count: usize) {
self.offset = self
.offset
.saturating_add(lines)
.min(item_count.saturating_sub(1));
}
pub fn select_next(&mut self, item_count: usize) {
if item_count == 0 {
return;
}
let next = match self.selected {
Some(i) => (i + 1).min(item_count.saturating_sub(1)),
None => 0,
};
self.selected = Some(next);
if !self.multi_select_enabled {
self.multi_selected.clear();
self.multi_selected.insert(next);
}
self.scroll_into_view_requested = true;
#[cfg(feature = "tracing")]
self.log_selection_change("select_next");
}
pub fn select_previous(&mut self) {
let prev = match self.selected {
Some(i) => i.saturating_sub(1),
None => 0,
};
self.selected = Some(prev);
if !self.multi_select_enabled {
self.multi_selected.clear();
self.multi_selected.insert(prev);
}
self.scroll_into_view_requested = true;
#[cfg(feature = "tracing")]
self.log_selection_change("select_previous");
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "state-persistence",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ListPersistState {
pub selected: Option<usize>,
pub offset: usize,
pub filter_query: String,
pub multi_select_enabled: bool,
pub multi_selected: Vec<usize>,
}
impl Stateful for ListState {
type State = ListPersistState;
fn state_key(&self) -> StateKey {
StateKey::new("List", self.persistence_id.as_deref().unwrap_or("default"))
}
fn save_state(&self) -> ListPersistState {
ListPersistState {
selected: self.selected,
offset: self.offset,
filter_query: self.filter_query.clone(),
multi_select_enabled: self.multi_select_enabled,
multi_selected: self.multi_selected.iter().copied().collect(),
}
}
fn restore_state(&mut self, state: ListPersistState) {
self.selected = state.selected;
self.hovered = None;
self.offset = state.offset;
self.filter_query = state.filter_query;
self.multi_select_enabled = state.multi_select_enabled;
self.multi_selected = state.multi_selected.into_iter().collect();
}
}
impl<'a> StatefulWidget for List<'a> {
type State = ListState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
#[cfg(feature = "tracing")]
let _widget_span = tracing::debug_span!(
"widget_render",
widget = "List",
x = area.x,
y = area.y,
w = area.width,
h = area.height
)
.entered();
#[cfg(feature = "tracing")]
let render_start = Instant::now();
#[cfg(feature = "tracing")]
let total_items = self.items.len();
let filter_active = !state.filter_query.trim().is_empty();
#[cfg(feature = "tracing")]
let selected_count = state.selected_count();
#[cfg(feature = "tracing")]
let render_span = tracing::debug_span!(
"list.render",
total_items,
visible_items = tracing::field::Empty,
selected_count,
filter_active,
render_duration_us = tracing::field::Empty
);
#[cfg(feature = "tracing")]
let _render_guard = render_span.enter();
let list_area = match &self.block {
Some(b) => {
b.render(area, frame);
b.inner(area)
}
None => area,
};
let mut rendered_visible_items = 0usize;
if !list_area.is_empty() {
clear_text_area(frame, list_area, self.style);
if self.items.is_empty() {
state.selected = None;
state.hovered = None;
state.offset = 0;
state.multi_selected.clear();
draw_text_span(
frame,
list_area.x,
list_area.y,
"No items",
self.style,
list_area.right(),
);
} else {
if let Some(selected) = state.selected
&& selected >= self.items.len()
{
state.selected = Some(self.items.len().saturating_sub(1));
}
if let Some(hovered) = state.hovered
&& hovered >= self.items.len()
{
state.hovered = None;
}
let filtered_indices = self.filtered_indices(state);
self.apply_filtered_selection_guard(state, &filtered_indices, filter_active);
if filtered_indices.is_empty() {
draw_text_span(
frame,
list_area.x,
list_area.y,
"No matches",
self.style,
list_area.right(),
);
} else {
let list_height = list_area.height as usize;
let max_offset = filtered_indices.len().saturating_sub(list_height.max(1));
state.offset = state.offset.min(max_offset);
if let Some(hovered) = state.hovered
&& filtered_indices.binary_search(&hovered).is_err()
{
state.hovered = None;
}
if state.scroll_into_view_requested {
if let Some(selected) = state.selected
&& let Some(selected_pos) =
filtered_indices.binary_search(&selected).ok()
{
if selected_pos >= state.offset + list_height {
state.offset = selected_pos - list_height + 1;
} else if selected_pos < state.offset {
state.offset = selected_pos;
}
}
state.scroll_into_view_requested = false;
}
for (row, item_index) in filtered_indices
.iter()
.skip(state.offset)
.take(list_height)
.enumerate()
{
let i = *item_index;
let item = &self.items[i];
let y = list_area.y.saturating_add(row as u16);
if y >= list_area.bottom() {
break;
}
let is_selected = state.selected == Some(i)
|| (state.multi_select_enabled && state.multi_selected.contains(&i));
let is_hovered = state.hovered == Some(i);
let mut item_style = if is_hovered {
self.hover_style.merge(&item.style)
} else {
item.style
};
if is_selected {
item_style = self.highlight_style.merge(&item_style);
}
let row_area = Rect::new(list_area.x, y, list_area.width, 1);
clear_text_row(frame, row_area, item_style);
let symbol = if is_selected {
self.highlight_symbol.unwrap_or(item.marker)
} else {
item.marker
};
let mut x = list_area.x;
if !symbol.is_empty() {
x = draw_text_span(frame, x, y, symbol, item_style, list_area.right());
x = draw_text_span(frame, x, y, " ", item_style, list_area.right());
}
if let Some(line) = item.content.lines().first() {
for span in line.spans() {
let span_style = match span.style {
Some(s) => s.merge(&item_style),
None => item_style,
};
x = draw_text_span_with_link(
frame,
x,
y,
&span.content,
span_style,
list_area.right(),
span.link.as_deref(),
);
if x >= list_area.right() {
break;
}
}
}
if let Some(id) = self.hit_id {
frame.register_hit(row_area, id, HitRegion::Content, i as u64);
}
rendered_visible_items = rendered_visible_items.saturating_add(1);
}
if filtered_indices.len() > list_height && list_area.width > 0 {
let indicator_x = list_area.right().saturating_sub(1);
if state.offset > 0 {
draw_text_span(
frame,
indicator_x,
list_area.y,
"↑",
self.style,
list_area.right(),
);
}
if state.offset + list_height < filtered_indices.len() {
draw_text_span(
frame,
indicator_x,
list_area.bottom().saturating_sub(1),
"↓",
self.style,
list_area.right(),
);
}
}
}
}
}
#[cfg(feature = "tracing")]
{
let elapsed_us = render_start.elapsed().as_micros() as u64;
render_span.record("visible_items", rendered_visible_items);
render_span.record("render_duration_us", elapsed_us);
tracing::debug!(
message = "list.metrics",
total_items,
visible_items = rendered_visible_items,
selected_count = state.selected_count(),
filter_active,
list_render_duration_us = elapsed_us
);
}
}
}
impl<'a> Widget for List<'a> {
fn render(&self, area: Rect, frame: &mut Frame) {
let mut state = ListState::default();
StatefulWidget::render(self, area, frame, &mut state);
}
}
impl ftui_a11y::Accessible for List<'_> {
fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
use ftui_a11y::node::{A11yNodeInfo, A11yRole};
let base_id = crate::a11y_node_id(area);
let item_count = self.items.len();
let child_ids: Vec<u64> = (0..item_count).map(|i| base_id + 1 + i as u64).collect();
let title = self
.block
.as_ref()
.and_then(|b| b.title_text())
.unwrap_or_default();
let mut list_node =
A11yNodeInfo::new(base_id, A11yRole::List, area).with_children(child_ids);
if !title.is_empty() {
list_node = list_node.with_name(title);
}
list_node = list_node.with_description(format!("{item_count} items"));
let mut nodes = vec![list_node];
for (i, item) in self.items.iter().enumerate() {
let item_id = base_id + 1 + i as u64;
let item_text = item
.content
.lines()
.first()
.map(|line| line.to_plain_text())
.unwrap_or_default();
let item_node =
A11yNodeInfo::new(item_id, A11yRole::ListItem, area).with_parent(base_id);
let item_node = if item_text.is_empty() {
item_node
} else {
item_node.with_name(item_text)
};
nodes.push(item_node);
}
nodes
}
}
impl MeasurableWidget for ListItem<'_> {
fn measure(&self, _available: Size) -> SizeConstraints {
let marker_width = display_width(self.marker) as u16;
let space_after_marker = if self.marker.is_empty() { 0u16 } else { 1 };
let text_width = self
.content
.lines()
.first()
.map(|line| line.width())
.unwrap_or(0)
.min(u16::MAX as usize) as u16;
let total_width = marker_width
.saturating_add(space_after_marker)
.saturating_add(text_width);
SizeConstraints::exact(Size::new(total_width, 1))
}
fn has_intrinsic_size(&self) -> bool {
true
}
}
impl MeasurableWidget for List<'_> {
fn measure(&self, available: Size) -> SizeConstraints {
let (chrome_width, chrome_height) = self
.block
.as_ref()
.map(|b| b.chrome_size())
.unwrap_or((0, 0));
if self.items.is_empty() {
return SizeConstraints {
min: Size::new(chrome_width, chrome_height),
preferred: Size::new(chrome_width, chrome_height),
max: None,
};
}
let inner_available = Size::new(
available.width.saturating_sub(chrome_width),
available.height.saturating_sub(chrome_height),
);
let mut max_width: u16 = 0;
let mut total_height: u16 = 0;
for item in &self.items {
let item_constraints = item.measure(inner_available);
max_width = max_width.max(item_constraints.preferred.width);
total_height = total_height.saturating_add(item_constraints.preferred.height);
}
if let Some(symbol) = self.highlight_symbol {
let symbol_width = display_width(symbol) as u16 + 1; max_width = max_width.saturating_add(symbol_width);
}
let preferred_width = max_width.saturating_add(chrome_width);
let preferred_height = total_height.saturating_add(chrome_height);
let min_height = chrome_height.saturating_add(1.min(total_height));
SizeConstraints {
min: Size::new(chrome_width, min_height),
preferred: Size::new(preferred_width, preferred_height),
max: None, }
}
fn has_intrinsic_size(&self) -> bool {
!self.items.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct ListStateSnapshot {
selected: Option<usize>,
offset: usize,
filter_query: String,
multi_select_enabled: bool,
multi_selected: Vec<usize>,
}
impl UndoSupport for ListState {
fn undo_widget_id(&self) -> UndoWidgetId {
self.undo_id
}
fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
Box::new(ListStateSnapshot {
selected: self.selected,
offset: self.offset,
filter_query: self.filter_query.clone(),
multi_select_enabled: self.multi_select_enabled,
multi_selected: self.multi_selected.iter().copied().collect(),
})
}
fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
if let Some(snap) = snapshot.downcast_ref::<ListStateSnapshot>() {
self.selected = snap.selected;
self.hovered = None;
self.offset = snap.offset;
self.filter_query = snap.filter_query.clone();
self.multi_select_enabled = snap.multi_select_enabled;
self.multi_selected = snap.multi_selected.iter().copied().collect();
true
} else {
false
}
}
}
impl ListUndoExt for ListState {
fn selected_index(&self) -> Option<usize> {
self.selected
}
fn set_selected_index(&mut self, index: Option<usize>) {
self.selected = index;
if index.is_none() {
self.offset = 0;
self.multi_selected.clear();
} else if !self.multi_select_enabled
&& let Some(selected) = index
{
self.multi_selected.clear();
self.multi_selected.insert(selected);
}
}
}
impl ListState {
#[must_use]
pub fn undo_id(&self) -> UndoWidgetId {
self.undo_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_core::event::{KeyCode, KeyEvent};
use ftui_render::cell::Cell;
use ftui_render::grapheme_pool::GraphemePool;
#[cfg(feature = "tracing")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "tracing")]
use tracing::Subscriber;
#[cfg(feature = "tracing")]
use tracing_subscriber::Layer;
#[cfg(feature = "tracing")]
use tracing_subscriber::layer::{Context, SubscriberExt};
fn row_text(frame: &Frame, y: u16) -> String {
let width = frame.buffer.width();
let mut actual = String::new();
for x in 0..width {
let ch = frame
.buffer
.get(x, y)
.and_then(|cell| cell.content.as_char())
.unwrap_or(' ');
actual.push(ch);
}
actual.trim().to_string()
}
fn raw_row_text(frame: &Frame, y: u16) -> String {
let width = frame.buffer.width();
let mut actual = String::new();
for x in 0..width {
let ch = frame
.buffer
.get(x, y)
.and_then(|cell| cell.content.as_char())
.unwrap_or(' ');
actual.push(ch);
}
actual
}
#[cfg(feature = "tracing")]
#[derive(Debug, Default)]
struct ListTraceState {
list_render_seen: bool,
has_total_items_field: bool,
has_visible_items_field: bool,
has_selected_count_field: bool,
has_filter_active_field: bool,
render_duration_recorded: bool,
selection_events: usize,
}
#[cfg(feature = "tracing")]
struct ListTraceCapture {
state: Arc<Mutex<ListTraceState>>,
}
#[cfg(feature = "tracing")]
impl<S> Layer<S> for ListTraceCapture
where
S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::Id,
_ctx: Context<'_, S>,
) {
if attrs.metadata().name() != "list.render" {
return;
}
let fields = attrs.metadata().fields();
let mut state = self.state.lock().expect("list trace state lock");
state.list_render_seen = true;
state.has_total_items_field |= fields.field("total_items").is_some();
state.has_visible_items_field |= fields.field("visible_items").is_some();
state.has_selected_count_field |= fields.field("selected_count").is_some();
state.has_filter_active_field |= fields.field("filter_active").is_some();
}
fn on_record(
&self,
id: &tracing::Id,
values: &tracing::span::Record<'_>,
ctx: Context<'_, S>,
) {
let Some(span) = ctx.span(id) else {
return;
};
if span.metadata().name() != "list.render" {
return;
}
struct DurationVisitor {
saw_duration: bool,
}
impl tracing::field::Visit for DurationVisitor {
fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
if field.name() == "render_duration_us" {
self.saw_duration = true;
}
}
fn record_debug(
&mut self,
field: &tracing::field::Field,
_value: &dyn std::fmt::Debug,
) {
if field.name() == "render_duration_us" {
self.saw_duration = true;
}
}
}
let mut visitor = DurationVisitor {
saw_duration: false,
};
values.record(&mut visitor);
if visitor.saw_duration {
self.state
.lock()
.expect("list trace state lock")
.render_duration_recorded = true;
}
}
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
struct MessageVisitor {
message: Option<String>,
}
impl tracing::field::Visit for MessageVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.message = Some(value.to_owned());
}
}
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
if field.name() == "message" {
self.message = Some(format!("{value:?}").trim_matches('"').to_owned());
}
}
}
let mut visitor = MessageVisitor { message: None };
event.record(&mut visitor);
if visitor.message.as_deref() == Some("list.selection") {
let mut state = self.state.lock().expect("list trace state lock");
state.selection_events = state.selection_events.saturating_add(1);
}
}
}
#[test]
fn render_empty_list() {
let list = List::new(Vec::<ListItem>::new());
let area = Rect::new(0, 0, 10, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
Widget::render(&list, area, &mut frame);
}
#[test]
fn render_simple_list() {
let items = vec![
ListItem::new("Item A"),
ListItem::new("Item B"),
ListItem::new("Item C"),
];
let list = List::new(items);
let area = Rect::new(0, 0, 10, 3);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 3, &mut pool);
let mut state = ListState::default();
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('I'));
assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('A'));
assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('B'));
assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('C'));
}
#[test]
fn list_state_select() {
let mut state = ListState::default();
assert_eq!(state.selected(), None);
state.select(Some(2));
assert_eq!(state.selected(), Some(2));
state.select(None);
assert_eq!(state.selected(), None);
assert_eq!(state.offset, 0);
}
#[test]
fn list_scrolls_to_selected() {
let items: Vec<ListItem> = (0..10)
.map(|i| ListItem::new(format!("Item {i}")))
.collect();
let list = List::new(items);
let area = Rect::new(0, 0, 10, 3);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 3, &mut pool);
let mut state = ListState::default();
state.select(Some(5));
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert!(state.offset <= 5);
assert!(state.offset + 3 > 5);
}
#[test]
fn list_clamps_selection() {
let items = vec![ListItem::new("A"), ListItem::new("B")];
let list = List::new(items);
let area = Rect::new(0, 0, 10, 3);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 3, &mut pool);
let mut state = ListState::default();
state.select(Some(10));
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert_eq!(state.selected(), Some(1));
}
#[test]
fn render_list_with_highlight_symbol() {
let items = vec![ListItem::new("A"), ListItem::new("B")];
let list = List::new(items).highlight_symbol(">");
let area = Rect::new(0, 0, 10, 2);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 2, &mut pool);
let mut state = ListState::default();
state.select(Some(0));
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('>'));
}
#[test]
fn render_zero_area() {
let list = List::new(vec![ListItem::new("A")]);
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
let mut state = ListState::default();
StatefulWidget::render(&list, area, &mut frame, &mut state);
}
#[test]
fn list_item_from_str() {
let item: ListItem = "hello".into();
assert_eq!(
item.content.lines().first().unwrap().to_plain_text(),
"hello"
);
assert_eq!(item.marker, "");
}
#[test]
fn list_item_with_marker() {
let items = vec![
ListItem::new("A").marker("•"),
ListItem::new("B").marker("•"),
];
let list = List::new(items);
let area = Rect::new(0, 0, 10, 2);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 2, &mut pool);
let mut state = ListState::default();
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('•'));
assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('•'));
}
#[test]
fn list_state_deselect_resets_offset() {
let mut state = ListState {
offset: 5,
..Default::default()
};
state.select(Some(10));
assert_eq!(state.offset, 5);
state.select(None);
assert_eq!(state.offset, 0); }
#[test]
fn list_scrolls_up_when_selection_above_viewport() {
let items: Vec<ListItem> = (0..10)
.map(|i| ListItem::new(format!("Item {i}")))
.collect();
let list = List::new(items);
let area = Rect::new(0, 0, 10, 3);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 3, &mut pool);
let mut state = ListState::default();
state.select(Some(8));
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert!(state.offset > 0);
state.select(Some(0));
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert_eq!(state.offset, 0);
}
#[test]
fn list_clamps_offset_to_fill_viewport_on_resize() {
let items: Vec<ListItem> = (0..10)
.map(|i| ListItem::new(format!("Item {i}")))
.collect();
let list = List::new(items);
let mut pool = GraphemePool::new();
let mut state = ListState {
offset: 7,
..Default::default()
};
let area_small = Rect::new(0, 0, 10, 3);
let mut frame_small = Frame::new(10, 3, &mut pool);
StatefulWidget::render(&list, area_small, &mut frame_small, &mut state);
assert_eq!(state.offset, 7);
assert!(row_text(&frame_small, 0).starts_with("Item 7"));
assert!(row_text(&frame_small, 2).starts_with("Item 9"));
let area_large = Rect::new(0, 0, 10, 5);
let mut frame_large = Frame::new(10, 5, &mut pool);
StatefulWidget::render(&list, area_large, &mut frame_large, &mut state);
assert_eq!(state.offset, 5);
assert!(row_text(&frame_large, 0).starts_with("Item 5"));
assert!(row_text(&frame_large, 4).starts_with("Item 9"));
}
#[test]
fn render_list_more_items_than_viewport() {
let items: Vec<ListItem> = (0..20).map(|i| ListItem::new(format!("{i}"))).collect();
let list = List::new(items);
let area = Rect::new(0, 0, 5, 3);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 3, &mut pool);
let mut state = ListState::default();
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('1'));
assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('2'));
}
#[test]
fn widget_render_uses_default_state() {
let items = vec![ListItem::new("X")];
let list = List::new(items);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
Widget::render(&list, area, &mut frame);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
}
#[test]
fn list_registers_hit_regions() {
let items = vec![ListItem::new("A"), ListItem::new("B"), ListItem::new("C")];
let list = List::new(items).hit_id(HitId::new(42));
let area = Rect::new(0, 0, 10, 3);
let mut pool = GraphemePool::new();
let mut frame = Frame::with_hit_grid(10, 3, &mut pool);
let mut state = ListState::default();
StatefulWidget::render(&list, area, &mut frame, &mut state);
let hit0 = frame.hit_test(5, 0);
let hit1 = frame.hit_test(5, 1);
let hit2 = frame.hit_test(5, 2);
assert_eq!(hit0, Some((HitId::new(42), HitRegion::Content, 0)));
assert_eq!(hit1, Some((HitId::new(42), HitRegion::Content, 1)));
assert_eq!(hit2, Some((HitId::new(42), HitRegion::Content, 2)));
}
#[test]
fn list_no_hit_without_hit_id() {
let items = vec![ListItem::new("A")];
let list = List::new(items); let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::with_hit_grid(10, 1, &mut pool);
let mut state = ListState::default();
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert!(frame.hit_test(5, 0).is_none());
}
#[test]
fn list_no_hit_without_hit_grid() {
let items = vec![ListItem::new("A")];
let list = List::new(items).hit_id(HitId::new(1));
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool); let mut state = ListState::default();
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert!(frame.hit_test(5, 0).is_none());
}
use crate::MeasurableWidget;
use ftui_core::geometry::Size;
#[test]
fn list_item_measure_simple() {
let item = ListItem::new("Hello"); let constraints = item.measure(Size::MAX);
assert_eq!(constraints.preferred, Size::new(5, 1));
assert_eq!(constraints.min, Size::new(5, 1));
assert_eq!(constraints.max, Some(Size::new(5, 1)));
}
#[test]
fn list_item_measure_with_marker() {
let item = ListItem::new("Hi").marker("•"); let constraints = item.measure(Size::MAX);
assert_eq!(constraints.preferred.width, 4);
assert_eq!(constraints.preferred.height, 1);
}
#[test]
fn list_item_has_intrinsic_size() {
let item = ListItem::new("test");
assert!(item.has_intrinsic_size());
}
#[test]
fn list_measure_empty() {
let list = List::new(Vec::<ListItem>::new());
let constraints = list.measure(Size::MAX);
assert_eq!(constraints.preferred, Size::new(0, 0));
assert!(!list.has_intrinsic_size());
}
#[test]
fn list_measure_single_item() {
let items = vec![ListItem::new("Hello")]; let list = List::new(items);
let constraints = list.measure(Size::MAX);
assert_eq!(constraints.preferred, Size::new(5, 1));
assert_eq!(constraints.min.height, 1);
}
#[test]
fn list_measure_multiple_items() {
let items = vec![
ListItem::new("Short"), ListItem::new("LongerItem"), ListItem::new("Tiny"), ];
let list = List::new(items);
let constraints = list.measure(Size::MAX);
assert_eq!(constraints.preferred.width, 10);
assert_eq!(constraints.preferred.height, 3);
}
#[test]
fn list_measure_with_block() {
let block = crate::block::Block::bordered(); let items = vec![ListItem::new("Hi")]; let list = List::new(items).block(block);
let constraints = list.measure(Size::MAX);
assert_eq!(constraints.preferred, Size::new(6, 5));
}
#[test]
fn list_measure_with_highlight_symbol() {
let items = vec![ListItem::new("Item")]; let list = List::new(items).highlight_symbol(">");
let constraints = list.measure(Size::MAX);
assert_eq!(constraints.preferred.width, 6);
}
#[test]
fn list_has_intrinsic_size() {
let items = vec![ListItem::new("X")];
let list = List::new(items);
assert!(list.has_intrinsic_size());
}
#[test]
fn list_min_height_is_one_row() {
let items: Vec<ListItem> = (0..100)
.map(|i| ListItem::new(format!("Item {i}")))
.collect();
let list = List::new(items);
let constraints = list.measure(Size::MAX);
assert_eq!(constraints.min.height, 1);
assert_eq!(constraints.preferred.height, 100);
}
#[test]
fn list_measure_is_pure() {
let items = vec![ListItem::new("Test")];
let list = List::new(items);
let a = list.measure(Size::new(100, 50));
let b = list.measure(Size::new(100, 50));
assert_eq!(a, b);
}
#[test]
fn list_state_undo_id_is_stable() {
let state = ListState::default();
let id1 = state.undo_id();
let id2 = state.undo_id();
assert_eq!(id1, id2);
}
#[test]
fn list_state_undo_id_unique_per_instance() {
let state1 = ListState::default();
let state2 = ListState::default();
assert_ne!(state1.undo_id(), state2.undo_id());
}
#[test]
fn list_state_snapshot_and_restore() {
let mut state = ListState::default();
state.select(Some(5));
state.offset = 3;
let snapshot = state.create_snapshot();
state.select(Some(10));
state.offset = 8;
assert_eq!(state.selected(), Some(10));
assert_eq!(state.offset, 8);
assert!(state.restore_snapshot(snapshot.as_ref()));
assert_eq!(state.selected(), Some(5));
assert_eq!(state.offset, 3);
}
#[test]
fn list_state_undo_ext_methods() {
let mut state = ListState::default();
assert_eq!(state.selected_index(), None);
state.set_selected_index(Some(3));
assert_eq!(state.selected_index(), Some(3));
state.set_selected_index(None);
assert_eq!(state.selected_index(), None);
assert_eq!(state.offset, 0); }
use crate::stateful::Stateful;
#[test]
fn list_state_with_persistence_id() {
let state = ListState::default().with_persistence_id("sidebar-menu");
assert_eq!(state.persistence_id(), Some("sidebar-menu"));
}
#[test]
fn list_state_default_no_persistence_id() {
let state = ListState::default();
assert_eq!(state.persistence_id(), None);
}
#[test]
fn list_state_save_restore_round_trip() {
let mut state = ListState::default().with_persistence_id("test");
state.select(Some(7));
state.offset = 4;
let saved = state.save_state();
assert_eq!(saved.selected, Some(7));
assert_eq!(saved.offset, 4);
state.select(None);
assert_eq!(state.selected, None);
assert_eq!(state.offset, 0);
state.restore_state(saved);
assert_eq!(state.selected, Some(7));
assert_eq!(state.offset, 4);
}
#[test]
fn list_state_key_uses_persistence_id() {
let state = ListState::default().with_persistence_id("file-browser");
let key = state.state_key();
assert_eq!(key.widget_type, "List");
assert_eq!(key.instance_id, "file-browser");
}
#[test]
fn list_state_key_default_when_no_id() {
let state = ListState::default();
let key = state.state_key();
assert_eq!(key.widget_type, "List");
assert_eq!(key.instance_id, "default");
}
#[test]
fn list_persist_state_default() {
let persist = ListPersistState::default();
assert_eq!(persist.selected, None);
assert_eq!(persist.offset, 0);
}
use crate::mouse::MouseResult;
use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
#[test]
fn list_state_click_selects() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
assert_eq!(result, MouseResult::Selected(3));
assert_eq!(state.selected(), Some(3));
}
#[test]
fn list_state_click_wrong_id_ignored() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
let hit = Some((HitId::new(99), HitRegion::Content, 3u64));
let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
assert_eq!(result, MouseResult::Ignored);
assert_eq!(state.selected(), None);
}
#[test]
fn list_state_click_out_of_range() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
let hit = Some((HitId::new(1), HitRegion::Content, 15u64));
let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
assert_eq!(result, MouseResult::Ignored);
assert_eq!(state.selected(), None);
}
#[test]
fn list_state_click_no_hit_ignored() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
let result = state.handle_mouse(&event, None, HitId::new(1), 10);
assert_eq!(result, MouseResult::Ignored);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn list_state_scroll_up() {
let mut state = {
let mut s = ListState::default();
s.offset = 10;
s
};
state.scroll_up(3);
assert_eq!(state.offset, 7);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn list_state_scroll_up_clamps_to_zero() {
let mut state = {
let mut s = ListState::default();
s.offset = 1;
s
};
state.scroll_up(5);
assert_eq!(state.offset, 0);
}
#[test]
fn list_state_scroll_down() {
let mut state = ListState::default();
state.scroll_down(3, 20);
assert_eq!(state.offset, 3);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn list_state_scroll_down_clamps() {
let mut state = ListState::default();
state.offset = 18;
state.scroll_down(5, 20);
assert_eq!(state.offset, 19); }
#[test]
#[allow(clippy::field_reassign_with_default)]
fn list_state_scroll_wheel_up() {
let mut state = {
let mut s = ListState::default();
s.offset = 10;
s
};
let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
let result = state.handle_mouse(&event, None, HitId::new(1), 20);
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.offset, 7);
}
#[test]
fn list_state_scroll_wheel_down() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
let result = state.handle_mouse(&event, None, HitId::new(1), 20);
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.offset, 3);
}
#[test]
fn list_state_select_next() {
let mut state = ListState::default();
state.select_next(5);
assert_eq!(state.selected(), Some(0));
state.select_next(5);
assert_eq!(state.selected(), Some(1));
}
#[test]
fn list_state_select_next_clamps() {
let mut state = ListState::default();
state.select(Some(4));
state.select_next(5);
assert_eq!(state.selected(), Some(4)); }
#[test]
fn list_state_select_next_empty() {
let mut state = ListState::default();
state.select_next(0);
assert_eq!(state.selected(), None); }
#[test]
fn list_state_select_previous() {
let mut state = ListState::default();
state.select(Some(3));
state.select_previous();
assert_eq!(state.selected(), Some(2));
}
#[test]
fn list_state_select_previous_clamps() {
let mut state = ListState::default();
state.select(Some(0));
state.select_previous();
assert_eq!(state.selected(), Some(0)); }
#[test]
fn list_state_select_previous_from_none() {
let mut state = ListState::default();
state.select_previous();
assert_eq!(state.selected(), Some(0));
}
#[test]
fn list_handle_key_down_from_none_selects_first() {
let list = List::new(vec![
ListItem::new("a"),
ListItem::new("b"),
ListItem::new("c"),
]);
let mut state = ListState::default();
assert_eq!(state.selected(), None);
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
assert_eq!(state.selected(), Some(0));
}
#[test]
fn list_handle_key_up_from_none_selects_last() {
let list = List::new(vec![
ListItem::new("a"),
ListItem::new("b"),
ListItem::new("c"),
]);
let mut state = ListState::default();
assert_eq!(state.selected(), None);
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
assert_eq!(state.selected(), Some(2));
}
#[test]
fn list_handle_key_navigation_supports_jk_and_arrows() {
let list = List::new(vec![
ListItem::new("a"),
ListItem::new("b"),
ListItem::new("c"),
]);
let mut state = ListState::default();
state.select(Some(0));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
assert_eq!(state.selected(), Some(1));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
assert_eq!(state.selected(), Some(2));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
assert_eq!(state.selected(), Some(1));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('k'))));
assert_eq!(state.selected(), Some(0));
}
#[test]
fn list_handle_key_filter_is_incremental_and_editable() {
let list = List::new(vec![
ListItem::new("alpha"),
ListItem::new("banana"),
ListItem::new("beta"),
]);
let mut state = ListState::default();
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
assert_eq!(state.filter_query(), "b");
assert_eq!(state.selected(), Some(1));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('e'))));
assert_eq!(state.filter_query(), "be");
assert_eq!(state.selected(), Some(2));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Backspace)));
assert_eq!(state.filter_query(), "b");
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Escape)));
assert_eq!(state.filter_query(), "");
}
#[test]
fn list_render_filter_no_matches_shows_empty_state() {
let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
let mut state = ListState::default();
state.set_filter_query("zzz");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(14, 3, &mut pool);
StatefulWidget::render(&list, Rect::new(0, 0, 14, 3), &mut frame, &mut state);
assert_eq!(row_text(&frame, 0), "No matches");
}
#[test]
fn list_render_shorter_item_clears_stale_row_suffix() {
let mut pool = GraphemePool::new();
let mut frame = Frame::new(12, 2, &mut pool);
let mut state = ListState::default();
let area = Rect::new(0, 0, 12, 2);
let long = List::new(vec![ListItem::new("alphabet")]);
StatefulWidget::render(&long, area, &mut frame, &mut state);
let short = List::new(vec![ListItem::new("a")]);
StatefulWidget::render(&short, area, &mut frame, &mut state);
assert_eq!(raw_row_text(&frame, 0), "a ");
}
#[test]
fn list_render_empty_state_clears_stale_rows_and_tail() {
let list = List::new(Vec::<ListItem>::new());
let mut pool = GraphemePool::new();
let mut frame = Frame::new(12, 3, &mut pool);
let area = Rect::new(0, 0, 12, 3);
frame.buffer.fill(area, Cell::from_char('X'));
Widget::render(&list, area, &mut frame);
assert_eq!(raw_row_text(&frame, 0), "No items ");
assert_eq!(raw_row_text(&frame, 1), " ");
assert_eq!(raw_row_text(&frame, 2), " ");
}
#[test]
fn list_render_no_matches_clears_stale_rows_and_tail() {
let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
let mut state = ListState::default();
state.set_filter_query("zzz");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(12, 3, &mut pool);
let area = Rect::new(0, 0, 12, 3);
frame.buffer.fill(area, Cell::from_char('X'));
StatefulWidget::render(&list, area, &mut frame, &mut state);
assert_eq!(raw_row_text(&frame, 0), "No matches ");
assert_eq!(raw_row_text(&frame, 1), " ");
assert_eq!(raw_row_text(&frame, 2), " ");
}
#[test]
fn list_multi_select_toggle_with_space() {
let list = List::new(vec![
ListItem::new("alpha"),
ListItem::new("beta"),
ListItem::new("gamma"),
]);
let mut state = ListState::default();
state.set_multi_select(true);
state.select(Some(0));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
assert!(state.selected_indices().contains(&0));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
assert!(state.selected_indices().contains(&1));
assert_eq!(state.selected_count(), 2);
}
#[test]
fn list_render_draws_scroll_indicators() {
let items: Vec<ListItem> = (0..8).map(|i| ListItem::new(format!("Item {i}"))).collect();
let list = List::new(items);
let mut state = ListState {
selected: Some(4),
offset: 2,
scroll_into_view_requested: false,
..Default::default()
};
let mut pool = GraphemePool::new();
let mut frame = Frame::new(8, 3, &mut pool);
StatefulWidget::render(&list, Rect::new(0, 0, 8, 3), &mut frame, &mut state);
assert_eq!(
frame.buffer.get(7, 0).and_then(|c| c.content.as_char()),
Some('↑')
);
assert_eq!(
frame.buffer.get(7, 2).and_then(|c| c.content.as_char()),
Some('↓')
);
}
#[cfg(feature = "tracing")]
#[test]
fn list_tracing_span_and_selection_events_are_emitted() {
let trace_state = Arc::new(Mutex::new(ListTraceState::default()));
let _trace_test_guard = crate::tracing_test_support::acquire();
let subscriber = tracing_subscriber::registry().with(ListTraceCapture {
state: Arc::clone(&trace_state),
});
let _guard = tracing::subscriber::set_default(subscriber);
tracing::callsite::rebuild_interest_cache();
let list = List::new(vec![
ListItem::new("a"),
ListItem::new("b"),
ListItem::new("c"),
]);
let mut state = ListState::default();
state.select(Some(0));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 3, &mut pool);
tracing::callsite::rebuild_interest_cache();
StatefulWidget::render(&list, Rect::new(0, 0, 10, 3), &mut frame, &mut state);
tracing::callsite::rebuild_interest_cache();
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
tracing::callsite::rebuild_interest_cache();
let snapshot = trace_state.lock().expect("list trace state lock");
assert!(snapshot.list_render_seen, "expected list.render span");
assert!(
snapshot.has_total_items_field,
"list.render missing total_items"
);
assert!(
snapshot.has_visible_items_field,
"list.render missing visible_items"
);
assert!(
snapshot.has_selected_count_field,
"list.render missing selected_count"
);
assert!(
snapshot.has_filter_active_field,
"list.render missing filter_active"
);
assert!(
snapshot.render_duration_recorded,
"list.render did not record render_duration_us"
);
assert!(
snapshot.selection_events >= 1,
"expected list.selection debug event"
);
}
#[test]
fn list_state_right_click_ignored() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 5, 2);
let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
assert_eq!(result, MouseResult::Ignored);
}
#[test]
fn list_state_click_border_region_ignored() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
let hit = Some((HitId::new(1), HitRegion::Border, 3u64));
let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
assert_eq!(result, MouseResult::Ignored);
}
#[test]
fn list_state_second_click_activates() {
let mut state = ListState::default();
state.select(Some(3));
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
assert_eq!(result, MouseResult::Activated(3));
assert_eq!(state.selected(), Some(3));
}
#[test]
fn list_state_hover_updates() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
assert_eq!(result, MouseResult::HoverChanged);
assert_eq!(state.hovered, Some(3));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn list_state_hover_same_index_ignored() {
let mut state = {
let mut s = ListState::default();
s.hovered = Some(3);
s
};
let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
assert_eq!(result, MouseResult::Ignored);
assert_eq!(state.hovered, Some(3));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn list_state_hover_clears() {
let mut state = {
let mut s = ListState::default();
s.hovered = Some(5);
s
};
let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
let result = state.handle_mouse(&event, None, HitId::new(1), 10);
assert_eq!(result, MouseResult::HoverChanged);
assert_eq!(state.hovered, None);
}
#[test]
fn list_state_hover_clear_when_already_none() {
let mut state = ListState::default();
let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
let result = state.handle_mouse(&event, None, HitId::new(1), 10);
assert_eq!(result, MouseResult::Ignored);
}
#[test]
fn list_navigate_down_while_filter_active() {
let list = List::new(vec![
ListItem::new("alpha"),
ListItem::new("banana"),
ListItem::new("beta"),
ListItem::new("gamma"),
]);
let mut state = ListState::default();
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
assert_eq!(state.filter_query(), "b");
assert_eq!(state.selected(), Some(1));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
assert_eq!(state.selected(), Some(2));
assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
assert_eq!(state.selected(), Some(2));
}
#[test]
fn list_navigate_up_while_filter_active() {
let list = List::new(vec![
ListItem::new("alpha"),
ListItem::new("banana"),
ListItem::new("beta"),
ListItem::new("gamma"),
]);
let mut state = ListState::default();
state.set_filter_query("b");
state.select(Some(2));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
assert_eq!(state.selected(), Some(1));
assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
assert_eq!(state.selected(), Some(1));
}
#[test]
fn list_filter_case_insensitive() {
let list = List::new(vec![
ListItem::new("Alpha"),
ListItem::new("BANANA"),
ListItem::new("beta"),
]);
let mut state = ListState::default();
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('B'))));
assert_eq!(state.selected(), Some(1)); }
#[test]
fn list_filter_matches_marker() {
let list = List::new(vec![
ListItem::new("apple").marker("fruit"),
ListItem::new("carrot").marker("veggie"),
ListItem::new("berry").marker("fruit"),
]);
let mut state = ListState::default();
state.set_filter_query("veg");
let filtered = list.filtered_indices(&mut state);
assert_eq!(&*filtered, &[1]); }
#[test]
fn list_multi_select_toggle_while_filtered() {
let list = List::new(vec![
ListItem::new("alpha"),
ListItem::new("banana"),
ListItem::new("beta"),
ListItem::new("gamma"),
]);
let mut state = ListState::default();
state.set_multi_select(true);
state.set_filter_query("b");
state.select(Some(1));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
assert!(state.selected_indices().contains(&1));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
assert!(state.selected_indices().contains(&2));
assert_eq!(state.selected_count(), 2);
}
#[test]
fn list_disable_multi_select_clears_extras() {
let mut state = ListState::default();
state.set_multi_select(true);
state.toggle_multi_selected(0);
state.toggle_multi_selected(1);
state.toggle_multi_selected(2);
assert_eq!(state.selected_count(), 3);
assert_eq!(state.selected(), Some(2));
state.set_multi_select(false);
assert_eq!(state.selected_count(), 1);
assert!(state.selected_indices().contains(&2)); }
#[test]
fn list_navigation_with_ctrl_modifier_ignored() {
let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
let mut state = ListState::default();
state.select(Some(0));
let ctrl_down = KeyEvent {
code: KeyCode::Down,
modifiers: Modifiers::CTRL,
kind: ftui_core::event::KeyEventKind::Press,
};
assert!(!list.handle_key(&mut state, &ctrl_down));
assert_eq!(state.selected(), Some(0)); }
#[test]
fn list_space_with_no_selection_in_multi_select_is_noop() {
let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
let mut state = ListState::default();
state.set_multi_select(true);
assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
assert_eq!(state.selected_count(), 0);
}
#[test]
fn list_backspace_on_empty_filter_returns_false() {
let list = List::new(vec![ListItem::new("alpha")]);
let mut state = ListState::default();
assert!(state.filter_query().is_empty());
assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Backspace)));
}
#[test]
fn list_escape_on_empty_filter_returns_false() {
let list = List::new(vec![ListItem::new("alpha")]);
let mut state = ListState::default();
assert!(state.filter_query().is_empty());
assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Escape)));
}
#[test]
fn list_navigate_in_empty_filtered_result() {
let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
let mut state = ListState::default();
state.select(Some(0));
state.set_filter_query("zzz");
let handled = list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down));
if handled {
assert_eq!(state.selected(), None);
}
}
#[test]
fn list_filter_preserves_selection_when_still_visible() {
let list = List::new(vec![
ListItem::new("alpha"),
ListItem::new("banana"),
ListItem::new("beta"),
]);
let mut state = ListState::default();
state.select(Some(2));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
assert_eq!(state.selected(), Some(2)); }
#[test]
fn list_filter_moves_selection_when_current_hidden() {
let list = List::new(vec![
ListItem::new("alpha"),
ListItem::new("banana"),
ListItem::new("cherry"),
]);
let mut state = ListState::default();
state.select(Some(2));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
assert_eq!(state.selected(), Some(1)); }
#[test]
#[allow(clippy::field_reassign_with_default)]
fn list_set_filter_query_resets_offset() {
let mut state = ListState::default();
state.offset = 10;
state.set_filter_query("abc");
assert_eq!(state.offset, 0);
assert_eq!(state.filter_query(), "abc");
}
#[test]
fn list_clear_filter_query_resets_offset() {
let mut state = ListState::default();
state.set_filter_query("abc");
state.offset = 5;
state.clear_filter_query();
assert_eq!(state.offset, 0);
assert!(state.filter_query().is_empty());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn list_clear_filter_query_noop_when_empty() {
let mut state = ListState::default();
state.offset = 5;
state.clear_filter_query(); assert_eq!(state.offset, 5); }
#[test]
fn list_select_next_in_multi_select_preserves_others() {
let mut state = ListState::default();
state.set_multi_select(true);
state.toggle_multi_selected(0);
state.toggle_multi_selected(2);
assert_eq!(state.selected(), Some(2));
assert_eq!(state.selected_count(), 2);
state.select_next(5);
assert_eq!(state.selected(), Some(3)); assert!(state.selected_indices().contains(&0));
assert!(state.selected_indices().contains(&2));
}
#[test]
fn list_deselect_clears_multi_selected() {
let mut state = ListState::default();
state.set_multi_select(true);
state.toggle_multi_selected(0);
state.toggle_multi_selected(1);
state.toggle_multi_selected(2);
assert_eq!(state.selected_count(), 3);
state.select(None);
assert_eq!(state.selected_count(), 0);
assert!(state.selected_indices().is_empty());
}
#[test]
fn list_vi_j_moves_through_filtered_items() {
let list = List::new(vec![
ListItem::new("xylophone"),
ListItem::new("berry"),
ListItem::new("box"),
ListItem::new("cat"),
]);
let mut state = ListState::default();
state.set_filter_query("b");
state.select(Some(1));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
assert_eq!(state.selected(), Some(2));
assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
assert_eq!(state.selected(), Some(2));
}
#[test]
fn list_jk_navigate_not_filter_even_when_empty() {
let list = List::new(vec![
ListItem::new("alpha"),
ListItem::new("jam"),
ListItem::new("kite"),
]);
let mut state = ListState::default();
assert!(state.filter_query().is_empty());
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
assert!(state.filter_query().is_empty());
assert_eq!(state.selected(), Some(0));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
assert!(state.filter_query().is_empty());
assert_eq!(state.selected(), Some(1));
assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('k'))));
assert!(state.filter_query().is_empty());
assert_eq!(state.selected(), Some(0));
}
#[test]
fn list_multi_select_untoggle_removes_from_set() {
let mut state = ListState::default();
state.set_multi_select(true);
state.select(Some(0));
state.toggle_multi_selected(0);
assert!(state.selected_indices().contains(&0));
assert_eq!(state.selected_count(), 1);
state.toggle_multi_selected(0);
assert!(!state.selected_indices().contains(&0));
}
#[test]
fn list_widget_render_uses_default_state() {
let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
let mut state = ListState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 3, &mut pool);
StatefulWidget::render(&list, Rect::new(0, 0, 10, 3), &mut frame, &mut state);
assert_eq!(row_text(&frame, 0), "alpha");
assert_eq!(row_text(&frame, 1), "beta");
}
}