lv-tui 0.3.0

A reactive TUI framework for Rust, inspired by Textual and React
Documentation
use crate::component::{Component, EventCx, LayoutCx, MeasureCx};
use crate::event::Event;
use crate::geom::{Insets, Pos, Rect, Size};
use crate::layout::Constraint;
use crate::node::Node;
use crate::render::RenderCx;
use crate::style::{Border, Color, Style};
use crate::text::Text;

/// 边框 + 内边距包装容器
pub struct Block {
    child: Option<Node>,
    style: Style,
    title: Option<Text>,
    title_bottom: Option<Text>,
    title_alignment: crate::style::TextAlign,
}

impl Block {
    pub fn new(child: impl Component + 'static) -> Self {
        Self {
            child: Some(Node::new(child)),
            style: Style::default(),
            title: None,
            title_bottom: None,
            title_alignment: crate::style::TextAlign::Left,
        }
    }

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

    pub fn padding(mut self, value: u16) -> Self {
        self.style = self.style.padding(value);
        self
    }

    pub fn title(mut self, title: impl Into<Text>) -> Self {
        self.title = Some(title.into());
        self
    }

    /// Sets a title at the bottom border.
    pub fn title_bottom(mut self, title: impl Into<Text>) -> Self {
        self.title_bottom = Some(title.into());
        self
    }

    /// Sets the title alignment (Left/Center/Right).
    pub fn title_alignment(mut self, align: crate::style::TextAlign) -> Self {
        self.title_alignment = align;
        self
    }

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

impl Component for Block {
    fn render(&self, cx: &mut RenderCx) {
        // Check if child is focused — use highlighted border
        let child_focused = self.child.as_ref().map(|c| cx.focused_id == Some(c.id)).unwrap_or(false);
        let border_style = if child_focused {
            Style::default().fg(Color::White).bold()
        } else {
            self.style.clone()
        };
        cx.buffer.draw_border(cx.rect, self.style.border, &border_style);

        // 边框标题(顶部)
        if let Some(title) = &self.title {
            let text = title.first_text();
            let x = match self.title_alignment {
                crate::style::TextAlign::Left => cx.rect.x.saturating_add(2),
                crate::style::TextAlign::Center => {
                    let tw: u16 = text.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
                    cx.rect.x.saturating_add((cx.rect.width.saturating_sub(tw)) / 2)
                }
                crate::style::TextAlign::Right => {
                    let tw: u16 = text.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
                    cx.rect.x.saturating_add(cx.rect.width.saturating_sub(tw).saturating_sub(2))
                }
            };
            cx.buffer.write_text(Pos { x, y: cx.rect.y }, cx.rect, text, &cx.style);
        }

        // 边框标题(底部)
        if let Some(title) = &self.title_bottom {
            let text = title.first_text();
            let y = cx.rect.y.saturating_add(cx.rect.height.saturating_sub(1));
            cx.buffer.write_text(Pos { x: cx.rect.x.saturating_add(2), y }, cx.rect, text, &cx.style);
        }

        if let Some(child) = &self.child {
            child.render_with_parent(cx.buffer, cx.focused_id, cx.clip_rect, cx.wrap, cx.truncate, cx.align, Some(&cx.style));
        }
    }

    fn for_each_child(&self, f: &mut dyn FnMut(&Node)) {
        if let Some(child) = &self.child {
            f(child);
        }
    }

    fn for_each_child_mut(&mut self, f: &mut dyn FnMut(&mut Node)) {
        if let Some(child) = &mut self.child {
            f(child);
        }
    }

    fn measure(&self, constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        let pad = self.effective_padding();
        let child_constraint = Constraint {
            min: Size::default(),
            max: Size {
                width: constraint.max.width.saturating_sub(pad.left + pad.right),
                height: constraint.max.height.saturating_sub(pad.top + pad.bottom),
            },
        };

        let child_size = self
            .child
            .as_ref()
            .map(|c| c.measure(child_constraint))
            .unwrap_or_default();

        Size {
            width: child_size.width.saturating_add(pad.left + pad.right),
            height: child_size.height.saturating_add(pad.top + pad.bottom),
        }
    }

    fn focusable(&self) -> bool {
        false
    }

    fn event(&mut self, event: &Event, cx: &mut EventCx) {
        if matches!(event, Event::Focus | Event::Blur | Event::Tick) {
            return;
        }

        if let Some(child) = &mut self.child {
            let mut child_cx = EventCx::with_task_sender(&mut child.dirty, cx.global_dirty, cx.quit, cx.phase, cx.propagation_stopped, cx.task_sender.clone());
            child.component.event(event, &mut child_cx);
        }
    }

    fn layout(&mut self, rect: Rect, _cx: &mut LayoutCx) {
        let inner = rect.inner(self.effective_padding());
        if let Some(child) = &mut self.child {
            child.layout(inner);
        }
    }

    fn style(&self) -> Style {
        self.style.clone()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testbuffer::TestBuffer;
    use crate::widgets::Label;

    #[test]
    fn test_block_border() {
        let mut tb = TestBuffer::new(10, 3);
        tb.render(&Block::new(Label::new("hi")).border(Border::Rounded).padding(0));
        assert_eq!(&tb.buffer.cells[0].symbol, "");
    }

    #[test]
    fn test_inner_rect() {
        let block = Block::new(Label::new("x")).border(Border::Rounded).padding(1);
        let r = Rect { x: 0, y: 0, width: 20, height: 10 };
        let inner = block.inner_rect(r);
        // border(1) + padding(1) = 2 on each side
        assert_eq!(inner.x, 2);
        assert_eq!(inner.y, 2);
        assert_eq!(inner.width, 16);
        assert_eq!(inner.height, 6);
    }

    #[test]
    fn test_block_title_alignment() {
        let mut tb = TestBuffer::new(20, 3);
        tb.render(&Block::new(Label::new("x")).border(Border::Rounded)
            .title("center").title_alignment(crate::style::TextAlign::Center));
        // Title should appear somewhere in the top row
        assert!(tb.buffer.cells.iter().any(|c| c.symbol == "c"));
    }
}

impl Block {
    /// Returns the usable inner rect after subtracting padding and border.
    pub fn inner_rect(&self, rect: Rect) -> Rect {
        let p = self.effective_padding();
        Rect {
            x: rect.x.saturating_add(p.left),
            y: rect.y.saturating_add(p.top),
            width: rect.width.saturating_sub(p.left.saturating_add(p.right)),
            height: rect.height.saturating_sub(p.top.saturating_add(p.bottom)),
        }
    }

    fn effective_padding(&self) -> Insets {
        let border_width: u16 = match self.style.border {
            Border::None => 0,
            _ => 1,
        };
        Insets {
            top: self.style.padding.top + border_width,
            right: self.style.padding.right + border_width,
            bottom: self.style.padding.bottom + border_width,
            left: self.style.padding.left + border_width,
        }
    }
}