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::buffer::Cell;
use crate::core::rect::Rect;
use crate::interaction::{
    HitRegion, InteractionLayer, WidgetAction, WidgetId, WidgetRole, WidgetState,
};
use crate::sanitize;
use crate::theme::ThemeTokens;
use crate::widgets::block::{Block, BorderStyle};
use crate::widgets::Widget;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneCockpitSpec {
    pub id: String,
    pub title: String,
    pub preferred_percent: u16,
    pub min_width: u16,
    pub min_height: u16,
    pub focused: bool,
    pub hidden: bool,
}

impl PaneCockpitSpec {
    pub fn new(id: &str, title: &str) -> Self {
        Self {
            id: id.to_string(),
            title: title.to_string(),
            preferred_percent: 33,
            min_width: 18,
            min_height: 4,
            focused: false,
            hidden: false,
        }
    }

    pub fn with_preferred_percent(mut self, percent: u16) -> Self {
        self.preferred_percent = percent;
        self
    }

    pub fn with_min_size(mut self, width: u16, height: u16) -> Self {
        self.min_width = width;
        self.min_height = height;
        self
    }

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

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

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneCockpitLayout {
    pub id: String,
    pub outer: Rect,
    pub inner: Rect,
}

#[derive(Debug, Clone)]
pub struct PaneCockpit {
    pub header: String,
    pub footer: String,
    pub panes: Vec<PaneCockpitSpec>,
    pub tokens: ThemeTokens,
    pub stack_breakpoint: u16,
    pub padding: Rect,
    pub background_motion: bool,
    pub frame: u64,
    pub region_id: Option<WidgetId>,
}

impl PaneCockpit {
    pub fn new() -> Self {
        Self {
            header: String::new(),
            footer: String::new(),
            panes: Vec::new(),
            tokens: ThemeTokens::SCRIN,
            stack_breakpoint: 90,
            padding: Rect::new(1, 1, 1, 1),
            background_motion: false,
            frame: 0,
            region_id: None,
        }
    }

    pub fn with_header(mut self, header: &str) -> Self {
        self.header = header.to_string();
        self
    }

    pub fn with_footer(mut self, footer: &str) -> Self {
        self.footer = footer.to_string();
        self
    }

    pub fn with_pane(mut self, pane: PaneCockpitSpec) -> Self {
        self.panes.push(pane);
        self
    }

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

    pub fn with_stack_breakpoint(mut self, width: u16) -> Self {
        self.stack_breakpoint = width;
        self
    }

    pub fn with_background_motion(mut self, enabled: bool, frame: u64) -> Self {
        self.background_motion = enabled;
        self.frame = frame;
        self
    }

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

    pub fn pane_layouts(&self, area: Rect) -> Vec<PaneCockpitLayout> {
        let content_top = if self.header.is_empty() {
            area.y
        } else {
            area.y.saturating_add(1)
        };
        let content_bottom = if self.footer.is_empty() {
            area.bottom()
        } else {
            area.bottom().saturating_sub(1)
        };
        let content = Rect::new(
            area.x,
            content_top,
            area.width,
            content_bottom.saturating_sub(content_top),
        )
        .inner(self.padding);
        let visible = self
            .panes
            .iter()
            .filter(|pane| !pane.hidden)
            .collect::<Vec<_>>();
        if visible.is_empty() || content.is_empty() {
            return Vec::new();
        }

        if area.width < self.stack_breakpoint {
            let mut layouts = Vec::new();
            let mut y = content.y;
            let remaining = content.height as usize;
            let each = (remaining / visible.len()).max(1) as u16;
            for (idx, pane) in visible.iter().enumerate() {
                if y >= content.bottom() {
                    break;
                }
                let height = if idx + 1 == visible.len() {
                    content.bottom().saturating_sub(y)
                } else {
                    each.max(pane.min_height)
                        .min(content.bottom().saturating_sub(y))
                };
                let outer = Rect::new(content.x, y, content.width, height);
                layouts.push(PaneCockpitLayout {
                    id: pane.id.clone(),
                    outer,
                    inner: Block::inner_for_bordered(outer),
                });
                y = y.saturating_add(height);
            }
            return layouts;
        }

        let total_percent = visible
            .iter()
            .map(|pane| pane.preferred_percent.max(1) as usize)
            .sum::<usize>()
            .max(1);
        let mut layouts = Vec::new();
        let mut x = content.x;
        for (idx, pane) in visible.iter().enumerate() {
            if x >= content.right() {
                break;
            }
            let width = if idx + 1 == visible.len() {
                content.right().saturating_sub(x)
            } else {
                let preferred =
                    content.width.saturating_mul(pane.preferred_percent) / total_percent as u16;
                preferred
                    .max(pane.min_width)
                    .min(content.right().saturating_sub(x))
            };
            let outer = Rect::new(x, content.y, width, content.height);
            layouts.push(PaneCockpitLayout {
                id: pane.id.clone(),
                outer,
                inner: Block::inner_for_bordered(outer),
            });
            x = x.saturating_add(width);
        }
        layouts
    }

    pub fn render_with_interaction(
        &self,
        buffer: &mut Buffer,
        area: Rect,
        layer: &mut InteractionLayer,
    ) -> Vec<PaneCockpitLayout> {
        self.render(buffer, area);
        let layouts = self.pane_layouts(area);
        if area.is_empty() {
            return layouts;
        }
        let region_id = self
            .region_id
            .clone()
            .unwrap_or_else(|| WidgetId::new("pane-cockpit"));
        layer.push_region(
            HitRegion::new(region_id.clone(), area)
                .with_role(WidgetRole::Panel)
                .with_label("pane cockpit"),
        );
        for layout in &layouts {
            let Some(spec) = self.panes.iter().find(|pane| pane.id == layout.id) else {
                continue;
            };
            layer.push_region(
                HitRegion::new(
                    format!("{}:pane:{}", region_id.as_ref(), spec.id),
                    layout.outer,
                )
                .with_role(WidgetRole::Pane)
                .with_label(spec.title.clone())
                .with_action(WidgetAction::Focus)
                .with_state(WidgetState::default().focused(spec.focused))
                .with_z_index(1),
            );
        }
        layouts
    }
}

impl Default for PaneCockpit {
    fn default() -> Self {
        Self::new()
    }
}

impl Widget for PaneCockpit {
    fn render(&self, buffer: &mut Buffer, area: Rect) {
        if area.is_empty() {
            return;
        }
        buffer.fill(area, ' ', self.tokens.text, Some(self.tokens.panel));
        if self.background_motion {
            for y in area.y as usize..area.bottom() as usize {
                for x in area.x as usize..area.right() as usize {
                    if ((x as u64 + y as u64 + self.frame) % 17) == 0 {
                        buffer.set(
                            x,
                            y,
                            Cell::new('.', self.tokens.dim, Some(self.tokens.panel)),
                        );
                    }
                }
            }
        }
        if !self.header.is_empty() {
            buffer.set_str(
                area.x as usize,
                area.y as usize,
                &sanitize::truncate_str(&self.header, area.width as usize),
                self.tokens.accent,
                Some(self.tokens.panel),
            );
        }
        if !self.footer.is_empty() {
            buffer.set_str(
                area.x as usize,
                area.bottom().saturating_sub(1) as usize,
                &sanitize::truncate_str(&self.footer, area.width as usize),
                self.tokens.dim,
                Some(self.tokens.panel),
            );
        }
        for layout in self.pane_layouts(area) {
            let Some(spec) = self.panes.iter().find(|pane| pane.id == layout.id) else {
                continue;
            };
            let color = if spec.focused {
                self.tokens.accent
            } else {
                self.tokens.dim
            };
            Block::new(&spec.title)
                .with_borders(BorderStyle::Rounded)
                .with_border_color(color)
                .render(buffer, layout.outer);
        }
    }
}

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

    #[test]
    fn pane_cockpit_stacks_when_narrow() {
        let cockpit = PaneCockpit::new()
            .with_pane(PaneCockpitSpec::new("a", "A"))
            .with_pane(PaneCockpitSpec::new("b", "B"))
            .with_stack_breakpoint(80);
        let layouts = cockpit.pane_layouts(Rect::new(0, 0, 40, 12));
        assert_eq!(layouts.len(), 2);
        assert_eq!(layouts[0].outer.x, layouts[1].outer.x);
        assert!(layouts[1].outer.y > layouts[0].outer.y);
    }
}