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::rect::Rect;
use crate::interaction::{
    HitRegion, InteractionLayer, ScrollRowHit, SelectableSpan, SelectionGroup, TextRange,
    WidgetAction, WidgetId, WidgetRole, WidgetState,
};
use crate::sanitize;
use crate::theme::ThemeTokens;
use crate::widgets::Widget;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TodoStatus {
    Open,
    Active,
    Done,
    Blocked,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TodoListStyle {
    Compact,
    Spacious,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoItem {
    pub id: String,
    pub text: String,
    pub status: TodoStatus,
    pub note: Option<String>,
}

impl TodoItem {
    pub fn new(id: &str, text: &str, status: TodoStatus) -> Self {
        Self {
            id: id.to_string(),
            text: text.to_string(),
            status,
            note: None,
        }
    }

    pub fn with_note(mut self, note: &str) -> Self {
        self.note = Some(note.to_string());
        self
    }
}

#[derive(Debug, Clone)]
pub struct TodoList<'a> {
    pub items: &'a [TodoItem],
    pub style: TodoListStyle,
    pub hover: Option<usize>,
    pub selected: Option<usize>,
    pub selectable: bool,
    pub active_marker: String,
    pub done_marker: String,
    pub open_marker: String,
    pub blocked_marker: String,
    pub tokens: ThemeTokens,
    pub region_id: Option<WidgetId>,
}

impl<'a> TodoList<'a> {
    pub fn new(items: &'a [TodoItem]) -> Self {
        Self {
            items,
            style: TodoListStyle::Compact,
            hover: None,
            selected: None,
            selectable: true,
            active_marker: "[*]".to_string(),
            done_marker: "[x]".to_string(),
            open_marker: "[ ]".to_string(),
            blocked_marker: "[!]".to_string(),
            tokens: ThemeTokens::SCRIN,
            region_id: None,
        }
    }

    pub fn with_style(mut self, style: TodoListStyle) -> Self {
        self.style = style;
        self
    }

    pub fn with_hover(mut self, row: Option<usize>) -> Self {
        self.hover = row;
        self
    }

    pub fn with_selected(mut self, row: Option<usize>) -> Self {
        self.selected = row;
        self
    }

    pub fn with_selectable(mut self, selectable: bool) -> Self {
        self.selectable = selectable;
        self
    }

    pub fn with_active_marker(mut self, marker: &str) -> Self {
        self.active_marker = marker.to_string();
        self
    }

    pub fn with_done_marker(mut self, marker: &str) -> Self {
        self.done_marker = marker.to_string();
        self
    }

    pub fn with_tokens(mut self, tokens: ThemeTokens) -> Self {
        self.tokens = tokens;
        self
    }

    pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
        self.region_id = Some(id.into());
        self
    }

    pub fn render_with_interaction(
        &self,
        buffer: &mut Buffer,
        area: Rect,
        layer: &mut InteractionLayer,
    ) {
        self.render(buffer, area);
        if area.is_empty() {
            return;
        }
        let region_id = self
            .region_id
            .clone()
            .unwrap_or_else(|| WidgetId::new("todo-list"));
        layer.push_region(
            HitRegion::new(region_id.clone(), area)
                .with_role(WidgetRole::Panel)
                .with_label("todo list"),
        );
        let group = SelectionGroup::new(format!("{}:items", region_id.as_ref()));
        let mut row_hits = Vec::new();
        for (row, item) in self.items.iter().take(area.height as usize).enumerate() {
            let y = area.y as usize + row;
            let row_id = WidgetId::new(format!("{}:{}", region_id.as_ref(), item.id));
            let row_area = Rect::new(area.x, y as u16, area.width, 1);
            let selected = self.selected == Some(row);
            let hovered = self.hover == Some(row);
            layer.push_region(
                HitRegion::new(row_id.clone(), row_area)
                    .with_role(WidgetRole::TodoItem)
                    .with_label(item.text.clone())
                    .with_action(WidgetAction::Toggle)
                    .with_row(row)
                    .with_selection_group(group.clone())
                    .with_state(
                        WidgetState::default()
                            .selected(selected)
                            .hovered(hovered)
                            .checked(item.status == TodoStatus::Done)
                            .active(item.status == TodoStatus::Active),
                    )
                    .with_z_index(1),
            );
            let span_id = format!("{}:span", row_id.as_ref());
            row_hits.push(
                ScrollRowHit::new(row_id.clone(), row)
                    .with_span_id(span_id.clone())
                    .with_item_id(row_id),
            );
            if self.selectable {
                let display = sanitize::sanitize_str(&row_text(self, item), area.width as usize);
                layer.push_selectable_span(
                    SelectableSpan::new(
                        span_id,
                        display.clone(),
                        0..display.len(),
                        Rect::new(
                            area.x,
                            y as u16,
                            sanitize::str_display_width(&display) as u16,
                            1,
                        ),
                    )
                    .with_source_id(region_id.clone())
                    .with_group(group.clone())
                    .with_logical_range(TextRange::new(
                        row,
                        0,
                        sanitize::str_display_width(&display),
                    )),
                );
            }
        }
        layer.push_scroll_region(region_id, area, 0, row_hits);
    }
}

impl Widget for TodoList<'_> {
    fn render(&self, buffer: &mut Buffer, area: Rect) {
        if area.is_empty() {
            return;
        }
        for (row, item) in self.items.iter().take(area.height as usize).enumerate() {
            let y = area.y as usize + row;
            let bg = if self.selected == Some(row) {
                Some(self.tokens.accent.dim(0.65))
            } else if self.hover == Some(row) {
                Some(self.tokens.panel.brighten(0.08))
            } else {
                None
            };
            for x in area.x as usize..area.right() as usize {
                buffer.set(
                    x,
                    y,
                    crate::core::buffer::Cell::new(' ', self.tokens.text, bg),
                );
            }
            let color = match item.status {
                TodoStatus::Open => self.tokens.dim,
                TodoStatus::Active => self.tokens.warning,
                TodoStatus::Done => self.tokens.success,
                TodoStatus::Blocked => self.tokens.error,
            };
            buffer.set_str(
                area.x as usize,
                y,
                &sanitize::truncate_str(&row_text(self, item), area.width as usize),
                color,
                bg,
            );
        }
    }
}

fn row_text(list: &TodoList<'_>, item: &TodoItem) -> String {
    let marker = match item.status {
        TodoStatus::Open => &list.open_marker,
        TodoStatus::Active => &list.active_marker,
        TodoStatus::Done => &list.done_marker,
        TodoStatus::Blocked => &list.blocked_marker,
    };
    match &item.note {
        Some(note) => format!("{} {} - {}", marker, item.text, note),
        None => format!("{} {}", marker, item.text),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn todo_list_registers_clickable_rows() {
        let items = [TodoItem::new("a", "Ship", TodoStatus::Active)];
        let list = TodoList::new(&items).with_region_id("todos");
        let mut buffer = Buffer::new(24, 2);
        let mut layer = InteractionLayer::new();

        list.render_with_interaction(&mut buffer, Rect::new(0, 0, 24, 2), &mut layer);
        let hit = layer.hit_test(1, 0).unwrap();
        assert_eq!(hit.id.as_ref(), "todos:a");
        assert_eq!(hit.role, WidgetRole::TodoItem);
        assert!(hit.state.active);
    }
}