tuible 0.0.2-alpha.1

A keyboard-driven database client for your terminal, built for both humans and AI agents.
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};

use crate::tui::state::Panel;

#[derive(Debug, Clone, Copy)]
pub struct AppLayout {
    pub tables: Rect,
    pub grid: Rect,
    pub sql: Rect,
    pub inspector: Rect,
    pub status: Rect,
}

#[cfg(test)]
pub fn compute(area: Rect) -> AppLayout {
    compute_with_sidebar(area, true)
}

#[cfg(test)]
pub fn compute_with_sidebar(area: Rect, sidebar_visible: bool) -> AppLayout {
    compute_with_panels(area, sidebar_visible, false, true)
}

pub fn compute_with_panels(
    area: Rect,
    sidebar_visible: bool,
    inspector_visible: bool,
    editor_visible: bool,
) -> AppLayout {
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .split(area);
    let constraints = match (sidebar_visible, inspector_visible) {
        (true, true) => [
            Constraint::Percentage(24),
            Constraint::Percentage(52),
            Constraint::Percentage(24),
        ],
        (true, false) => [
            Constraint::Percentage(28),
            Constraint::Percentage(72),
            Constraint::Length(0),
        ],
        (false, true) => [
            Constraint::Length(0),
            Constraint::Percentage(72),
            Constraint::Percentage(28),
        ],
        (false, false) => [
            Constraint::Length(0),
            Constraint::Percentage(100),
            Constraint::Length(0),
        ],
    };
    let body = Layout::default()
        .direction(Direction::Horizontal)
        .constraints(constraints)
        .split(rows[0]);
    let workspace_constraints = if editor_visible {
        [Constraint::Percentage(40), Constraint::Percentage(60)]
    } else {
        [Constraint::Length(0), Constraint::Percentage(100)]
    };
    let workspace = Layout::default()
        .direction(Direction::Vertical)
        .constraints(workspace_constraints)
        .split(body[1]);
    AppLayout {
        tables: body[0],
        grid: workspace[1],
        sql: workspace[0],
        inspector: body[2],
        status: rows[1],
    }
}

pub fn hit_test(layout: &AppLayout, x: u16, y: u16) -> Option<Panel> {
    let pos = Position::new(x, y);
    if layout.tables.contains(pos) {
        Some(Panel::Tables)
    } else if layout.grid.contains(pos) {
        Some(Panel::Grid)
    } else if layout.sql.contains(pos) {
        Some(Panel::Sql)
    } else if layout.inspector.contains(pos) {
        Some(Panel::Inspector)
    } else {
        None
    }
}

pub fn filter_rows_area(panel: Rect) -> Rect {
    let inner = Rect::new(
        panel.x.saturating_add(1),
        panel.y.saturating_add(1),
        panel.width.saturating_sub(2),
        panel.height.saturating_sub(2),
    );
    Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner)[0]
}

pub fn filter_control_constraints(width: u16) -> [Constraint; 7] {
    // Toggle, Apply, remove, add, and six separators consume 16 cells.
    let flexible = width.saturating_sub(16);
    let column = flexible.saturating_mul(30) / 100;
    let operator = flexible.saturating_mul(35) / 100;
    let value = flexible.saturating_sub(column).saturating_sub(operator);
    [
        Constraint::Length(3),
        Constraint::Length(column),
        Constraint::Length(operator),
        Constraint::Length(value),
        Constraint::Length(5),
        Constraint::Length(1),
        Constraint::Length(1),
    ]
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used, clippy::unwrap_used)]

    use super::*;

    fn layout() -> AppLayout {
        compute(Rect::new(0, 0, 80, 24))
    }

    #[test]
    fn splits_area_into_four_regions() {
        let l = layout();
        assert_eq!(l.tables.x, 0);
        assert_eq!(l.grid.x, l.tables.width);
        assert_eq!(l.sql.x, l.tables.width);
        assert_eq!(l.grid.y, l.sql.bottom());
        assert_eq!(l.tables.height, l.sql.height + l.grid.height);
        assert_eq!(l.status.height, 1);
        assert_eq!(l.status.y, 23);
    }

    #[test]
    fn hit_test_maps_coordinates_to_panels() {
        let l = layout();
        assert_eq!(hit_test(&l, 5, 5), Some(Panel::Tables));
        assert_eq!(hit_test(&l, 60, 5), Some(Panel::Sql));
        assert_eq!(hit_test(&l, 40, 21), Some(Panel::Grid));
        assert_eq!(hit_test(&l, 40, 23), None);
    }

    #[test]
    fn filter_controls_fit_the_available_width() {
        for width in [30, 42, 80] {
            let constraints = filter_control_constraints(width);
            let controls = Layout::horizontal(constraints)
                .spacing(1)
                .split(Rect::new(0, 0, width, 1));

            assert_eq!(controls[0].x, 0);
            assert_eq!(controls[6].right(), width);
        }
    }

    #[test]
    fn collapsed_sidebar_gives_workspace_the_full_width() {
        let l = compute_with_sidebar(Rect::new(0, 0, 80, 24), false);
        assert_eq!(l.tables.width, 0);
        assert_eq!(l.sql.x, 0);
        assert_eq!(l.grid.x, 0);
        assert_eq!(l.sql.width, 80);
    }

    #[test]
    fn record_inspector_uses_the_right_side() {
        let l = compute_with_panels(Rect::new(0, 0, 100, 30), true, true, true);
        assert_eq!(l.tables.x, 0);
        assert_eq!(l.inspector.right(), 100);
        assert!(l.inspector.width > 0);
        assert_eq!(l.grid.right(), l.inspector.x);
    }

    #[test]
    fn hidden_editor_gives_data_the_full_workspace_height() {
        let l = compute_with_panels(Rect::new(0, 0, 100, 30), true, true, false);

        assert_eq!(l.sql.height, 0);
        assert_eq!(l.grid.y, 0);
        assert_eq!(l.grid.height, 29);
    }
}