tui-lipan 0.1.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
use crate::backend::ratatui_backend::common::{
    ClipBounds, DrawCellClip, draw_cell, to_ratatui_style,
};
use crate::backend::ratatui_backend::render::{
    RenderState, is_box_drawing_symbol, render_offset_for_node, scroll_view_clip_rect,
    to_merge_strategy,
};
use crate::core::node::NodeKind;
use crate::style::resolve::resolve_border_style;
use crate::style::{Rect, Style};
use crate::widgets::BorderMergeMode;
use crate::widgets::Orientation;

pub(crate) struct DividerRenderCtx {
    pub clip_rect: Option<Rect>,
    pub label_rect: Option<Rect>,
    pub label_padding: u16,
}

pub(crate) fn render_divider(
    f: &mut ratatui::Frame<'_>,
    orientation: Orientation,
    ch: char,
    style: Style,
    rect: Rect,
    ctx: DividerRenderCtx,
) {
    let DividerRenderCtx {
        clip_rect,
        label_rect,
        label_padding,
    } = ctx;
    if rect.w == 0 || rect.h == 0 {
        return;
    }

    let mut draw_rect = rect;
    match orientation {
        Orientation::Horizontal => draw_rect.h = 1,
        Orientation::Vertical => draw_rect.w = 1,
    }

    if draw_rect.w == 0 || draw_rect.h == 0 {
        return;
    }

    if let Some(clip) = clip_rect
        && draw_rect.intersection(&clip).is_empty()
    {
        return;
    }

    let mut gap_rect = label_rect;
    if let Some(gap) = &mut gap_rect {
        let pad = label_padding as i16;
        gap.x = gap.x.saturating_sub(pad);
        gap.w = gap.w.saturating_add(label_padding.saturating_mul(2));
        *gap = gap.intersection(&draw_rect);
        if gap.is_empty() {
            gap_rect = None;
        }
    }

    let buf = f.buffer_mut();
    let clip = clip_rect
        .map(ClipBounds::from_rect)
        .unwrap_or_else(ClipBounds::unbounded);
    let buf_bounds = ClipBounds::from_rrect(buf.area);
    let rstyle = to_ratatui_style(style);
    let symbol = ch.to_string();

    match orientation {
        Orientation::Horizontal => {
            let y = draw_rect.y as i32;
            let start_x = draw_rect.x as i32;
            let end_x = draw_rect
                .x
                .saturating_add(draw_rect.w as i16)
                .saturating_sub(1) as i32;
            for x in start_x..=end_x {
                if gap_rect.is_some_and(|gap| gap.contains(x as i16, draw_rect.y)) {
                    continue;
                }
                draw_cell(
                    buf,
                    x,
                    y,
                    &symbol,
                    rstyle,
                    DrawCellClip {
                        clip: &clip,
                        buf_bounds: &buf_bounds,
                    },
                );
            }
        }
        Orientation::Vertical => {
            let x = draw_rect.x as i32;
            let start_y = draw_rect.y as i32;
            let end_y = draw_rect
                .y
                .saturating_add(draw_rect.h as i16)
                .saturating_sub(1) as i32;
            for y in start_y..=end_y {
                if gap_rect.is_some_and(|gap| gap.contains(draw_rect.x, y as i16)) {
                    continue;
                }
                draw_cell(
                    buf,
                    x,
                    y,
                    &symbol,
                    rstyle,
                    DrawCellClip {
                        clip: &clip,
                        buf_bounds: &buf_bounds,
                    },
                );
            }
        }
    }
}

pub(crate) fn render_divider_node(
    state: &mut RenderState<'_, '_, '_>,
    node: &crate::core::node::Node,
    divider_node: &crate::widgets::internal::DividerNode,
    rect: Rect,
    clip_bounds: Option<Rect>,
) {
    let label_rect = node.children.first().and_then(|child_id| {
        if !state.ctx.tree.is_valid(*child_id) {
            return None;
        }
        let child_offset = render_offset_for_node(state.ctx.tree, *child_id);
        let mut child_rect = child_offset.apply_to_rect(state.ctx.tree.node(*child_id).rect);
        child_rect.x = child_rect.x.saturating_add(state.content.x as i16);
        child_rect.y = child_rect.y.saturating_add(state.content.y as i16);
        Some(child_rect)
    });
    render_divider(
        state.f,
        divider_node.orientation,
        divider_node.ch,
        resolve_border_style(node.active_theme(), divider_node.style),
        rect,
        DividerRenderCtx {
            clip_rect: clip_bounds,
            label_rect,
            label_padding: divider_node.label_padding,
        },
    );

    if divider_node.join_frame {
        let mut parent_id = node.parent;
        let mut frame_rect = None;
        let mut frame_style = None;
        let mut frame_merge_mode = None;
        while let Some(id) = parent_id {
            let parent = state.ctx.tree.node(id);
            if let NodeKind::Frame(props) = &parent.kind {
                if props.has_border() {
                    let active = state.focus_chain.contains(&id);
                    let is_hovered = Some(id) == state.ctx.hovered;
                    let (style, _) = crate::backend::ratatui_backend::renderers::frame::render::resolve_block_style(props, active, is_hovered);
                    let parent_offset = render_offset_for_node(state.ctx.tree, id);
                    let mut absolute = parent_offset.apply_to_rect(parent.rect);
                    absolute.x = absolute.x.saturating_add(state.content.x as i16);
                    absolute.y = absolute.y.saturating_add(state.content.y as i16);
                    frame_rect = Some(absolute);
                    frame_style = Some(style);
                    frame_merge_mode = Some(props.border_merge_mode);
                }
                break;
            }
            parent_id = parent.parent;
        }
        if let (Some(frame_rect), Some(frame_style), Some(frame_merge_mode)) =
            (frame_rect, frame_style, frame_merge_mode)
            && frame_rect.w > 1
            && frame_rect.h > 1
        {
            let buf = state.f.buffer_mut();
            let buf_bounds = ClipBounds::from_rrect(buf.area);
            let border_style = to_ratatui_style(frame_style);
            let line_style = to_ratatui_style(resolve_border_style(
                node.active_theme(),
                divider_node.style,
            ));
            let symbol = divider_node.ch.to_string();
            let scroll_clip = scroll_view_clip_rect(state.ctx.tree, node.parent, state.content);
            let mut clip_rect = frame_rect;
            if let Some(clip) = scroll_clip {
                clip_rect = clip_rect.intersection(&clip);
            }
            if !clip_rect.is_empty() {
                let clip = ClipBounds::from_rect(clip_rect);
                let mut gap_rect = label_rect;
                if let Some(gap) = &mut gap_rect {
                    let pad = divider_node.label_padding as i16;
                    gap.x = gap.x.saturating_sub(pad);
                    gap.w = gap
                        .w
                        .saturating_add(divider_node.label_padding.saturating_mul(2));
                }

                match divider_node.orientation {
                    crate::widgets::Orientation::Horizontal => {
                        let y = rect.y;
                        if y > frame_rect.y
                            && y < frame_rect
                                .y
                                .saturating_add(frame_rect.h as i16)
                                .saturating_sub(1)
                        {
                            let start_x = frame_rect.x;
                            let end_x = frame_rect
                                .x
                                .saturating_add(frame_rect.w as i16)
                                .saturating_sub(1);

                            for x in start_x..=end_x {
                                if gap_rect.is_some_and(|gap| gap.contains(x, y)) {
                                    continue;
                                }

                                if !clip.contains(x as i32, y as i32)
                                    || !buf_bounds.contains(x as i32, y as i32)
                                {
                                    continue;
                                }

                                let Some(cell) = buf.cell_mut((x as u16, y as u16)) else {
                                    continue;
                                };

                                let at_border = x == start_x || x == end_x;
                                let endpoint_symbol = if x == start_x { "" } else { "" };
                                let merge_symbol = if at_border {
                                    endpoint_symbol
                                } else {
                                    symbol.as_str()
                                };
                                let should_merge = at_border
                                    && frame_merge_mode != BorderMergeMode::Replace
                                    && is_box_drawing_symbol(cell.symbol())
                                    && is_box_drawing_symbol(merge_symbol);

                                if should_merge {
                                    cell.merge_symbol(
                                        merge_symbol,
                                        to_merge_strategy(frame_merge_mode),
                                    );
                                    cell.set_style(border_style);
                                } else if at_border {
                                    // Keep existing border glyph/style when merge cannot apply.
                                } else {
                                    cell.set_symbol(symbol.as_str()).set_style(line_style);
                                }
                            }
                        }
                    }
                    crate::widgets::Orientation::Vertical => {
                        let x = rect.x;
                        if x > frame_rect.x
                            && x < frame_rect
                                .x
                                .saturating_add(frame_rect.w as i16)
                                .saturating_sub(1)
                        {
                            let start_y = frame_rect.y;
                            let end_y = frame_rect
                                .y
                                .saturating_add(frame_rect.h as i16)
                                .saturating_sub(1);

                            for y in start_y..=end_y {
                                if !clip.contains(x as i32, y as i32)
                                    || !buf_bounds.contains(x as i32, y as i32)
                                {
                                    continue;
                                }

                                let Some(cell) = buf.cell_mut((x as u16, y as u16)) else {
                                    continue;
                                };

                                let at_border = y == start_y || y == end_y;
                                let endpoint_symbol = if y == start_y { "" } else { "" };
                                let merge_symbol = if at_border {
                                    endpoint_symbol
                                } else {
                                    symbol.as_str()
                                };
                                let should_merge = at_border
                                    && frame_merge_mode != BorderMergeMode::Replace
                                    && is_box_drawing_symbol(cell.symbol())
                                    && is_box_drawing_symbol(merge_symbol);

                                if should_merge {
                                    cell.merge_symbol(
                                        merge_symbol,
                                        to_merge_strategy(frame_merge_mode),
                                    );
                                    cell.set_style(border_style);
                                } else if at_border {
                                    // Keep existing border glyph/style when merge cannot apply.
                                } else {
                                    cell.set_symbol(symbol.as_str()).set_style(line_style);
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}