rootle 0.13.1

Modal TUI for browsing remote source-control systems from the terminal
Documentation
//! Component contract (PLAN.md §6). Components own state, emit Actions,
//! render into a caller-given Rect. See .agents/skills/rootle-component.

pub mod browser;
pub mod clone_wizard;
pub mod command_line;
pub mod commit;
pub mod consent_popup;
pub mod global_search;
pub mod keybinds_popup;
pub mod list_view;
pub mod modeline;
pub mod pane;
pub mod preview;
pub mod refs_popup;
pub mod search_popup;
pub mod settings_popup;
pub(crate) mod text;
pub mod vim_input;

use crate::action::Action;
use crate::theme::Theme;
use ratatui::Frame;
use ratatui::crossterm::event::KeyEvent;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};

pub trait Component {
    fn handle_key(&mut self, key: KeyEvent) -> Action;
    fn update(&mut self, action: &Action);
    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme);
}

/// Centered percentage rect, shared by popups (search popup shell,
/// scope picker, future dialogs).
pub(crate) fn centered(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
    let vertical = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - pct_y) / 2),
            Constraint::Percentage(pct_y),
            Constraint::Percentage((100 - pct_y) / 2),
        ])
        .split(area);
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - pct_x) / 2),
            Constraint::Percentage(pct_x),
            Constraint::Percentage((100 - pct_x) / 2),
        ])
        .split(vertical[1])[1]
}

/// centered() with a size floor. Popups have a minimum usable layout
/// (input row + a bordered results box + hints); below it the fixed
/// rows guillotine and content leaks past the border (0023 breaker,
/// 40×10). Inflate to the minimum, recentered, never exceeding the
/// viewport itself.
pub(crate) fn centered_clamped(area: Rect, pct_x: u16, pct_y: u16, min_w: u16, min_h: u16) -> Rect {
    let mut rect = centered(area, pct_x, pct_y);
    if rect.width < min_w {
        rect.width = min_w.min(area.width);
        rect.x = area.x + (area.width - rect.width) / 2;
    }
    if rect.height < min_h {
        rect.height = min_h.min(area.height);
        rect.y = area.y + (area.height - rect.height) / 2;
    }
    rect
}

/// Pretty scrollbar embedded in the RIGHT border of a bordered box:
/// the track is the border itself (│, surface2), the thumb a bold
/// accent column (┃). No-op when the content fits. `total` = content
/// lines, `offset` = index of the top visible line.
pub(crate) fn scrollbar(
    frame: &mut Frame,
    outer: Rect,
    content_height: usize,
    total: usize,
    offset: usize,
    theme: &Theme,
) {
    let track = content_height;
    if total <= track || track == 0 || outer.height < 2 || outer.width == 0 {
        return;
    }
    let thumb = (track * track / total).max(1);
    let max_offset = total - track;
    let pos = offset.min(max_offset) * (track - thumb) / max_offset.max(1);
    let sem = &theme.semantic;
    let buf = frame.buffer_mut();
    for i in 0..track {
        let x = outer.x + outer.width - 1;
        let y = outer.y + 1 + i as u16;
        if x >= buf.area().width || y >= buf.area().height {
            break;
        }
        let cell = &mut buf[(x, y)];
        if i >= pos && i < pos + thumb {
            cell.set_symbol("┃");
            cell.set_style(
                Style::default()
                    .fg(sem.border_focused)
                    .add_modifier(Modifier::BOLD),
            );
        } else {
            cell.set_symbol("│");
            cell.set_style(Style::default().fg(sem.surface2));
        }
    }
}