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 ratatui::buffer::Cell as BufferCell;
use ratatui::style::Color as RColor;

use crate::backend::ratatui_backend::common::{
    apply_effect_style_clipped, from_ratatui_color, to_ratatui_color, to_ratatui_rect,
};
use crate::backend::ratatui_backend::render::{AnimatedRestoreSnapshot, blend_ratatui_toward};
use crate::style::{ColorTransform, Rect, Style};
use crate::widgets::internal::AnimatedNode;

pub(crate) fn render_animated(
    f: &mut ratatui::Frame<'_>,
    node: &AnimatedNode,
    rect: Rect,
    clip_rect: Option<Rect>,
    underlay: Option<&AnimatedRestoreSnapshot>,
    terminal_bg: Option<ratatui::style::Color>,
) {
    let opacity = node.opacity.clamp(0.0, 1.0);
    let has_fg_override = node.current_fg.is_some() || node.inherited_fg_exit.is_some();
    let has_bg_override = node.current_bg.is_some() || node.inherited_bg_exit.is_some();
    if opacity >= 1.0 && !has_fg_override && !has_bg_override {
        return;
    }

    if has_fg_override || has_bg_override {
        let mut draw_rect = rect;
        if let Some(clip) = clip_rect {
            draw_rect = draw_rect.intersection(&clip);
        }
        if !draw_rect.is_empty() {
            let r_rect = crate::backend::ratatui_backend::common::to_ratatui_rect(draw_rect);
            let intersection = f.area().intersection(r_rect);
            if intersection.width > 0 && intersection.height > 0 {
                let fg = node.current_fg.map(to_ratatui_color);
                let bg = node.current_bg.map(to_ratatui_color);
                let inherited_fg = node
                    .inherited_fg_exit
                    .as_ref()
                    .map(|exit| (exit.target, exit.progress.current().clamp(0.0, 1.0)));
                let inherited_bg = node
                    .inherited_bg_exit
                    .as_ref()
                    .map(|exit| (exit.target, exit.progress.current().clamp(0.0, 1.0)));
                let buf = f.buffer_mut();
                for y in intersection.y..intersection.y + intersection.height {
                    for x in intersection.x..intersection.x + intersection.width {
                        if let Some(cell) = buf.cell_mut((x, y)) {
                            if let Some(fg) = fg {
                                cell.fg = fg;
                            } else if let Some((target, progress)) = inherited_fg {
                                cell.fg = to_ratatui_color(
                                    from_ratatui_color(cell.fg).blend_toward(target, progress),
                                );
                            }
                            if let Some(bg) = bg {
                                cell.bg = bg;
                            } else if let Some((target, progress)) = inherited_bg {
                                let source = non_reset(cell.bg).or(terminal_bg).unwrap_or(cell.bg);
                                cell.bg = to_ratatui_color(
                                    from_ratatui_color(source).blend_toward(target, progress),
                                );
                            }
                        }
                    }
                }
            }
        }
    }

    if opacity >= 1.0 {
        return;
    }

    if node.opacity_target.is_none()
        && let Some(underlay) = underlay
    {
        composite_opacity_over_underlay(
            f,
            node.opacity_fg_only,
            rect,
            clip_rect,
            underlay,
            terminal_bg,
            opacity,
        );
        return;
    }

    let opacity_tf = if let Some(target) = node.opacity_target {
        ColorTransform::OpacityToward {
            factor: opacity,
            target,
        }
    } else {
        ColorTransform::Opacity(opacity)
    };

    let mut style = Style::new().transform_fg(opacity_tf);
    if !node.opacity_fg_only {
        style = style.transform_bg(opacity_tf);
    }

    apply_effect_style_clipped(f, rect, style, clip_rect, terminal_bg);
}

fn composite_opacity_over_underlay(
    f: &mut ratatui::Frame<'_>,
    fg_only: bool,
    rect: Rect,
    clip_rect: Option<Rect>,
    underlay: &AnimatedRestoreSnapshot,
    terminal_bg: Option<RColor>,
    opacity: f32,
) {
    let mut draw_rect = rect;
    if let Some(clip) = clip_rect {
        draw_rect = draw_rect.intersection(&clip);
    }
    if draw_rect.is_empty() {
        return;
    }

    let r_rect = to_ratatui_rect(draw_rect);
    let intersection = f.area().intersection(r_rect);
    if intersection.width == 0 || intersection.height == 0 {
        return;
    }

    let buf = f.buffer_mut();
    for y in intersection.y..intersection.y + intersection.height {
        for x in intersection.x..intersection.x + intersection.width {
            let Some(saved) = underlay.cell_at(x, y) else {
                continue;
            };
            let Some(cell) = buf.cell_mut((x, y)) else {
                continue;
            };
            if cells_match(cell, saved) {
                continue;
            }

            let mut dim_cell = false;
            if !fg_only {
                let source_fallback = non_reset(saved.bg).or(terminal_bg);
                let (bg, dim) =
                    blend_ratatui_toward(cell.bg, saved.bg, source_fallback, terminal_bg, opacity);
                cell.bg = bg;
                dim_cell |= dim;
            }

            let fg_target = non_reset(cell.bg)
                .or_else(|| non_reset(saved.bg))
                .or(terminal_bg);
            let (fg, dim) = blend_ratatui_toward(
                cell.fg,
                fg_target.unwrap_or(RColor::Reset),
                None,
                terminal_bg,
                opacity,
            );
            cell.fg = fg;
            dim_cell |= dim;
            if dim_cell {
                cell.set_style(cell.style().add_modifier(ratatui::style::Modifier::DIM));
            }
        }
    }
}

fn cells_match(cell: &BufferCell, saved: &BufferCell) -> bool {
    cell.symbol() == saved.symbol()
        && cell.fg == saved.fg
        && cell.bg == saved.bg
        && cell.underline_color == saved.underline_color
        && cell.modifier == saved.modifier
}

fn non_reset(color: RColor) -> Option<RColor> {
    (color != RColor::Reset).then_some(color)
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use ratatui::Terminal;
    use ratatui::backend::TestBackend;
    use ratatui::style::Color as RColor;

    use super::render_animated;
    use crate::animation::{Easing, ExitAnimation};
    use crate::style::{Color, Rect};
    use crate::widgets::{Animated, Text};

    #[test]
    fn inherited_exit_colors_blend_from_the_rendered_child_colors() {
        let rect = Rect {
            x: 0,
            y: 0,
            w: 1,
            h: 1,
        };
        let mut node = crate::widgets::internal::AnimatedNode::from(
            Animated::new(Text::new("x")).auto_exit(
                ExitAnimation::new(100)
                    .keep_opacity()
                    .fg(Color::Rgb(220, 240, 200))
                    .bg(Color::Rgb(200, 220, 240))
                    .easing(Easing::Linear),
            ),
        );
        assert!(node.begin_auto_exit(None));
        node.tick(Duration::from_millis(50));
        assert!(node.current_fg.is_none() && node.current_bg.is_none());

        let backend = TestBackend::new(1, 1);
        let mut terminal = Terminal::new(backend).expect("terminal should initialize");
        terminal
            .draw(|frame| {
                let cell = frame.buffer_mut().cell_mut((0, 0)).expect("cell");
                cell.fg = RColor::Rgb(20, 40, 60);
                cell.bg = RColor::Reset;
                render_animated(
                    frame,
                    &node,
                    rect,
                    None,
                    None,
                    Some(RColor::Rgb(60, 40, 20)),
                );
            })
            .expect("draw should succeed");

        let cell = &terminal.backend().buffer()[(0, 0)];
        assert_eq!(cell.fg, RColor::Rgb(120, 140, 130));
        assert_eq!(cell.bg, RColor::Rgb(130, 130, 130));
    }
}