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, WidgetAction, WidgetId, WidgetRole};
use crate::sanitize;
use crate::theme::ThemeTokens;
use crate::widgets::block::{Block, BorderStyle};
use crate::widgets::Widget;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KanbanCard {
    pub id: String,
    pub title: String,
    pub badges: Vec<String>,
    pub note: Option<String>,
}

impl KanbanCard {
    pub fn new(id: &str, title: &str) -> Self {
        Self {
            id: id.to_string(),
            title: title.to_string(),
            badges: Vec::new(),
            note: None,
        }
    }

    pub fn with_badge(mut self, badge: &str) -> Self {
        self.badges.push(badge.to_string());
        self
    }

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

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KanbanLane {
    pub id: String,
    pub title: String,
    pub cards: Vec<KanbanCard>,
    pub empty_hint: String,
}

impl KanbanLane {
    pub fn new(id: &str, title: &str) -> Self {
        Self {
            id: id.to_string(),
            title: title.to_string(),
            cards: Vec::new(),
            empty_hint: "empty".to_string(),
        }
    }

    pub fn with_card(mut self, card: KanbanCard) -> Self {
        self.cards.push(card);
        self
    }

    pub fn with_empty_hint(mut self, hint: &str) -> Self {
        self.empty_hint = hint.to_string();
        self
    }
}

#[derive(Debug, Clone)]
pub struct KanbanBoard {
    pub lanes: Vec<KanbanLane>,
    pub tokens: ThemeTokens,
    pub intake_label: Option<String>,
    pub region_id: Option<WidgetId>,
}

impl KanbanBoard {
    pub fn new(lanes: Vec<KanbanLane>) -> Self {
        Self {
            lanes,
            tokens: ThemeTokens::SCRIN,
            intake_label: None,
            region_id: None,
        }
    }

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

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

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

    pub fn lane_rects(&self, area: Rect) -> Vec<Rect> {
        if self.lanes.is_empty() || area.is_empty() {
            return Vec::new();
        }
        let count = self.lanes.len() as u16;
        let width = (area.width / count).max(1);
        let mut rects = Vec::with_capacity(self.lanes.len());
        let mut x = area.x;
        for idx in 0..self.lanes.len() {
            let w = if idx + 1 == self.lanes.len() {
                area.right().saturating_sub(x)
            } else {
                width.min(area.right().saturating_sub(x))
            };
            rects.push(Rect::new(x, area.y, w, area.height));
            x = x.saturating_add(w);
        }
        rects
    }

    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("kanban"));
        layer.push_region(
            HitRegion::new(region_id.clone(), area)
                .with_role(WidgetRole::Panel)
                .with_label("kanban board"),
        );
        for (lane, rect) in self.lanes.iter().zip(self.lane_rects(area)) {
            layer.push_region(
                HitRegion::new(format!("{}:lane:{}", region_id.as_ref(), lane.id), rect)
                    .with_role(WidgetRole::BoardColumn)
                    .with_label(format!("{} ({})", lane.title, lane.cards.len()))
                    .with_action(WidgetAction::Focus)
                    .with_z_index(1),
            );
            let inner = Block::inner_for_bordered(rect);
            let max_cards = inner.height.saturating_sub(2) as usize;
            for (row, card) in lane.cards.iter().take(max_cards).enumerate() {
                let y = inner.y.saturating_add(1 + row as u16);
                let card_area = Rect::new(inner.x, y, inner.width, 1);
                layer.push_region(
                    HitRegion::new(
                        format!("{}:card:{}", region_id.as_ref(), card.id),
                        card_area,
                    )
                    .with_role(WidgetRole::BoardCard)
                    .with_label(card.title.clone())
                    .with_action(WidgetAction::Open)
                    .with_row(row)
                    .with_z_index(2),
                );
            }
        }
    }
}

impl Widget for KanbanBoard {
    fn render(&self, buffer: &mut Buffer, area: Rect) {
        if area.is_empty() {
            return;
        }
        buffer.fill(area, ' ', self.tokens.text, Some(self.tokens.panel));
        for (lane, rect) in self.lanes.iter().zip(self.lane_rects(area)) {
            let title = format!("{} ({})", lane.title, lane.cards.len());
            Block::new(&title)
                .with_borders(BorderStyle::Rounded)
                .with_border_color(self.tokens.dim)
                .render(buffer, rect);
            let inner = Block::inner_for_bordered(rect);
            if inner.is_empty() {
                continue;
            }
            if lane.cards.is_empty() {
                buffer.set_str(
                    inner.x as usize,
                    inner.y as usize,
                    &sanitize::truncate_str(&lane.empty_hint, inner.width as usize),
                    self.tokens.dim,
                    Some(self.tokens.panel),
                );
            }
            let max_cards = inner.height.saturating_sub(2) as usize;
            for (row, card) in lane.cards.iter().take(max_cards).enumerate() {
                let y = inner.y as usize + 1 + row;
                let badges = if card.badges.is_empty() {
                    String::new()
                } else {
                    format!(" [{}]", card.badges.join(","))
                };
                let text = format!("- {}{}", card.title, badges);
                buffer.set_str(
                    inner.x as usize,
                    y,
                    &sanitize::truncate_str(&text, inner.width as usize),
                    self.tokens.text,
                    Some(self.tokens.panel),
                );
            }
            if lane.cards.len() > max_cards && inner.height > 0 {
                let more = format!("+{} more", lane.cards.len() - max_cards);
                buffer.set_str(
                    inner.x as usize,
                    inner.bottom().saturating_sub(1) as usize,
                    &sanitize::truncate_str(&more, inner.width as usize),
                    self.tokens.warning,
                    Some(self.tokens.panel),
                );
            } else if let Some(label) = &self.intake_label {
                buffer.set_str(
                    inner.x as usize,
                    inner.bottom().saturating_sub(1) as usize,
                    &sanitize::truncate_str(label, inner.width as usize),
                    self.tokens.accent,
                    Some(self.tokens.panel),
                );
            }
        }
    }
}

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

    #[test]
    fn kanban_board_registers_card_hit_regions() {
        let board = KanbanBoard::new(vec![
            KanbanLane::new("todo", "Todo").with_card(KanbanCard::new("a", "Build")),
            KanbanLane::new("done", "Done"),
            KanbanLane::new("later", "Later"),
        ])
        .with_region_id("board");
        let mut buffer = Buffer::new(60, 8);
        let mut layer = InteractionLayer::new();

        board.render_with_interaction(&mut buffer, Rect::new(0, 0, 60, 8), &mut layer);
        assert!(layer
            .regions
            .iter()
            .any(|region| region.id.as_ref() == "board:card:a"));
    }
}