scrin 0.1.83

A terminal UI toolkit with panes, widgets, overlays, animations, and Aisling-powered effects/loaders.
Documentation
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");
    }
}