haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Rendering layer for the `haz init` scaffold TUI.
//!
//! Pure ratatui drawing: every function takes a `&mut Frame` plus
//! read-only state and emits widgets. No IO and no state mutation,
//! so the layout logic is isolated from the dispatcher and the
//! terminal driver. Exercised by hand against a real terminal; the
//! `Frame`-typed signatures need `ratatui::TestBackend` to unit
//! test, which is left to a future pass.

use std::path::Path;

use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};

use crate::commands::init::commit::CommitPlan;
use crate::commands::init::model::{HiddenKind, SelectionState, VisibleNode};
use crate::commands::init::tui::{TuiFocus, TuiState};

const COLOR_PROJECT: Color = Color::Green;
const COLOR_PROJECTS_PARENT: Color = Color::Cyan;

/// Lines occupied by chrome ABOVE the tree (title + cwd).
const CHROME_ABOVE_TREE: u16 = 2;
/// Lines occupied by chrome BELOW the tree (spacer + commit
/// button + status + keys). The tree is sized to fit its
/// visible rows up to the height that leaves room for this
/// chrome; remaining vertical space falls into the trailing
/// `Min(0)` chunk so the chrome sits close to the tree on
/// tall terminals instead of being pushed to the screen bottom.
const CHROME_BELOW_TREE: u16 = 4;

pub(super) fn render_tree_screen(frame: &mut Frame<'_>, state: &TuiState) {
    let area = frame.area();

    let visible_count = u16::try_from(state.tree.visible_nodes().len()).unwrap_or(u16::MAX);
    let available_for_tree = area
        .height
        .saturating_sub(CHROME_ABOVE_TREE + CHROME_BELOW_TREE);
    let tree_height = visible_count.min(available_for_tree).max(1);

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),           // 0: title
            Constraint::Length(1),           // 1: cwd
            Constraint::Length(tree_height), // 2: tree
            Constraint::Length(1),           // 3: spacer
            Constraint::Length(1),           // 4: commit button
            Constraint::Length(1),           // 5: status
            Constraint::Length(1),           // 6: keybindings
            Constraint::Min(0),              // 7: empty trailer
        ])
        .split(area);

    render_title(frame, chunks[0]);
    render_cwd(frame, chunks[1], &state.cwd_host);
    render_tree(frame, chunks[2], state);
    render_commit_button(frame, chunks[4], state);
    render_status(frame, chunks[5], state.status.as_deref());
    render_keys(frame, chunks[6]);

    if state.help_visible {
        render_help_overlay(frame, area);
    }
}

fn render_title(frame: &mut Frame<'_>, area: Rect) {
    let para = Paragraph::new(Line::from(Span::styled(
        " haz init \u{2014} select directories to register as projects ",
        Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
    )));
    frame.render_widget(para, area);
}

fn render_cwd(frame: &mut Frame<'_>, area: Rect, cwd: &Path) {
    let text = format!(" cwd: {}", cwd.display());
    let para = Paragraph::new(Line::from(Span::raw(text)));
    frame.render_widget(para, area);
}

fn render_tree(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
    let visible = state.tree.visible_nodes();
    let width = area.width as usize;
    let items: Vec<ListItem<'static>> = visible
        .iter()
        .map(|vn| ListItem::new(build_row_line(vn, width)))
        .collect();

    let list = List::new(items).highlight_style(
        Style::default()
            .add_modifier(Modifier::REVERSED)
            .add_modifier(Modifier::BOLD),
    );

    let mut list_state = ListState::default();
    if state.focus == TuiFocus::Tree {
        list_state.select(Some(state.tree.focused_visible_index()));
    }
    frame.render_stateful_widget(list, area, &mut list_state);
}

/// Fixed column gap between the row's content and its italic
/// state label. The label sits this many columns to the right of
/// the name, regardless of terminal width, so it does not
/// migrate across the screen on resize and stays close to the
/// row it describes.
const GAP_TO_RIGHT_LABEL: usize = 4;

fn build_row_line(vn: &VisibleNode<'_>, area_width: usize) -> Line<'static> {
    let (state_glyph, state_glyph_style, state_text, state_text_style) = state_decor(vn.node.state);

    // Every Node in the model is a directory (the read_dir
    // filter drops non-directories), so every row is
    // expandable: the triangle just reflects expanded vs
    // collapsed, never "leaf".
    let expand_glyph = if vn.node.expanded {
        "\u{25BC}" // â–¼
    } else {
        "\u{25B6}" // â–¶
    };

    let indent: String = " ".repeat(vn.depth * 2);
    let name = vn.node.name.to_string_lossy().into_owned();

    // Left-hand block: state glyph, depth indent, expand glyph, name.
    let left_text = format!(" {state_glyph} {indent}{expand_glyph} {name}");
    let left_len = left_text.chars().count();

    // Italic state label sits at a fixed gap after the row's
    // name. When even that minimum-gap layout overflows the
    // terminal, drop the label rather than let ratatui clip it
    // mid-glyph.
    let right_text = state_text.to_string();
    let right_len = right_text.chars().count();
    let has_room_for_right_label =
        !right_text.is_empty() && left_len + GAP_TO_RIGHT_LABEL + right_len <= area_width;
    let pad_width = GAP_TO_RIGHT_LABEL;

    let mut row_modifier = Modifier::empty();
    if vn.locked {
        row_modifier |= Modifier::DIM;
    }
    let hidden_fg = match vn.node.hidden_kind {
        HiddenKind::Visible => None,
        HiddenKind::Dotfile => {
            // Dotfile rows dim only; no foreground recolour, so
            // they read as "background" content.
            row_modifier |= Modifier::DIM;
            None
        }
        HiddenKind::Gitignored => {
            // Gitignored rows dim AND recolour to a warning
            // orange (RGB 204/102/0), per the design's
            // "obviously here but you probably don't want it"
            // intent.
            row_modifier |= Modifier::DIM;
            Some(Color::Rgb(204, 102, 0))
        }
    };

    let glyph_style = state_glyph_style.add_modifier(row_modifier);
    let plain_style = match hidden_fg {
        Some(fg) => Style::default().fg(fg).add_modifier(row_modifier),
        None => Style::default().add_modifier(row_modifier),
    };
    let italic_style = state_text_style.add_modifier(row_modifier);

    let mut spans = vec![
        Span::styled(" ", plain_style),
        Span::styled(state_glyph.to_string(), glyph_style),
        Span::styled(format!(" {indent}{expand_glyph} {name}"), plain_style),
    ];
    if has_room_for_right_label {
        spans.push(Span::styled(" ".repeat(pad_width), plain_style));
        spans.push(Span::styled(right_text, italic_style));
    }
    Line::from(spans)
}

fn state_decor(state: SelectionState) -> (&'static str, Style, &'static str, Style) {
    match state {
        SelectionState::None => (
            "\u{25A1}", // â–¡
            Style::default(),
            "",
            Style::default(),
        ),
        SelectionState::Project => (
            "\u{25A0}", // â– 
            Style::default()
                .fg(COLOR_PROJECT)
                .add_modifier(Modifier::BOLD),
            "(project)",
            Style::default()
                .fg(COLOR_PROJECT)
                .add_modifier(Modifier::ITALIC),
        ),
        SelectionState::ProjectsParent => (
            "\u{25A3}", // â–£
            Style::default()
                .fg(COLOR_PROJECTS_PARENT)
                .add_modifier(Modifier::BOLD),
            "(projects parent)",
            Style::default()
                .fg(COLOR_PROJECTS_PARENT)
                .add_modifier(Modifier::ITALIC),
        ),
    }
}

fn render_commit_button(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
    let count = state.tree.project_root_set().len();
    let label = format!(
        "  [ Commit selection ({count} project{plural}) ]",
        count = count,
        plural = if count == 1 { "" } else { "s" },
    );
    let style = if state.focus == TuiFocus::CommitButton {
        Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
    } else {
        Style::default().add_modifier(Modifier::BOLD)
    };
    let para = Paragraph::new(Line::from(Span::styled(label, style)));
    frame.render_widget(para, area);
}

fn render_status(frame: &mut Frame<'_>, area: Rect, status: Option<&str>) {
    let text = status.unwrap_or("");
    let para = Paragraph::new(Line::from(Span::styled(
        format!(" {text}"),
        Style::default().fg(Color::Yellow),
    )));
    frame.render_widget(para, area);
}

fn render_keys(frame: &mut Frame<'_>, area: Rect) {
    let line = Line::from(vec![
        Span::raw(" \u{2191}\u{2193} move  "),
        Span::raw("\u{2192} expand  "),
        Span::raw("\u{2190} collapse  "),
        Span::raw("Space cycle selection  "),
        Span::raw("Enter commit  "),
        Span::raw("H hidden  "),
        Span::raw("q cancel  "),
        Span::raw("? help"),
    ])
    .style(Style::default().add_modifier(Modifier::DIM));
    let para = Paragraph::new(line);
    frame.render_widget(para, area);
}

fn render_help_overlay(frame: &mut Frame<'_>, area: Rect) {
    let inner = centred_rect(area, 60, 18);
    let block = Block::bordered().title(" help ").bold();
    let body = vec![
        Line::from("Navigation"),
        Line::from("  \u{2191} / \u{2193}        move focus through visible rows"),
        Line::from("  \u{2192}            expand focused directory"),
        Line::from("  \u{2190}            collapse focused directory"),
        Line::from(""),
        Line::from("Selection (AUX-003 / AUX-004)"),
        Line::from("  Space        cycle: none -> project -> projects-parent"),
        Line::from(""),
        Line::from("Commit"),
        Line::from("  Enter        leave selection, show confirmation"),
        Line::from(""),
        Line::from("Other"),
        Line::from("  H            toggle hidden directories"),
        Line::from("  ?            toggle this overlay"),
        Line::from("  q / Esc      cancel (exit with no changes)"),
        Line::from("  Ctrl+C       abort"),
    ];
    let para = Paragraph::new(body).block(block).wrap(Wrap { trim: false });
    frame.render_widget(Clear, inner);
    frame.render_widget(para, inner);
}

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

pub(super) fn render_confirmation_screen(frame: &mut Frame<'_>, plan: &CommitPlan) {
    let area = frame.area();
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1), // title
            Constraint::Min(0),    // body
            Constraint::Length(1), // keys
        ])
        .split(area);

    let title = Paragraph::new(Line::from(Span::styled(
        " haz init \u{2014} confirm scaffold ",
        Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
    )));
    frame.render_widget(title, chunks[0]);

    let mut lines: Vec<Line<'static>> = Vec::new();
    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        " will write .haz/workspace.yml:",
        Style::default().add_modifier(Modifier::BOLD),
    )));
    for line in plan.workspace_yml_body.lines() {
        lines.push(Line::from(format!("   {line}")));
    }
    lines.push(Line::from(""));
    if plan.project_ymls.is_empty() {
        lines.push(Line::from(Span::styled(
            " will scaffold no haz.yml files (empty projects: list).",
            Style::default().add_modifier(Modifier::ITALIC),
        )));
    } else {
        lines.push(Line::from(Span::styled(
            " will scaffold haz.yml:",
            Style::default().add_modifier(Modifier::BOLD),
        )));
        for scaffold in &plan.project_ymls {
            lines.push(Line::from(format!(
                "   {root}        name: {name}",
                root = scaffold.project_root.display(),
                name = scaffold.sanitised_name,
            )));
        }
    }
    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        " Proceed?  y = write, n / q = decline",
        Style::default().add_modifier(Modifier::BOLD),
    )));

    let para = Paragraph::new(lines).wrap(Wrap { trim: false });
    frame.render_widget(para, chunks[1]);

    let keys = Paragraph::new(Line::from(vec![
        Span::raw(" y proceed  "),
        Span::raw("n decline  "),
        Span::raw("q / Esc cancel  "),
        Span::raw("Ctrl+C abort"),
    ]))
    .style(Style::default().add_modifier(Modifier::DIM));
    frame.render_widget(keys, chunks[2]);
}