tui-lipan 0.2.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
use super::layout::{terminal_content_layout, terminal_lines};
use super::mod_private::Terminal;
use super::node::TerminalNode;
use super::screen::TerminalViewport;
use super::selection::{ScrollbackLineage, rebase_selection};
use crate::core::node::{NodeId, NodeKind, NodeTree};
use crate::style::{LayoutConstraints, Rect, ScrollbarVariant};

pub(crate) fn reconcile_terminal(
    tree: &mut NodeTree,
    id: NodeId,
    terminal: &Terminal,
    rect: Rect,
    constraints: &LayoutConstraints,
) -> NodeId {
    // A live screen stands in for the snapshot the caller would otherwise have passed, so layout,
    // the scrollbar and the scroll rules below all keep working off one code path. The pre-draw
    // refresh then only has to catch output that arrived since this pass.
    let live = terminal.screen.as_ref().map(|screen| {
        let snapshot = screen.snapshot();
        if terminal.decorations.is_empty() {
            snapshot
        } else {
            snapshot.decorated(&terminal.decorations)
        }
    });
    let lines = match &live {
        Some(snapshot) => snapshot.color_lines.clone(),
        None => terminal
            .color_lines
            .clone()
            .unwrap_or_else(|| terminal_lines(terminal.content.as_ref())),
    };
    let total_scrollback_rows = live
        .as_ref()
        .map_or(terminal.total_scrollback_rows, |snapshot| {
            snapshot.total_scrollback_rows
        });
    let snapshot_scrollback_offset = live
        .as_ref()
        .map_or(terminal.scrollback_offset, |snapshot| {
            snapshot.scrollback_offset
        });

    let avail_w = rect.w;
    let avail_h = rect.h;
    let mut rect = rect;
    let parent_v_edge = tree.parent_frame_integrated_v_edge(id).unwrap_or(false);
    let scrollbar_visible = terminal.scrollbar && total_scrollback_rows > 0;
    let scrollbar_integrated = scrollbar_visible
        && matches!(terminal.scrollbar_variant, ScrollbarVariant::Integrated)
        && (terminal.border || parent_v_edge);
    let _scrollbar_cols = if scrollbar_visible && !scrollbar_integrated {
        1
    } else {
        0
    };

    // Note: Terminal builder should already have handled Length::Auto via measure_terminal,
    // but we ensure constraints are applied here.
    rect.w = constraints.clamp_width(rect.w, avail_w);
    rect.h = constraints.clamp_height(rect.h, avail_h);

    // Compute viewport rows (inner area height).
    let inner = rect.inner(terminal.border, terminal.padding);
    let layout = terminal_content_layout(
        inner,
        terminal.border,
        terminal.scrollbar,
        terminal.scrollbar_variant,
        terminal.scrollbar_gap,
        total_scrollback_rows,
        parent_v_edge,
    );
    let viewport_rows = layout.content_rect.h as usize;
    let viewport_cols = layout.content_rect.w as usize;

    // Read previous scroll state from existing node to persist across frames.
    let (
        old_scroll_override,
        old_snapshot_scrollback_offset,
        old_selection,
        old_lineage,
        old_viewport_rows,
        old_viewport_cols,
    ) = if let NodeKind::Terminal(old) = &tree.node(id).kind {
        (
            old.scroll_override,
            old.snapshot_scrollback_offset,
            old.selection,
            old.lineage,
            old.viewport_rows,
            old.viewport_cols,
        )
    } else {
        (
            None,
            snapshot_scrollback_offset,
            None,
            ScrollbackLineage::default(),
            0,
            0,
        )
    };

    // Determine effective scrollback offset:
    // - Preserve an input-driven override while the parent snapshot is unchanged.
    // - A changed snapshot is authoritative and clears the override.
    let snapshot_offset_changed = snapshot_scrollback_offset != old_snapshot_scrollback_offset;
    let scroll_override = if snapshot_offset_changed {
        None
    } else {
        old_scroll_override
    };
    let scrollback_offset = scroll_override.unwrap_or(snapshot_scrollback_offset);
    let snapshot_lineage = live
        .as_ref()
        .map_or(ScrollbackLineage::default(), |snapshot| ScrollbackLineage {
            evicted_lines: snapshot.evicted_lines,
            history_epoch: snapshot.history_epoch,
        });
    let selection = {
        let candidate = if terminal.selection_controlled {
            terminal.selection
        } else {
            old_selection.or(terminal.selection)
        };
        rebase_selection(candidate, old_lineage, snapshot_lineage)
    };

    if let Some(cb) = terminal.on_resize.as_ref()
        && (viewport_cols != old_viewport_cols || viewport_rows != old_viewport_rows)
    {
        let cols = viewport_cols.max(1).min(u16::MAX as usize) as u16;
        let rows = viewport_rows.max(1).min(u16::MAX as usize) as u16;
        cb.emit(TerminalViewport { cols, rows });
    }

    let node = tree.node_mut(id);
    node.rect = rect;
    node.children.clear();
    node.kind = NodeKind::Terminal(TerminalNode {
        lines,
        cursor_row: live.as_ref().map_or(terminal.cursor_row, |s| s.cursor_row),
        cursor_col: live.as_ref().map_or(terminal.cursor_col, |s| s.cursor_col),
        // A live screen reports what the child program wants; `show_cursor` is what the app allows.
        cursor_visible: live.as_ref().map_or(terminal.show_cursor, |s| {
            s.cursor_visible && terminal.show_cursor
        }),
        cursor_shape: live
            .as_ref()
            .map_or(terminal.cursor_shape, |s| s.cursor_shape),
        cursor_blinking: live
            .as_ref()
            .map_or(terminal.cursor_blinking, |s| s.cursor_blinking),
        caret_color: terminal.caret_color,
        selection,
        selection_controlled: terminal.selection_controlled,
        lineage: snapshot_lineage,
        selection_style: terminal.selection_style,
        mouse_mode: live.as_ref().map_or(terminal.mouse_mode, |s| s.mouse_mode),
        key_modes: live.as_ref().map_or(terminal.key_modes, |s| s.key_modes),
        #[cfg(feature = "terminal-images")]
        images: live
            .as_ref()
            .map_or_else(|| terminal.images.clone(), |s| s.images.clone()),
        screen: terminal.screen.clone(),
        decorations: terminal.decorations.clone(),
        live_sequence: live.as_ref().map(|s| s.sequence),
        show_cursor_requested: terminal.show_cursor,
        paste_shortcut_behavior: terminal.paste_shortcut_behavior,
        on_selection: terminal.on_selection.clone(),
        on_mouse_forward: terminal.on_mouse_forward.clone(),
        style: terminal.style,
        hover_style: terminal.hover_style,
        focus_style: terminal.focus_style,
        focus_content_style: terminal.focus_content_style,
        border: terminal.border,
        border_style: terminal.border_style,
        padding: terminal.padding,
        scrollback_offset,
        snapshot_scrollback_offset,
        total_scrollback_rows,
        viewport_rows,
        viewport_cols,
        scroll_wheel: terminal.scroll_wheel,
        scroll_override,
        scrollbar: terminal.scrollbar,
        scrollbar_variant: terminal.scrollbar_variant,
        scrollbar_gap: terminal.scrollbar_gap,
        scrollbar_thumb: terminal.scrollbar_thumb,
        scrollbar_thumb_style: terminal.scrollbar_thumb_style,
        scrollbar_thumb_focus_style: terminal.scrollbar_thumb_focus_style,
        scrollbar_track_style: terminal.scrollbar_track_style,
        on_scroll: terminal.on_scroll.clone(),
        on_scroll_to: terminal.on_scroll_to.clone(),
        focusable: terminal.focusable,
        tab_stop: terminal.tab_stop,
        on_focus: terminal.on_focus.clone(),
        on_blur: terminal.on_blur.clone(),
        on_key: terminal.on_key.clone(),
        on_input: terminal.on_input.clone(),
    });

    tree.register_scrollbar_zone(id);

    id
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Element;
    use crate::layout::LayoutEngine;
    use crate::widgets::{Terminal, TerminalRenderSnapshot};

    fn reconcile(tree: &mut NodeTree, scrollback_offset: usize) {
        let terminal: Element = Terminal::new()
            .snapshot(TerminalRenderSnapshot {
                scrollback_offset,
                total_scrollback_rows: 100,
                ..TerminalRenderSnapshot::default()
            })
            .into();
        LayoutEngine::reconcile_with_focus(
            tree,
            &terminal,
            Rect {
                x: 0,
                y: 0,
                w: 80,
                h: 24,
            },
            None,
        );
    }

    #[test]
    fn changed_snapshot_offset_clears_mouse_scroll_override() {
        let mut tree = NodeTree::new();
        reconcile(&mut tree, 10);

        let NodeKind::Terminal(node) = &mut tree.node_mut(tree.root).kind else {
            panic!("expected terminal root");
        };
        node.scrollback_offset = 20;
        node.scroll_override = Some(20);

        reconcile(&mut tree, 0);

        let NodeKind::Terminal(node) = &tree.node(tree.root).kind else {
            panic!("expected terminal root");
        };
        assert_eq!(node.scrollback_offset, 0);
        assert_eq!(node.scroll_override, None);
        assert_eq!(node.snapshot_scrollback_offset, 0);
    }

    #[test]
    fn unchanged_snapshot_preserves_pending_mouse_scroll_override() {
        let mut tree = NodeTree::new();
        reconcile(&mut tree, 10);

        let NodeKind::Terminal(node) = &mut tree.node_mut(tree.root).kind else {
            panic!("expected terminal root");
        };
        node.scrollback_offset = 20;
        node.scroll_override = Some(20);

        reconcile(&mut tree, 10);

        let NodeKind::Terminal(node) = &tree.node(tree.root).kind else {
            panic!("expected terminal root");
        };
        assert_eq!(node.scrollback_offset, 20);
        assert_eq!(node.scroll_override, Some(20));
    }
}