use crate::tui::app::{App, ConfirmAction, FocusedPane, InputAction, Mode};
use crate::tui::widgets::{render_details, render_diff, render_reorder_preview, render_stack_tree};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame,
};
pub fn render(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), Constraint::Length(4), ])
.split(f.area());
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(38), Constraint::Percentage(62)])
.split(chunks[0]);
render_stack_tree(f, app, main_chunks[0]);
if matches!(app.mode, Mode::Reorder)
|| matches!(app.mode, Mode::Confirm(ConfirmAction::ApplyReorder))
{
render_reorder_preview(f, app, main_chunks[1]);
} else {
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10), Constraint::Min(3), ])
.split(main_chunks[1]);
render_details(f, app, right_chunks[0]);
render_diff(f, app, right_chunks[1]);
}
render_status_bar(f, app, chunks[1]);
match &app.mode {
Mode::Help => render_help_modal(f),
Mode::Confirm(action) => render_confirm_modal(f, action),
Mode::Input(action) => render_input_modal(f, action, &app.input_buffer, app.input_cursor),
_ => {}
}
}
fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
let status_line = if let Some(msg) = &app.status_message {
Line::from(Span::styled(
msg.clone(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))
} else {
match app.mode {
Mode::Normal => {
let (focus_label, focus_color, focus_hint) = match app.focused_pane {
FocusedPane::Stack => (" STACK ", Color::Cyan, "browse branches"),
FocusedPane::Diff => (" PATCH ", Color::Green, "scroll patch"),
};
let branch_count = app.branches.len();
Line::from(vec![
Span::styled(
focus_label,
Style::default()
.fg(Color::Black)
.bg(focus_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{} branches", branch_count),
Style::default().fg(Color::White),
),
Span::styled(" • ", Style::default().fg(Color::DarkGray)),
Span::styled(focus_hint, Style::default().fg(Color::DarkGray)),
])
}
Mode::Search => Line::from(vec![
Span::styled("/", Style::default().fg(Color::Cyan)),
Span::raw(" filtering branches "),
Span::styled("Type", Style::default().fg(Color::Cyan)),
Span::raw(" to narrow "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(" close search"),
]),
Mode::Help => Line::from("Press any key to close"),
Mode::Confirm(_) => Line::from(vec![
Span::styled("y", Style::default().fg(Color::Cyan)),
Span::raw(" confirm "),
Span::styled("n/Esc", Style::default().fg(Color::Cyan)),
Span::raw(" cancel"),
]),
Mode::Input(_) => Line::from(vec![
Span::styled("⏎", Style::default().fg(Color::Cyan)),
Span::raw(" confirm "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(" cancel"),
]),
Mode::Reorder => Line::from(vec![
Span::styled(
" ◀ REORDER ▶ ",
Style::default()
.fg(Color::Black)
.bg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Shift+↑/↓", Style::default().fg(Color::Magenta)),
Span::raw(" move branch in stack "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" apply "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(" cancel"),
]),
}
};
let shortcuts_line = match app.mode {
Mode::Normal => build_normal_shortcuts(app),
Mode::Search => Line::from(vec![
key_hint("↑↓", Color::Cyan),
Span::raw(" navigate "),
key_hint("Enter", Color::Green),
Span::raw(" checkout "),
key_hint("Esc", Color::Cyan),
Span::raw(" cancel"),
]),
Mode::Help => Line::from(vec![Span::styled(
"? closes this dialog",
Style::default().fg(Color::DarkGray),
)]),
Mode::Confirm(_) => Line::from(vec![
key_hint("y", Color::Green),
Span::raw(" confirm "),
key_hint("Esc", Color::Red),
Span::raw(" cancel"),
]),
Mode::Input(_) => Line::from(vec![
key_hint("Enter", Color::Green),
Span::raw(" accept "),
key_hint("Esc", Color::Red),
Span::raw(" cancel"),
]),
Mode::Reorder => Line::from(vec![
key_hint("Shift+↑↓", Color::Magenta),
Span::raw(" move "),
key_hint("Enter", Color::Green),
Span::raw(" apply "),
key_hint("Esc", Color::Red),
Span::raw(" cancel"),
]),
};
let paragraph = Paragraph::new(vec![status_line, shortcuts_line])
.block(Block::default().borders(Borders::ALL));
f.render_widget(paragraph, area);
}
fn build_normal_shortcuts(app: &App) -> Line<'static> {
let mut spans = vec![
key_hint("↑↓", Color::Cyan),
Span::raw(" move "),
key_hint("Tab", Color::Cyan),
Span::raw(" pane "),
];
if let Some(branch) = app.selected_branch() {
let (label, action, color) = if !branch.is_current {
("Enter", "checkout", Color::Green)
} else if branch.is_trunk {
("n", "new", Color::Green)
} else if branch.needs_restack {
("r", "restack", Color::Yellow)
} else if branch.pr_number.is_some() {
("p", "PR", Color::Cyan)
} else {
("s", "submit", Color::Green)
};
spans.push(key_hint(label, color));
spans.push(Span::raw(format!(" {} ", action)));
}
spans.push(key_hint("/", Color::Cyan));
spans.push(Span::raw(" search "));
spans.push(key_hint("?", Color::Yellow));
spans.push(Span::raw(" help "));
spans.push(key_hint("q", Color::Cyan));
spans.push(Span::raw(" quit"));
Line::from(spans)
}
fn key_hint(label: &str, color: Color) -> Span<'static> {
Span::styled(
format!(" {} ", label),
Style::default()
.fg(Color::Black)
.bg(color)
.add_modifier(Modifier::BOLD),
)
}
fn render_help_modal(f: &mut Frame) {
let area = centered_rect(60, 70, f.area());
let help_text = vec![
Line::from(vec![Span::styled(
"Stax TUI Help",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![Span::styled(
"Navigation",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(" ↑/k Move selection up"),
Line::from(" ↓/j Move selection down"),
Line::from(" Enter Checkout selected branch"),
Line::from(" Tab Switch focus to patch scrolling"),
Line::from(""),
Line::from(vec![Span::styled(
"Actions",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(" r Restack selected branch"),
Line::from(" R Restack all branches"),
Line::from(" s Submit stack (push + create PRs)"),
Line::from(" p Open PR in browser"),
Line::from(" n Create new branch"),
Line::from(" e Rename current branch"),
Line::from(" d Delete selected branch"),
Line::from(" o Reorder stack (reparent)"),
Line::from(""),
Line::from(vec![Span::styled(
"Reorder Mode (press 'o' to enter)",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(" Shift+↑/K Move branch up in stack"),
Line::from(" Shift+↓/J Move branch down in stack"),
Line::from(" Enter Apply reparenting and restack"),
Line::from(" Esc Cancel reorder"),
Line::from(""),
Line::from(vec![Span::styled(
"Other",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(" / Search/filter branches"),
Line::from(" ? Show this help"),
Line::from(" q/Esc Quit"),
Line::from(""),
Line::from(vec![Span::styled(
"Press any key to close",
Style::default().fg(Color::DarkGray),
)]),
];
let paragraph = Paragraph::new(help_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Help ")
.border_style(Style::default().fg(Color::Cyan)),
)
.wrap(Wrap { trim: false });
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn render_confirm_modal(f: &mut Frame, action: &ConfirmAction) {
let area = centered_rect(50, 20, f.area());
let message = match action {
ConfirmAction::Delete(branch) => format!("Delete branch '{}'?", branch),
ConfirmAction::Restack(branch) => format!("Restack '{}'?", branch),
ConfirmAction::RestackAll => "Restack all branches?".to_string(),
ConfirmAction::ApplyReorder => "Apply reorder and restack affected branches?".to_string(),
};
let content = vec![
Line::from(""),
Line::from(vec![Span::styled(
message,
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::styled("y", Style::default().fg(Color::Green)),
Span::raw(" confirm "),
Span::styled("n/Esc", Style::default().fg(Color::Red)),
Span::raw(" cancel"),
]),
];
let paragraph = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Confirm ")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false });
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn render_input_modal(f: &mut Frame, action: &InputAction, input: &str, cursor: usize) {
let area = centered_rect(50, 25, f.area());
let title = match action {
InputAction::Rename => " Rename Branch ",
InputAction::NewBranch => " New Branch ",
};
let prompt = match action {
InputAction::Rename => "Enter new branch name:",
InputAction::NewBranch => "Enter branch name:",
};
let (before, after) = input.split_at(cursor.min(input.len()));
let content = vec![
Line::from(""),
Line::from(vec![Span::styled(
prompt,
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Cyan)),
Span::styled(before, Style::default().fg(Color::White)),
Span::styled(
"│",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::SLOW_BLINK),
),
Span::styled(after, Style::default().fg(Color::White)),
]),
Line::from(""),
Line::from(vec![
Span::styled("←→ move ", Style::default().fg(Color::DarkGray)),
Span::styled("Home/End ", Style::default().fg(Color::DarkGray)),
Span::styled("⏎ confirm ", Style::default().fg(Color::DarkGray)),
Span::styled("Esc cancel", Style::default().fg(Color::DarkGray)),
]),
];
let paragraph = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Cyan)),
)
.wrap(Wrap { trim: false });
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}