oyo 0.1.71

CLI TUI for oyo - Step-through diff viewer
use ratatui::{
    layout::Rect,
    style::Style,
    text::{Line, Span},
};
use unicode_width::UnicodeWidthStr;

pub(crate) struct FooterAction<A> {
    pub(crate) action: Option<A>,
    pub(crate) spans: Vec<Span<'static>>,
}

impl<A> FooterAction<A> {
    pub(crate) fn new(action: Option<A>, spans: Vec<Span<'static>>) -> Self {
        Self { action, spans }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct FooterHit<A> {
    pub(crate) area: Rect,
    pub(crate) action: A,
}

pub(crate) fn action_at<A: Copy>(hits: &[FooterHit<A>], column: u16, row: u16) -> Option<A> {
    hits.iter()
        .find(|hit| hit.area.contains((column, row).into()))
        .map(|hit| hit.action)
}

pub(crate) fn footer_line<A: Copy>(
    area: Rect,
    border: Style,
    actions: Vec<FooterAction<A>>,
) -> (Line<'static>, Vec<FooterHit<A>>) {
    let width = usize::from(area.width);
    if width < 4 || area.height == 0 {
        let edge = match width {
            0 => String::new(),
            1 => "╯".to_owned(),
            _ => format!("╰{}╯", "─".repeat(width - 2)),
        };
        return (Line::from(Span::styled(edge, border)), Vec::new());
    }
    let mut spans = vec![Span::styled("╰ ", border)];
    let mut hits = Vec::new();
    let mut used = 0;
    for action in actions {
        let action_width = action
            .spans
            .iter()
            .map(|span| span.content.width())
            .sum::<usize>();
        let gap = if used == 0 { 0 } else { 4 };
        if action_width == 0 || used + gap + action_width > width - 4 {
            continue;
        }
        if gap > 0 {
            spans.push(Span::raw(" ".repeat(gap)));
        }
        if let Some(action) = action.action {
            hits.push(FooterHit {
                area: Rect::new(
                    area.x + 2 + (used + gap) as u16,
                    area.y,
                    action_width as u16,
                    1,
                ),
                action,
            });
        }
        spans.extend(action.spans);
        used += gap + action_width;
    }
    spans.push(Span::styled(
        format!(" {}╯", "─".repeat(width - used - 4)),
        border,
    ));
    (Line::from(spans), hits)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn footer_actions_own_only_their_visible_cells() {
        let area = Rect::new(3, 5, 32, 1);
        let (line, hits) = footer_line(
            area,
            Style::default(),
            vec![
                FooterAction::new(Some(1), vec![Span::raw("界 select")]),
                FooterAction::new(Some(2), vec![Span::raw("esc cancel")]),
            ],
        );
        assert_eq!(line.width(), 32);
        assert_eq!(action_at(&hits, 5, 5), Some(1));
        assert_eq!(action_at(&hits, 13, 5), Some(1));
        for x in [3, 4, 14, 15, 16, 17, 28, 34] {
            assert_eq!(action_at(&hits, x, 5), None);
        }
        assert_eq!(action_at(&hits, 18, 5), Some(2));
        assert_eq!(action_at(&hits, 27, 5), Some(2));
        assert_eq!(action_at(&hits, 18, 6), None);
    }

    #[test]
    fn omitted_actions_and_messages_never_create_click_targets() {
        for width in 0..40 {
            let area = Rect::new(0, 0, width, 1);
            let (line, hits) = footer_line(
                area,
                Style::default(),
                vec![
                    FooterAction::new(Some(1), vec![Span::raw("f6 f7 f8 select")]),
                    FooterAction::new(None, vec![Span::raw("Unavailable")]),
                    FooterAction::new(Some(2), vec![Span::raw("esc")]),
                ],
            );
            assert_eq!(line.width(), usize::from(width));
            let text = line
                .spans
                .iter()
                .map(|span| span.content.as_ref())
                .collect::<String>();
            assert_eq!(
                hits.iter().any(|hit| hit.action == 1),
                text.contains("f6 f7 f8 select")
            );
            assert_eq!(hits.iter().any(|hit| hit.action == 2), text.contains("esc"));
            for hit in hits {
                assert_eq!(hit.area.intersection(area), hit.area);
            }
        }
        let (_, hits) = footer_line(
            Rect::new(0, 0, 30, 0),
            Style::default(),
            vec![FooterAction::new(Some(1), vec![Span::raw("enter select")])],
        );
        assert!(hits.is_empty());
    }
}