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;
const CHROME_ABOVE_TREE: u16 = 2;
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), Constraint::Length(1), Constraint::Length(tree_height), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.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);
}
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);
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();
let left_text = format!(" {state_glyph} {indent}{expand_glyph} {name}");
let left_len = left_text.chars().count();
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 => {
row_modifier |= Modifier::DIM;
None
}
HiddenKind::Gitignored => {
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), Constraint::Min(0), Constraint::Length(1), ])
.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]);
}