upgate-presentation 0.1.0

Keep globally installed developer tools up to date
Documentation
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Tabs};

use crate::tui::theme::TuiTheme;

const LEFT_OVERFLOW_HINT_WIDTH: usize = 5;
const LEFT_OVERFLOW_HINT: &str = " ⇧+⇥ ";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisibleTabs {
    pub start: usize,
    pub selected_in_slice: usize,
    pub titles: Vec<Line<'static>>,
    pub has_left_overflow: bool,
}

pub fn visible_tabs(
    all_titles: &[Line<'static>],
    selected: usize,
    previous_offset: usize,
    available_width: u16,
) -> VisibleTabs {
    if all_titles.is_empty() {
        return VisibleTabs {
            start: 0,
            selected_in_slice: 0,
            titles: Vec::new(),
            has_left_overflow: false,
        };
    }

    let selected = selected.min(all_titles.len() - 1);
    let mut start = previous_offset.min(selected);
    let available_width = usize::from(available_width);

    while start < selected
        && tabs_width(&all_titles[start..=selected]) + left_overflow_hint_width(start > 0)
            > available_width
    {
        start += 1;
    }

    let mut used = left_overflow_hint_width(start > 0);
    let mut titles = Vec::new();
    for title in all_titles.iter().skip(start) {
        let title_width = tab_width(title);
        if used + title_width > available_width && !titles.is_empty() {
            break;
        }

        titles.push(title.clone());
        used += title_width;
    }

    let selected_in_slice = selected
        .saturating_sub(start)
        .min(titles.len().saturating_sub(1));
    VisibleTabs {
        start,
        selected_in_slice,
        titles,
        has_left_overflow: start > 0,
    }
}

pub fn render_tabs(frame: &mut Frame<'_>, area: Rect, tabs: VisibleTabs, theme: &TuiTheme) {
    if area.is_empty() {
        return;
    }

    let left_hint_width = if tabs.has_left_overflow {
        area.width
            .min(u16::try_from(LEFT_OVERFLOW_HINT_WIDTH).unwrap_or(u16::MAX))
    } else {
        0
    };

    let [left_area, tabs_area] =
        Layout::horizontal([Constraint::Length(left_hint_width), Constraint::Fill(1)]).areas(area);

    if tabs.has_left_overflow && left_hint_width > 0 {
        frame.render_widget(left_overflow_hint(theme), left_area);
    }

    if !tabs.titles.is_empty() {
        let widget = Tabs::new(tabs.titles)
            .select(tabs.selected_in_slice)
            .style(theme.normal)
            .highlight_style(theme.keycap)
            .divider("")
            .padding(" ", " ");
        frame.render_widget(widget, tabs_area);
    }
}

fn left_overflow_hint(theme: &TuiTheme) -> Paragraph<'static> {
    Paragraph::new(Line::from(Span::styled(LEFT_OVERFLOW_HINT, theme.keycap)))
}

fn tabs_width(titles: &[Line<'_>]) -> usize {
    titles.iter().map(tab_width).sum()
}

fn tab_width(title: &Line<'_>) -> usize {
    title.width() + 2
}

const fn left_overflow_hint_width(enabled: bool) -> usize {
    if enabled { LEFT_OVERFLOW_HINT_WIDTH } else { 0 }
}