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::Buffer;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

use crate::backend::ratatui_backend::common::{draw_cell_clipped, to_ratatui_style};
use crate::backend::ratatui_backend::render::RenderState;
use crate::core::node::NodeId;
use crate::style::resolve::{resolve_base_style, resolve_force_accent_style};
use crate::style::{Padding, Rect, Style, ThemeRole, resolve_slot};
use crate::utils::gradient::ColorGradient;

pub(crate) struct SliderRenderCtx<'a> {
    pub style: Style,
    pub filled_track_style: Style,
    pub filled_track_gradient: Option<ColorGradient>,
    pub thumb_style: Style,
    pub thumb_gradient: Option<ColorGradient>,
    pub label: Option<&'a str>,
    pub label_style: Style,
    pub show_value: bool,
    pub padding: Padding,
    pub is_focused: bool,
    pub is_hovered: bool,
    pub disabled: bool,
    pub disabled_style: Style,
    pub hover_style: Style,
    pub focus_style: Style,
    pub focus_thumb_style: Style,
    pub hover_thumb_style: Style,
    pub thumb_symbol: &'a str,
    pub track_symbol: &'a str,
    pub filled_track_symbol: &'a str,
    pub hover_thumb_symbol: Option<&'a str>,
    pub clip_rect: Option<Rect>,
}

pub(crate) fn render_slider(
    buf: &mut Buffer,
    value: f64,
    min: f64,
    max: f64,
    area: Rect,
    ctx: SliderRenderCtx<'_>,
) {
    let SliderRenderCtx {
        style,
        filled_track_style,
        filled_track_gradient,
        thumb_style,
        thumb_gradient,
        label,
        label_style,
        show_value,
        padding,
        is_focused,
        is_hovered,
        disabled,
        disabled_style,
        hover_style,
        focus_style,
        focus_thumb_style,
        hover_thumb_style,
        thumb_symbol,
        track_symbol,
        filled_track_symbol,
        hover_thumb_symbol,
        clip_rect,
    } = ctx;
    let inner = area.inset(padding);

    let y = inner.y as i32 + (inner.h.saturating_sub(1) / 2) as i32;
    let mut x = inner.x as i32;
    let mut w = inner.w as i32;

    if w <= 0 {
        return;
    }

    let mut style = style;
    let mut filled_track_style = filled_track_style;
    let mut thumb_style = thumb_style;
    let mut label_style = label_style;
    if disabled {
        style = style.patch(disabled_style);
        filled_track_style = filled_track_style.patch(disabled_style);
        thumb_style = thumb_style.patch(disabled_style);
        label_style = label_style.patch(disabled_style);
    }

    if let Some(label_text) = label {
        let label_w = UnicodeWidthStr::width(label_text) as i32;
        if w > label_w {
            render_text_clipped(buf, label_text, label_style, x, y, label_w, clip_rect);
            x = x.saturating_add(label_w + 1);
            w = w.saturating_sub(label_w + 1);
        }
    }

    if show_value {
        let value_w = crate::widgets::slider::value_slot_width(min, max) as i32;
        let value_text = format!("{:.1}", value);
        let value_text = format!("{:>width$}", value_text, width = value_w.max(0) as usize);

        if w > value_w {
            render_text_clipped(
                buf,
                &value_text,
                style,
                x.saturating_add(w).saturating_sub(value_w),
                y,
                value_w,
                clip_rect,
            );
            w = w.saturating_sub(value_w + 1);
        }
    }

    if w <= 0 {
        return;
    }

    let range = max - min;
    let progress = if range > 0.0 {
        (value - min) / range
    } else {
        0.0
    };
    let progress = progress.clamp(0.0, 1.0);

    let track_len = w.saturating_sub(1) as f64;
    let thumb_pos = (progress * track_len).round() as i32;

    let mut current_thumb_style = thumb_style;
    let mut current_track_style = style;
    let mut current_filled_track_style = filled_track_style;
    if !disabled {
        if is_hovered {
            current_thumb_style = current_thumb_style.patch(hover_thumb_style);
            current_track_style = current_track_style.patch(hover_style);
            current_filled_track_style = current_filled_track_style.patch(hover_style);
        }
        if is_focused {
            current_thumb_style = current_thumb_style.patch(focus_thumb_style);
            current_track_style = current_track_style.patch(focus_style);
            current_filled_track_style = current_filled_track_style.patch(focus_style);
        }
    }

    let mut current_thumb_char = thumb_symbol;
    if !disabled
        && is_hovered
        && let Some(hover_sym) = hover_thumb_symbol
    {
        current_thumb_char = hover_sym;
    }

    let track_rstyle = to_ratatui_style(current_track_style);

    for i in 0..w {
        let cx = x + i;
        if i == thumb_pos {
            let thumb_style = if let Some(gradient) = thumb_gradient {
                current_thumb_style.patch(Style::new().fg(gradient.color_at(progress)))
            } else {
                current_thumb_style
            };
            draw_cell_clipped(
                buf,
                cx,
                y,
                current_thumb_char,
                to_ratatui_style(thumb_style),
                clip_rect,
            );
        } else if i < thumb_pos {
            let filled_style = if let Some(gradient) = filled_track_gradient {
                let t = if w <= 1 {
                    1.0
                } else {
                    i as f64 / (w - 1) as f64
                };
                current_filled_track_style.patch(Style::new().fg(gradient.color_at(t)))
            } else {
                current_filled_track_style
            };
            draw_cell_clipped(
                buf,
                cx,
                y,
                filled_track_symbol,
                to_ratatui_style(filled_style),
                clip_rect,
            );
        } else {
            draw_cell_clipped(buf, cx, y, track_symbol, track_rstyle, clip_rect);
        }
    }
}

fn render_text_clipped(
    buf: &mut Buffer,
    content: &str,
    style: Style,
    x: i32,
    y: i32,
    max_width: i32,
    clip_rect: Option<Rect>,
) {
    if max_width <= 0 {
        return;
    }

    let rstyle = to_ratatui_style(style);
    let mut curr_x = x;
    for g in content.graphemes(true) {
        let cw = UnicodeWidthStr::width(g) as i32;
        if cw == 0 {
            continue;
        }
        if curr_x - x + cw > max_width {
            break;
        }
        draw_cell_clipped(buf, curr_x, y, g, rstyle, clip_rect);
        curr_x += cw;
    }
}

pub(crate) fn render_slider_node(
    state: &mut RenderState<'_, '_, '_>,
    node_id: NodeId,
    node: &crate::widgets::internal::SliderNode,
    rect: Rect,
    _rrect: ratatui::layout::Rect,
    clip_bounds: Option<Rect>,
) {
    let is_focused = Some(node_id) == state.ctx.focused && !node.disabled;
    let is_hovered = Some(node_id) == state.ctx.hovered && !node.disabled;
    let theme = state.ctx.tree.node(node_id).active_theme();
    render_slider(
        state.f.buffer_mut(),
        node.value,
        node.min,
        node.max,
        rect,
        SliderRenderCtx {
            style: resolve_base_style(theme, node.style),
            filled_track_style: resolve_force_accent_style(theme, node.filled_track_style),
            filled_track_gradient: node.filled_track_gradient,
            thumb_style: resolve_force_accent_style(theme, node.thumb_style),
            thumb_gradient: node.thumb_gradient,
            label: node.label.as_deref(),
            label_style: resolve_base_style(theme, node.label_style),
            show_value: node.show_value,
            padding: node.padding,
            is_focused,
            is_hovered,
            disabled: node.disabled,
            disabled_style: crate::backend::ratatui_backend::renderers::theme::with_theme_muted(
                theme,
                node.disabled_style,
            ),
            hover_style: resolve_slot(theme, ThemeRole::Hover, &node.hover_style),
            focus_style: resolve_slot(theme, ThemeRole::Focus, &node.focus_style),
            focus_thumb_style: resolve_slot(theme, ThemeRole::Focus, &node.focus_thumb_style),
            hover_thumb_style: resolve_slot(theme, ThemeRole::Hover, &node.hover_thumb_style),
            thumb_symbol: &node.thumb_symbol,
            track_symbol: &node.track_symbol,
            filled_track_symbol: &node.filled_track_symbol,
            hover_thumb_symbol: node.hover_thumb_symbol.as_deref(),
            clip_rect: clip_bounds,
        },
    );
}