mecha10-cli 0.8.0

Mecha10 CLI tool
Documentation
//! Shared layout and footer helpers for the ratatui-based TUI screens.
//!
//! Keeps the common content/footer split and `key: label` footer styling in one place.

use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
    Frame,
};

/// Split `area` into exactly two rects along `direction` using `constraints`, returning
/// `(first, second)`.
fn split_two(area: Rect, direction: Direction, constraints: [Constraint; 2]) -> (Rect, Rect) {
    let chunks = Layout::default()
        .direction(direction)
        .constraints(constraints)
        .split(area);
    (chunks[0], chunks[1])
}

/// Split a full-frame area into `(content_area, footer_area)` using the
/// standard `[Constraint::Min(10), Constraint::Length(3)]` vertical layout
/// shared by every TUI screen in this module.
pub fn standard_tui_layout(area: Rect) -> (Rect, Rect) {
    split_two(area, Direction::Vertical, [Constraint::Min(10), Constraint::Length(3)])
}

/// Split an area horizontally into a 60%/40% main panel + sidebar, the
/// layout shared by `DevModeTui` and `TeleopUi`.
pub fn main_and_sidebar_layout(area: Rect) -> (Rect, Rect) {
    split_two(
        area,
        Direction::Horizontal,
        [Constraint::Percentage(60), Constraint::Percentage(40)],
    )
}

/// Split an area horizontally into two panels using the given left-panel
/// percentage (the right panel gets the remainder), the layout shared by
/// `TopologyTui` (30/70 nav/details) and `TopicsListTui` (40/60 topics/messages).
pub fn two_panel_layout(area: Rect, left_percent: u16) -> (Rect, Rect) {
    split_two(
        area,
        Direction::Horizontal,
        [
            Constraint::Percentage(left_percent),
            Constraint::Percentage(100 - left_percent),
        ],
    )
}

/// Render the "Dashboard / Docs / quit" footer shared by `DevModeTui` and `TeleopUi`: a
/// dashboard link, a link to the docs, and a quit hint whose key label differs slightly between
/// the two screens (`quit_label`).
pub fn draw_dashboard_footer(f: &mut Frame, area: Rect, dashboard_url: &str, quit_label: &str) {
    render_footer_hints(
        f,
        area,
        &[
            ("Dashboard", dashboard_url, Color::Cyan),
            ("Docs", "https://mecha10.dev", Color::Yellow),
            (quit_label, "Exit", Color::Red),
        ],
    );
}

pub trait SidebarTui {
    /// Draw the main (left) content panel.
    fn draw_main(&mut self, f: &mut Frame, area: Rect);
    /// Draw the sidebar (right) panel.
    fn draw_sidebar(&self, f: &mut Frame, area: Rect);
    /// Draw the footer.
    fn draw_footer(&self, f: &mut Frame, area: Rect);

    /// Draw the full screen: main/sidebar on top, footer below.
    fn draw(&mut self, f: &mut Frame) {
        let (content_area, footer_area) = standard_tui_layout(f.area());
        let (main_area, sidebar_area) = main_and_sidebar_layout(content_area);

        self.draw_main(f, main_area);
        self.draw_sidebar(f, sidebar_area);
        self.draw_footer(f, footer_area);
    }
}

pub trait TwoPanelTui {
    /// Percentage width of the left panel (the right panel gets the remainder).
    fn left_panel_percent(&self) -> u16;
    /// Draw the left panel.
    fn draw_left(&mut self, f: &mut Frame, area: Rect);
    /// Draw the right panel.
    fn draw_right(&mut self, f: &mut Frame, area: Rect);
    /// Draw the footer.
    fn draw_footer(&self, f: &mut Frame, area: Rect);

    /// Draw the full screen: left/right panels on top, footer below.
    fn draw(&mut self, f: &mut Frame) {
        let (content_area, footer_area) = standard_tui_layout(f.area());
        let (left_area, right_area) = two_panel_layout(content_area, self.left_panel_percent());

        self.draw_left(f, left_area);
        self.draw_right(f, right_area);
        self.draw_footer(f, footer_area);
    }
}

/// Render a footer bar of `key: label` keybinding hints, styled consistently across TUIs.
///
/// `hints` is a list of `(key, label, color)` triples.
pub fn render_footer_hints(f: &mut Frame, area: Rect, hints: &[(&str, &str, Color)]) {
    let mut spans = vec![Span::raw("  ")];

    for (i, (key, label, color)) in hints.iter().enumerate() {
        spans.push(Span::styled(
            format!("{}:", key),
            Style::default().fg(*color).add_modifier(Modifier::BOLD),
        ));

        let trailing = if i + 1 == hints.len() { "" } else { "  " };
        spans.push(Span::raw(format!(" {}{}", label, trailing)));
    }

    let footer = Paragraph::new(vec![Line::from(spans)]).block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray)),
    );

    f.render_widget(footer, area);
}

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

    #[test]
    fn standard_tui_layout_splits_content_and_footer() {
        let area = Rect::new(0, 0, 100, 40);
        let (content, footer) = standard_tui_layout(area);

        assert_eq!(footer.height, 3);
        assert_eq!(content.height, 37);
        assert_eq!(content.y, 0);
        assert_eq!(footer.y, 37);
    }

    #[test]
    fn two_panel_layout_splits_by_given_percentage() {
        let area = Rect::new(0, 0, 100, 40);
        let (left, right) = two_panel_layout(area, 30);

        assert_eq!(left.width, 30);
        assert_eq!(right.width, 70);
        assert_eq!(left.x, 0);
        assert_eq!(right.x, 30);
    }

    #[test]
    fn main_and_sidebar_layout_splits_60_40() {
        let area = Rect::new(0, 0, 100, 40);
        let (main, sidebar) = main_and_sidebar_layout(area);

        assert_eq!(main.width, 60);
        assert_eq!(sidebar.width, 40);
        assert_eq!(main.x, 0);
        assert_eq!(sidebar.x, 60);
    }

    #[test]
    fn two_panel_layout_matches_main_and_sidebar_at_60_40() {
        let area = Rect::new(0, 0, 100, 40);
        let (left, right) = two_panel_layout(area, 60);
        let (expected_main, expected_sidebar) = main_and_sidebar_layout(area);

        assert_eq!((left.width, left.x), (expected_main.width, expected_main.x));
        assert_eq!((right.width, right.x), (expected_sidebar.width, expected_sidebar.x));
    }
}