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::utils::math::round_mul_div;
use crate::utils::scrollbar::ScrollbarMetrics;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ScrollViewWindow {
    pub offset: usize,
    pub max_offset: usize,
    pub visible_rows: usize,
    pub top_indicator: bool,
    pub bottom_indicator: bool,
    pub bottom_count: usize,
}

pub(crate) fn calc_scroll_view_window(
    offset: usize,
    total_rows: usize,
    viewport_rows: usize,
    show_scroll_indicators: bool,
) -> ScrollViewWindow {
    if total_rows == 0 || viewport_rows == 0 {
        return ScrollViewWindow {
            offset: 0,
            max_offset: 0,
            visible_rows: 0,
            top_indicator: false,
            bottom_indicator: false,
            bottom_count: 0,
        };
    }

    if !show_scroll_indicators || total_rows <= viewport_rows {
        let max_offset = total_rows.saturating_sub(viewport_rows);
        let offset = offset.min(max_offset);
        return ScrollViewWindow {
            offset,
            max_offset,
            visible_rows: total_rows.saturating_sub(offset).min(viewport_rows),
            top_indicator: false,
            bottom_indicator: false,
            bottom_count: 0,
        };
    }

    let max_offset = total_rows.saturating_sub(viewport_rows).saturating_add(1);
    let offset = offset.min(max_offset);

    let top_indicator = offset >= 2;
    let top_reserved = top_indicator as usize;

    let slots_with_bottom = viewport_rows.saturating_sub(top_reserved).saturating_sub(1);
    let end_with_bottom = offset.saturating_add(slots_with_bottom).min(total_rows);
    let hidden_below_with_bottom = total_rows.saturating_sub(end_with_bottom);

    if slots_with_bottom > 0 && hidden_below_with_bottom >= 2 {
        ScrollViewWindow {
            offset,
            max_offset,
            visible_rows: slots_with_bottom,
            top_indicator,
            bottom_indicator: true,
            bottom_count: hidden_below_with_bottom,
        }
    } else {
        let visible_rows = viewport_rows.saturating_sub(top_reserved);
        ScrollViewWindow {
            offset,
            max_offset,
            visible_rows: total_rows.saturating_sub(offset).min(visible_rows),
            top_indicator,
            bottom_indicator: false,
            bottom_count: 0,
        }
    }
}

pub(crate) fn normalize_input_offset(
    previous_offset: usize,
    target_offset: usize,
    total_rows: usize,
    viewport_rows: usize,
    show_scroll_indicators: bool,
) -> usize {
    let window = calc_scroll_view_window(
        target_offset,
        total_rows,
        viewport_rows,
        show_scroll_indicators,
    );
    let offset = window.offset;

    if show_scroll_indicators && offset == 1 {
        if previous_offset > offset {
            0
        } else {
            2.min(window.max_offset)
        }
    } else {
        offset
    }
}

pub(crate) fn scroll_view_scrollbar_metrics(
    offset: usize,
    total_rows: usize,
    viewport_rows: usize,
    track_size: usize,
    show_scroll_indicators: bool,
    half_cell: bool,
) -> Option<ScrollbarMetrics> {
    if total_rows == 0 || viewport_rows == 0 || track_size == 0 || total_rows <= viewport_rows {
        return None;
    }

    let effective_track = if half_cell {
        track_size.saturating_mul(2)
    } else {
        track_size
    };
    let window = calc_scroll_view_window(offset, total_rows, viewport_rows, show_scroll_indicators);
    let clamped_offset = window.offset.min(window.max_offset);

    let mut thumb_len = round_mul_div(effective_track, viewport_rows, total_rows)
        .max(1)
        .min(effective_track);
    if total_rows > viewport_rows && effective_track > 1 && thumb_len == effective_track {
        thumb_len = effective_track - 1;
    }

    let max_thumb_start = effective_track.saturating_sub(thumb_len);
    let compressed_max_offset = if show_scroll_indicators && window.max_offset > 0 {
        window.max_offset.saturating_sub(1)
    } else {
        window.max_offset
    };
    let compressed_offset = if show_scroll_indicators && clamped_offset > 0 {
        clamped_offset.saturating_sub(1)
    } else {
        clamped_offset
    };

    let mut thumb_start = if compressed_max_offset == 0 {
        0
    } else {
        round_mul_div(max_thumb_start, compressed_offset, compressed_max_offset)
    };
    if max_thumb_start > 0 && clamped_offset < window.max_offset && thumb_start == max_thumb_start {
        thumb_start = thumb_start.saturating_sub(1);
    }

    Some(ScrollbarMetrics {
        thumb_len,
        thumb_start,
        max_thumb_start,
        max_offset: window.max_offset,
    })
}

#[cfg(test)]
mod tests {
    use super::{calc_scroll_view_window, normalize_input_offset, scroll_view_scrollbar_metrics};

    #[test]
    fn suppresses_one_above_and_one_below_indicators() {
        let window = calc_scroll_view_window(1, 100, 10, true);
        assert_eq!(window.offset, 1);
        assert!(!window.top_indicator);
        assert!(window.bottom_indicator);

        let window = calc_scroll_view_window(5, 10, 6, true);
        assert_eq!(window.offset, 5);
        assert!(window.top_indicator);
        assert!(!window.bottom_indicator);
    }

    #[test]
    fn keeps_bottom_slot_for_true_bottom_only() {
        let before_bottom = scroll_view_scrollbar_metrics(5, 20, 10, 10, true, false)
            .expect("metrics before bottom");
        let at_bottom =
            scroll_view_scrollbar_metrics(11, 20, 10, 10, true, false).expect("metrics at bottom");

        assert!(before_bottom.thumb_start < before_bottom.max_thumb_start);
        assert_eq!(at_bottom.thumb_start, at_bottom.max_thumb_start);
    }

    #[test]
    fn half_cell_metrics_share_compressed_indicator_mapping() {
        let top = scroll_view_scrollbar_metrics(0, 20, 10, 10, true, true).expect("top metrics");
        let one_hidden =
            scroll_view_scrollbar_metrics(1, 20, 10, 10, true, true).expect("one hidden");
        let two_hidden =
            scroll_view_scrollbar_metrics(2, 20, 10, 10, true, true).expect("two hidden");

        assert_eq!(top.thumb_start, one_hidden.thumb_start);
        assert!(two_hidden.thumb_start > top.thumb_start);
    }

    #[test]
    fn input_normalization_skips_hidden_one_above_when_scrolling_up() {
        assert_eq!(normalize_input_offset(4, 1, 12, 8, true), 0);
    }

    #[test]
    fn input_normalization_skips_hidden_one_above_when_scrolling_down() {
        assert_eq!(normalize_input_offset(0, 1, 12, 8, true), 2);
    }
}