use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table, TableState, Wrap},
};
use crate::controllers::regions::MAX_TOTAL_REPLICAS;
use crate::tui_theme::Theme;
use super::{RegionRow, ScaleTuiApp, ScaleTuiFocus, ScaleTuiMode};
fn selected_row_style(theme: &Theme) -> Style {
Style::new()
.fg(theme.fg)
.bg(theme.selection)
.add_modifier(Modifier::BOLD)
}
pub fn render(app: &ScaleTuiApp, frame: &mut Frame) {
let theme = app.theme;
let area = frame.area();
frame.render_widget(Clear, area);
if area.width < 72 || area.height < 18 {
let warning = Paragraph::new("Terminal too small. Please resize (min 72x18).")
.style(Style::default().fg(theme.pending));
frame.render_widget(warning, area);
return;
}
let visible_rows = app.visible_indices().len().max(1) as u16;
let preview_height = 4;
let reserved_height = 2 + 1 + preview_height + 2;
let table_height = visible_rows
.saturating_add(3)
.min(area.height.saturating_sub(reserved_height).max(5));
let chunks = Layout::vertical([
Constraint::Length(2),
Constraint::Length(table_height),
Constraint::Length(1),
Constraint::Length(preview_height),
Constraint::Length(2),
Constraint::Min(0),
])
.split(area);
render_header(app, frame, chunks[0]);
render_table(app, frame, chunks[1]);
render_help_bar(app, frame, chunks[2]);
render_preview(app, frame, chunks[3]);
render_actions(app, frame, chunks[4]);
match app.mode {
ScaleTuiMode::Confirm => render_confirm_popup(app, frame, area),
ScaleTuiMode::Help => render_help_popup(theme, frame, area),
ScaleTuiMode::Browse | ScaleTuiMode::Edit => {}
}
}
fn render_header(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
let theme = app.theme;
let header = vec![
Span::styled(" Scale ", Style::default().fg(theme.dim)),
Span::styled(
app.service_name.clone(),
Style::default()
.fg(theme.running)
.add_modifier(Modifier::BOLD),
),
Span::styled(" in ", Style::default().fg(theme.dim)),
Span::styled(
app.environment_name.clone(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
];
frame.render_widget(
Paragraph::new(vec![Line::from(header), Line::from("")]),
area,
);
}
fn render_table(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
let theme = app.theme;
let visible = app.visible_indices();
if visible.is_empty() {
let message = "No regions available.";
frame.render_widget(
Paragraph::new(format!(" {message}")).style(Style::default().fg(theme.dim)),
area,
);
return;
}
let rows = visible.iter().enumerate().map(|(visible_idx, idx)| {
let row = &app.rows[*idx];
let selected = app.focus == ScaleTuiFocus::Regions && app.selected == visible_idx;
Row::new(vec![
Cell::from(region_label(row)),
replica_cell(app, visible_idx, row, selected),
Cell::from(change_label(row)),
])
.style(row_style(theme, row, selected))
});
let table = Table::new(
rows,
[
Constraint::Percentage(64),
Constraint::Length(12),
Constraint::Min(20),
],
)
.header(
Row::new(vec!["Region", "Replicas", "Change"])
.style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)),
)
.block(
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(theme.accent_dim)),
)
.row_highlight_style(selected_row_style(theme));
let mut state = TableState::default();
if app.focus == ScaleTuiFocus::Regions {
state.select(Some(app.selected.min(visible.len().saturating_sub(1))));
}
frame.render_stateful_widget(table, area, &mut state);
}
fn render_actions(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
let theme = app.theme;
let line = Line::from(vec![
button(
"Apply",
app.focus == ScaleTuiFocus::Apply,
theme.fg,
theme.running,
),
Span::raw(" "),
button(
"Cancel",
app.focus == ScaleTuiFocus::Cancel,
theme.fg,
theme.danger,
),
]);
frame.render_widget(Paragraph::new(vec![Line::from(""), line]), area);
}
fn render_preview(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
let theme = app.theme;
let mut lines = Vec::new();
if let Some(error) = &app.error {
lines.push(Line::from(Span::styled(
error.clone(),
Style::default().fg(theme.danger),
)));
} else if !app.changes().is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(app.command_preview()));
lines.push(Line::from(Span::styled(
format!("{} region change(s) selected.", app.changes().len()),
Style::default().fg(theme.running),
)));
}
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), area);
}
fn render_help_bar(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
let theme = app.theme;
let help = match app.mode {
ScaleTuiMode::Browse if app.focus == ScaleTuiFocus::Regions => {
"Up/Down move type edit +/- adjust 0 remove Enter edit t theme ? help"
}
ScaleTuiMode::Browse => "Enter activate Up regions q cancel t theme ? help",
ScaleTuiMode::Edit => "Type replicas Enter save Esc cancel Backspace delete",
ScaleTuiMode::Confirm => "Enter apply e edit q cancel",
ScaleTuiMode::Help => "Esc close help",
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
help,
Style::default().fg(theme.dim),
))),
area,
);
}
fn render_confirm_popup(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
let theme = app.theme;
let popup = centered_rect(58, 12, area);
frame.render_widget(Clear, popup);
let mut lines = vec![
Line::from(Span::styled(
"Apply scale changes?",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
for row in app.changed_rows().into_iter().take(5) {
lines.push(Line::from(format!(
"{} {} -> {}",
row.label, row.current, row.desired
)));
}
let hidden = app.changed_rows().len().saturating_sub(5);
if hidden > 0 {
lines.push(Line::from(Span::styled(
format!("and {hidden} more..."),
Style::default().fg(theme.dim),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Enter apply e edit q cancel",
Style::default().fg(theme.dim),
)));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.surface))
.padding(Padding::new(1, 1, 1, 1));
frame.render_widget(
Paragraph::new(lines).block(block).wrap(Wrap { trim: true }),
popup,
);
}
fn render_help_popup(theme: &Theme, frame: &mut Frame, area: Rect) {
let popup = centered_rect(62, 13, area);
frame.render_widget(Clear, popup);
let lines = vec![
Line::from(Span::styled(
"Scale TUI help",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from("+ / - adjusts the selected region by one replica."),
Line::from("Type a number to edit the selected replica cell inline."),
Line::from(format!("Replica total is capped at {MAX_TOTAL_REPLICAS}.")),
Line::from("Enter saves an inline edit."),
Line::from("0 sets the selected region to zero replicas."),
Line::from("a previews and applies the selected changes."),
Line::from("t cycles the colour theme."),
Line::from("q or Esc cancels without applying."),
Line::from(""),
Line::from(Span::styled("Esc close", Style::default().fg(theme.dim))),
];
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.surface))
.padding(Padding::new(1, 1, 1, 1));
frame.render_widget(Paragraph::new(lines).block(block), popup);
}
fn region_label(row: &RegionRow) -> String {
let mut label = row.label.clone();
if row.dedicated {
label.push_str(" [dedicated]");
}
if !row.available {
label.push_str(" [unavailable]");
}
label
}
fn replica_cell(
app: &ScaleTuiApp,
visible_idx: usize,
row: &RegionRow,
selected: bool,
) -> Cell<'static> {
let theme = app.theme;
let is_editing = app.mode == ScaleTuiMode::Edit && app.selected == visible_idx;
if is_editing {
return Cell::from(Line::from(vec![Span::styled(
format!("[{}]", app.edit_input),
replica_style(theme, row, selected),
)]));
}
Cell::from(Line::from(Span::styled(
row.desired.to_string(),
replica_style(theme, row, selected),
)))
}
fn button(label: &'static str, focused: bool, on_color: Color, color: Color) -> Span<'static> {
let style = if focused {
Style::default()
.fg(on_color)
.bg(color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color).add_modifier(Modifier::BOLD)
};
Span::styled(format!("[ {label} ]"), style)
}
fn change_label(row: &RegionRow) -> String {
if !row.changed() {
return String::new();
}
if row.desired == 0 && row.current > 0 {
return format!("was {}, remove", row.current);
}
match row.change() {
change if change > 0 => format!("was {}, +{}", row.current, change),
change if change < 0 => format!("was {}, {}", row.current, change),
_ => String::new(),
}
}
fn row_style(theme: &Theme, row: &RegionRow, selected: bool) -> Style {
if selected {
return selected_row_style(theme);
}
if row.changed() {
Style::default().fg(theme.running)
} else if !row.available {
Style::default().fg(theme.pending)
} else {
Style::default()
}
}
fn replica_style(theme: &Theme, row: &RegionRow, selected: bool) -> Style {
if selected {
return selected_row_style(theme);
}
if row.changed() {
Style::default()
.fg(theme.running)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let width = width.min(area.width.saturating_sub(2));
let height = height.min(area.height.saturating_sub(2));
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((area.height.saturating_sub(height)) / 2),
Constraint::Length(height),
Constraint::Min(0),
])
.split(area);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length((area.width.saturating_sub(width)) / 2),
Constraint::Length(width),
Constraint::Min(0),
])
.split(vertical[1]);
horizontal[1]
}