lv-tui 0.2.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::{Rect, Size};
use crate::layout::Constraint;
use crate::node::Node;
use crate::render::RenderCx;
use crate::style::Style;

/// Split direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitDirection {
    /// First on the left, second on the right.
    Horizontal,
    /// First on top, second on bottom.
    Vertical,
}

/// A resizable split pane with two children.
///
/// The divider position is controlled by `ratio` (0–100), representing the
/// percentage of space allocated to the first child. Use Ctrl+←/→ (horizontal)
/// or Ctrl+↑/↓ (vertical) to adjust the ratio.
pub struct SplitPane {
    first: Option<Node>,
    second: Option<Node>,
    /// Percentage of space for the first child (0–100).
    ratio: u16,
    direction: SplitDirection,
    rect: Rect,
    style: Style,
}

impl SplitPane {
    /// Creates an empty split pane.
    pub fn new() -> Self {
        Self {
            first: None,
            second: None,
            ratio: 50,
            direction: SplitDirection::Horizontal,
            rect: Rect::default(),
            style: Style::default(),
        }
    }

    /// Sets the first (left/top) child.
    pub fn first(mut self, component: impl Component + 'static) -> Self {
        self.first = Some(Node::new(component));
        self
    }

    /// Sets the second (right/bottom) child.
    pub fn second(mut self, component: impl Component + 'static) -> Self {
        self.second = Some(Node::new(component));
        self
    }

    /// Sets the ratio (0–100) of space for the first child.
    pub fn ratio(mut self, ratio: u16) -> Self {
        self.ratio = ratio.min(100);
        self
    }

    /// Sets the split direction.
    pub fn direction(mut self, direction: SplitDirection) -> Self {
        self.direction = direction;
        self
    }

    /// Builder: sets the splitter line style.
    pub fn style(mut self, style: Style) -> Self {
        self.style = style;
        self
    }
}

impl Component for SplitPane {
    fn render(&self, cx: &mut RenderCx) {
        let is_h = self.direction == SplitDirection::Horizontal;

        // Calculate child rects
        let (first_rect, second_rect) = self.child_rects();

        // Render first
        if let Some(child) = &self.first {
            let saved = child.rect();
            child.set_rect(first_rect);
            child.render_with_clip(cx.buffer, cx.focused_id, Some(first_rect));
            child.set_rect(saved);
        }

        // Divider line
        let div_style = Style::default().bg(crate::style::Color::White).fg(crate::style::Color::Black);
        if is_h {
            let div_x = second_rect.x.saturating_sub(1);
            for y in self.rect.y..self.rect.y.saturating_add(self.rect.height) {
                cx.buffer.write_text(
                    crate::geom::Pos { x: div_x, y },
                    self.rect, "", &div_style,
                );
            }
        } else {
            let div_y = second_rect.y.saturating_sub(1);
            for x in self.rect.x..self.rect.x.saturating_add(self.rect.width) {
                cx.buffer.write_text(
                    crate::geom::Pos { x, y: div_y },
                    self.rect, "", &div_style,
                );
            }
        }

        // Render second
        if let Some(child) = &self.second {
            let saved = child.rect();
            child.set_rect(second_rect);
            child.render_with_clip(cx.buffer, cx.focused_id, Some(second_rect));
            child.set_rect(saved);
        }
    }

    fn measure(&self, constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        Size { width: constraint.max.width, height: constraint.max.height }
    }

    fn event(&mut self, event: &Event, cx: &mut EventCx) {
        // Ctrl+arrows adjust ratio
        if let Event::Key(key_event) = event {
            if key_event.modifiers.ctrl {
                match self.direction {
                    SplitDirection::Horizontal => {
                        match &key_event.key {
                            crate::event::Key::Left => {
                                self.ratio = self.ratio.saturating_sub(5);
                                cx.invalidate_layout();
                                return;
                            }
                            crate::event::Key::Right => {
                                self.ratio = (self.ratio + 5).min(100);
                                cx.invalidate_layout();
                                return;
                            }
                            _ => {}
                        }
                    }
                    SplitDirection::Vertical => {
                        match &key_event.key {
                            crate::event::Key::Up => {
                                self.ratio = self.ratio.saturating_sub(5);
                                cx.invalidate_layout();
                                return;
                            }
                            crate::event::Key::Down => {
                                self.ratio = (self.ratio + 5).min(100);
                                cx.invalidate_layout();
                                return;
                            }
                            _ => {}
                        }
                    }
                }
            }
        }

        // Forward events to both children (capture phase only)
        if cx.phase() == crate::event::EventPhase::Capture {
            for child_opt in [&mut self.first, &mut self.second].iter_mut() {
                if let Some(child) = child_opt {
                    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) {
        self.rect = rect;
        let (first_rect, second_rect) = self.child_rects();
        if let Some(child) = &mut self.first { child.layout(first_rect); }
        if let Some(child) = &mut self.second { child.layout(second_rect); }
    }

    fn for_each_child(&self, f: &mut dyn FnMut(&Node)) {
        if let Some(child) = &self.first { f(child); }
        if let Some(child) = &self.second { f(child); }
    }
    fn for_each_child_mut(&mut self, f: &mut dyn FnMut(&mut Node)) {
        if let Some(child) = &mut self.first { f(child); }
        if let Some(child) = &mut self.second { f(child); }
    }
    fn focusable(&self) -> bool { false }
    fn style(&self) -> Style { self.style.clone() }
}

impl SplitPane {
    fn child_rects(&self) -> (Rect, Rect) {
        let is_h = self.direction == SplitDirection::Horizontal;
        let total = if is_h { self.rect.width } else { self.rect.height };
        let divider = 1u16;
        let available = total.saturating_sub(divider);
        let first_size = (available as u32 * self.ratio as u32 / 100) as u16;
        let second_size = available.saturating_sub(first_size);

        if is_h {
            let first = Rect { x: self.rect.x, y: self.rect.y, width: first_size, height: self.rect.height };
            let sx = self.rect.x.saturating_add(first_size).saturating_add(divider);
            let second = Rect { x: sx, y: self.rect.y, width: second_size, height: self.rect.height };
            (first, second)
        } else {
            let first = Rect { x: self.rect.x, y: self.rect.y, width: self.rect.width, height: first_size };
            let sy = self.rect.y.saturating_add(first_size).saturating_add(divider);
            let second = Rect { x: self.rect.x, y: sy, width: self.rect.width, height: second_size };
            (first, second)
        }
    }
}