scrin 0.1.75

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::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);
        }
    }
}

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);
    }
}