use crate::core::buffer::Buffer;
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::interaction::{
HitRegion, InteractionLayer, ScrollRowHit, SelectableSpan, SelectionGroup, TextRange,
WidgetAction, WidgetId, WidgetRole, WidgetState,
};
use crate::sanitize;
use crate::style::Style;
use crate::widgets::paragraph::{Line, Span, Text};
use crate::widgets::Widget;
#[derive(Debug, Clone)]
pub struct ListItem {
pub content: String,
pub text: Text,
pub style: Style,
}
impl ListItem {
pub fn new(content: &str) -> Self {
Self {
content: content.to_string(),
text: Text::raw(content),
style: Style::new(),
}
}
pub fn from_text(text: Text) -> Self {
let content = text
.lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_str())
.collect::<Vec<_>>()
.join("");
Self {
content,
text,
style: Style::new(),
}
}
pub fn with_line(line: Line) -> Self {
Self::from_text(Text::new(vec![line]))
}
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
for line in &mut self.text.lines {
for span in &mut line.spans {
span.style = style.merge(&span.style);
}
}
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ListState {
pub selected: Option<usize>,
pub offset: usize,
}
impl ListState {
pub fn new() -> Self {
Self::default()
}
pub fn select(&mut self, selected: Option<usize>) {
self.selected = selected;
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn offset(&self) -> usize {
self.offset
}
}
#[derive(Debug, Clone)]
pub struct List<'a> {
pub items: &'a [ListItem],
pub selected: Option<usize>,
pub highlight_style: Style,
pub highlight_symbol: &'a str,
}
impl<'a> List<'a> {
pub fn new(items: &'a [ListItem]) -> Self {
Self {
items,
selected: None,
highlight_style: Style::new().fg(Color::WHITE).bg(Color::rgb(31, 111, 235)),
highlight_symbol: "▸ ",
}
}
pub fn with_selected(mut self, selected: usize) -> Self {
self.selected = Some(selected);
self
}
pub fn with_highlight_style(mut self, style: Style) -> Self {
self.highlight_style = style;
self
}
pub fn with_highlight_symbol(mut self, symbol: &'a str) -> Self {
self.highlight_symbol = symbol;
self
}
pub fn render_stateful(&self, buffer: &mut Buffer, area: Rect, state: &mut ListState) {
if area.width == 0 || area.height == 0 || self.items.is_empty() {
return;
}
if state.selected.is_none() {
state.selected = self.selected;
}
if let Some(selected) = state.selected {
let selected = selected.min(self.items.len().saturating_sub(1));
state.selected = Some(selected);
if selected < state.offset {
state.offset = selected;
} else if selected >= state.offset + area.height as usize {
state.offset = selected + 1 - area.height as usize;
}
}
for row in 0..area.height as usize {
let item_idx = state.offset + row;
if item_idx >= self.items.len() {
break;
}
let y = area.y as usize + row;
let is_selected = state.selected == Some(item_idx);
let item = &self.items[item_idx];
let style = if is_selected {
item.style.merge(&self.highlight_style)
} else {
item.style
};
let fg = style.fg_or_default();
let bg = style.bg;
if is_selected {
buffer.fill(Rect::new(area.x, y as u16, area.width, 1), ' ', fg, bg);
buffer.set_str(area.x as usize, y, self.highlight_symbol, fg, bg);
}
let x = area.x as usize
+ if is_selected {
self.highlight_symbol.chars().count()
} else {
0
};
let width = area.right() as usize - x.min(area.right() as usize);
render_item_line(buffer, item, x, y, width, style);
}
}
pub fn render_stateful_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
state: &mut ListState,
layer: &mut InteractionLayer,
region_id: impl Into<WidgetId>,
) {
self.render_stateful(buffer, area, state);
if area.is_empty() || self.items.is_empty() {
return;
}
let region_id = region_id.into();
layer.push_region(
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Region)
.with_label("list"),
);
let group = SelectionGroup::new(format!("{}:items", region_id.as_ref()));
let mut row_hits = Vec::new();
for row in 0..area.height as usize {
let item_idx = state.offset + row;
if item_idx >= self.items.len() {
break;
}
let y = area.y as usize + row;
let item = &self.items[item_idx];
let row_id = WidgetId::new(format!("{}:item:{}", region_id.as_ref(), item_idx));
let row_area = Rect::new(area.x, y as u16, area.width, 1);
let selected = state.selected == Some(item_idx);
layer.push_region(
HitRegion::new(row_id.clone(), row_area)
.with_role(WidgetRole::ListItem)
.with_label(item.content.clone())
.with_action(WidgetAction::Focus)
.with_row(item_idx)
.with_selection_group(group.clone())
.with_state(WidgetState::default().selected(selected))
.with_z_index(1),
);
let span_id = format!("{}:span:{}", region_id.as_ref(), item_idx);
row_hits.push(
ScrollRowHit::new(row_id.clone(), item_idx)
.with_span_id(span_id.clone())
.with_item_id(row_id),
);
let display = sanitize::sanitize_str(&item.content, area.width as usize);
let width = sanitize::str_display_width(&display).min(area.width as usize);
layer.push_selectable_span(
SelectableSpan::new(
span_id,
display.clone(),
0..display.len(),
Rect::new(area.x, y as u16, width as u16, 1),
)
.with_source_id(region_id.clone())
.with_group(group.clone())
.with_logical_range(TextRange::new(
item_idx,
0,
sanitize::str_display_width(&display),
)),
);
}
layer.push_scroll_region(region_id, area, state.offset, row_hits);
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
region_id: impl Into<WidgetId>,
) {
let mut state = ListState {
selected: self.selected,
offset: 0,
};
self.render_stateful_with_interaction(buffer, area, &mut state, layer, region_id);
}
}
impl<'a> Widget for List<'a> {
fn render(&self, buffer: &mut Buffer, area: Rect) {
let mut state = ListState {
selected: self.selected,
offset: 0,
};
self.render_stateful(buffer, area, &mut state);
}
}
fn render_item_line(
buffer: &mut Buffer,
item: &ListItem,
x: usize,
y: usize,
width: usize,
base: Style,
) {
let Some(line) = item.text.lines.first() else {
return;
};
let mut col = 0usize;
for span in &line.spans {
let style = base.merge(&span.style);
let fg = style.fg_or_default();
let bg = style.bg;
for ch in span.content.chars() {
if col >= width {
return;
}
buffer.set(
x + col,
y,
crate::core::buffer::Cell {
ch,
fg,
bg,
bold: style.bold,
italic: style.italic,
underlined: style.underlined,
},
);
col += 1;
}
}
}
pub fn rich_item(spans: Vec<Span>) -> ListItem {
ListItem::with_line(Line::new(spans))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stateful_list_keeps_selected_visible() {
let items = (0..10)
.map(|i| ListItem::new(&format!("item {i}")))
.collect::<Vec<_>>();
let list = List::new(&items);
let mut state = ListState::new();
state.select(Some(6));
let mut buffer = Buffer::new(12, 3);
list.render_stateful(&mut buffer, Rect::new(0, 0, 12, 3), &mut state);
assert_eq!(state.offset, 4);
assert_eq!(buffer.get(0, 2).unwrap().ch, '▸');
}
#[test]
fn list_renders_rich_item_span_color() {
let red = Color::rgb(255, 0, 0);
let items = [rich_item(vec![Span::styled("hot", Style::new().fg(red))])];
let mut buffer = Buffer::new(8, 1);
List::new(&items).render(&mut buffer, Rect::new(0, 0, 8, 1));
assert_eq!(buffer.get(0, 0).unwrap().fg, red);
}
#[test]
fn list_interaction_maps_scrolled_rows_to_items() {
let items = (0..5)
.map(|i| ListItem::new(&format!("item {i}")))
.collect::<Vec<_>>();
let list = List::new(&items);
let mut state = ListState::new();
state.select(Some(3));
let mut buffer = Buffer::new(12, 2);
let mut layer = InteractionLayer::new();
list.render_stateful_with_interaction(
&mut buffer,
Rect::new(0, 0, 12, 2),
&mut state,
&mut layer,
"todo-list",
);
let hit = layer.scroll_hit_test(1, 1).unwrap();
assert_eq!(hit.logical_row, 3);
assert_eq!(hit.item_id.unwrap().as_ref(), "todo-list:item:3");
}
}