tact 0.6.2

Terminal interface for Nanocodex
//! Shared chrome and layout for centered modal components.

use crate::tui::theme::Theme;
use ratatui::{
    Frame,
    layout::{Alignment, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, BorderType, Borders, Clear, Paragraph},
};
use unicode_width::UnicodeWidthStr;

const KEY_BINDING_SEPARATOR: &str = " · ";

pub(super) struct Floating<'a> {
    title: &'a str,
    width: u16,
    height: u16,
    key_bindings: &'a [&'a str],
    placement: Placement,
    border_color: Option<Color>,
    title_color: Option<Color>,
}

#[derive(Clone, Copy, Default)]
enum Placement {
    #[default]
    Center,
    Top,
}

pub(super) struct FloatingLayout {
    pub(super) body: Rect,
}

impl<'a> Floating<'a> {
    pub(super) const fn new(
        title: &'a str,
        width: u16,
        height: u16,
        key_bindings: &'a [&'a str],
    ) -> Self {
        Self {
            title,
            width,
            height,
            key_bindings,
            placement: Placement::Center,
            border_color: None,
            title_color: None,
        }
    }

    pub(super) const fn at_top(mut self) -> Self {
        self.placement = Placement::Top;
        self
    }

    pub(super) const fn colors(mut self, border: Color, title: Color) -> Self {
        self.border_color = Some(border);
        self.title_color = Some(title);
        self
    }

    pub(super) fn render(self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) -> FloatingLayout {
        let popup = match self.placement {
            Placement::Center => centered(area, self.width, self.height),
            Placement::Top => top_centered(area, self.width, self.height),
        };
        let border_color = self.border_color.unwrap_or_else(|| theme.border());
        let title_color = self.title_color.unwrap_or_else(|| theme.accent());
        let mut block = Block::new()
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded)
            .border_style(Style::default().fg(border_color));
        if !self.title.is_empty() {
            block = block
                .title(format!(" {} ", self.title))
                .title_alignment(Alignment::Center)
                .title_style(
                    Style::default()
                        .fg(title_color)
                        .add_modifier(Modifier::BOLD),
                );
        }
        let inner = block.inner(popup);
        frame.render_widget(Clear, popup);
        frame.render_widget(block, popup);

        let key_bindings = self.key_binding_lines(inner.width);
        let footer_height = u16::try_from(key_bindings.len()).unwrap_or(u16::MAX);
        let (body, footer) = split_footer(inner, footer_height);
        if !footer.is_empty() {
            frame.render_widget(
                Paragraph::new(key_bindings)
                    .style(Style::default().fg(theme.border()))
                    .alignment(Alignment::Center),
                footer,
            );
        }
        FloatingLayout { body }
    }

    fn key_binding_lines(&self, width: u16) -> Vec<Line<'a>> {
        if width == 0 {
            return Vec::new();
        }

        let width = usize::from(width);
        let separator_width = KEY_BINDING_SEPARATOR.width();
        let mut lines = Vec::new();
        let mut spans = Vec::new();
        let mut line_width = 0;
        for key_binding in self.key_bindings {
            let key_binding_width = key_binding.width();
            if !spans.is_empty() && line_width + separator_width + key_binding_width > width {
                lines.push(Line::from(std::mem::take(&mut spans)));
                line_width = 0;
            }
            if !spans.is_empty() {
                spans.push(Span::raw(KEY_BINDING_SEPARATOR));
                line_width += separator_width;
            }
            spans.push(Span::raw(*key_binding));
            line_width += key_binding_width;
        }
        if !spans.is_empty() {
            lines.push(Line::from(spans));
        }
        lines
    }
}

fn centered(area: Rect, width: u16, height: u16) -> Rect {
    let width = width.min(area.width);
    let height = height.min(area.height);
    Rect {
        x: area.x + area.width.saturating_sub(width) / 2,
        y: area.y + area.height.saturating_sub(height) / 2,
        width,
        height,
    }
}

fn top_centered(area: Rect, width: u16, height: u16) -> Rect {
    let width = width.min(area.width);
    Rect {
        x: area.x + area.width.saturating_sub(width) / 2,
        y: area.y,
        width,
        height: height.min(area.height),
    }
}

fn split_footer(inner: Rect, footer_height: u16) -> (Rect, Rect) {
    let footer_height = footer_height.min(inner.height);
    if footer_height == 0 {
        return (inner, Rect::default());
    }
    let footer = Rect {
        y: inner.bottom() - footer_height,
        height: footer_height,
        ..inner
    };
    let body = Rect {
        height: inner.height - footer_height,
        ..inner
    };
    (body, footer)
}

#[cfg(test)]
mod tests {
    use super::Floating;
    use crate::tui::theme::Theme;
    use ratatui::{Terminal, backend::TestBackend, layout::Rect, style::Color};

    #[test]
    fn floating_centers_rounded_chrome_and_border_colored_key_bindings() {
        let mut terminal = Terminal::new(TestBackend::new(20, 8)).unwrap();

        terminal
            .draw(|frame| {
                Floating::new("Test", 16, 6, &["left", "right"]).render(
                    frame,
                    frame.area(),
                    &Theme::default(),
                );
            })
            .unwrap();

        let buffer = terminal.backend().buffer();
        assert_eq!(buffer[(2, 1)].symbol(), "");
        assert_eq!(buffer[(17, 6)].symbol(), "");
        assert_eq!(buffer[(4, 5)].symbol(), "l");
        assert_eq!(buffer[(9, 5)].symbol(), "·");
        assert_eq!(buffer[(9, 5)].fg, Color::DarkGray);
    }

    #[test]
    fn floating_wraps_key_bindings_without_clipping_menu_help() {
        let mut terminal = Terminal::new(TestBackend::new(30, 10)).unwrap();
        let mut body = Rect::default();

        terminal
            .draw(|frame| {
                body = Floating::new(
                    "Test",
                    24,
                    8,
                    &["first option", "second option", "third option"],
                )
                .render(frame, frame.area(), &Theme::default())
                .body;
            })
            .unwrap();

        let buffer = terminal.backend().buffer();
        let rendered = (0..buffer.area.height)
            .map(|row| {
                (0..buffer.area.width)
                    .map(|column| buffer[(column, row)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");
        assert_eq!(body.height, 3);
        assert!(rendered.contains("first option"));
        assert!(rendered.contains("second option"));
        assert!(rendered.contains("third option"));
    }
}