railwayapp 4.59.0

Interact with Railway via CLI
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 super::{RegionRow, ScaleTuiApp, ScaleTuiFocus, ScaleTuiMode};

const LABEL_COLOR: Color = Color::DarkGray;
const BORDER_COLOR: Color = Color::DarkGray;
const SELECTED_ROW_STYLE: Style = Style::new()
    .fg(Color::White)
    .bg(Color::Indexed(238))
    .add_modifier(Modifier::BOLD);

pub fn render(app: &ScaleTuiApp, frame: &mut Frame) {
    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(Color::Yellow));
        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(frame, area),
        ScaleTuiMode::Browse | ScaleTuiMode::Edit => {}
    }
}

fn render_header(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
    let header = vec![
        Span::styled("  Scale ", Style::default().fg(LABEL_COLOR)),
        Span::styled(
            app.service_name.clone(),
            Style::default()
                .fg(Color::Green)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled("  in  ", Style::default().fg(LABEL_COLOR)),
        Span::styled(
            app.environment_name.clone(),
            Style::default()
                .fg(Color::Blue)
                .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 visible = app.visible_indices();
    if visible.is_empty() {
        let message = "No regions available.";
        frame.render_widget(
            Paragraph::new(format!("  {message}")).style(Style::default().fg(LABEL_COLOR)),
            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(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(LABEL_COLOR)
                .add_modifier(Modifier::BOLD),
        ),
    )
    .block(
        Block::default()
            .borders(Borders::TOP | Borders::BOTTOM)
            .border_style(Style::default().fg(BORDER_COLOR)),
    )
    .row_highlight_style(SELECTED_ROW_STYLE);

    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 line = Line::from(vec![
        button("Apply", app.focus == ScaleTuiFocus::Apply, Color::Green),
        Span::raw("  "),
        button("Cancel", app.focus == ScaleTuiFocus::Cancel, Color::Red),
    ]);

    frame.render_widget(Paragraph::new(vec![Line::from(""), line]), area);
}

fn render_preview(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
    let mut lines = Vec::new();

    if let Some(error) = &app.error {
        lines.push(Line::from(Span::styled(
            error.clone(),
            Style::default().fg(Color::Red),
        )));
    } 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(Color::Green),
        )));
    }

    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), area);
}

fn render_help_bar(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
    let help = match app.mode {
        ScaleTuiMode::Browse if app.focus == ScaleTuiFocus::Regions => {
            "Up/Down move  type edit  +/- adjust  0 remove  Enter edit  ? help"
        }
        ScaleTuiMode::Browse => "Enter activate  Up regions  q cancel  ? 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(LABEL_COLOR),
        ))),
        area,
    );
}

fn render_confirm_popup(app: &ScaleTuiApp, frame: &mut Frame, area: Rect) {
    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(Color::Cyan)
                .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(LABEL_COLOR),
        )));
    }

    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        "Enter apply  e edit  q cancel",
        Style::default().fg(LABEL_COLOR),
    )));

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .padding(Padding::new(1, 1, 1, 1));
    frame.render_widget(
        Paragraph::new(lines).block(block).wrap(Wrap { trim: true }),
        popup,
    );
}

fn render_help_popup(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(Color::Cyan)
                .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("q or Esc cancels without applying."),
        Line::from(""),
        Line::from(Span::styled("Esc close", Style::default().fg(LABEL_COLOR))),
    ];

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .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 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(row, selected),
        )]));
    }

    Cell::from(Line::from(Span::styled(
        row.desired.to_string(),
        replica_style(row, selected),
    )))
}

fn button(label: &'static str, focused: bool, color: Color) -> Span<'static> {
    let style = if focused {
        Style::default()
            .fg(Color::White)
            .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(row: &RegionRow, selected: bool) -> Style {
    if selected {
        return SELECTED_ROW_STYLE;
    }

    if row.changed() {
        Style::default().fg(Color::Green)
    } else if !row.available {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default()
    }
}

fn replica_style(row: &RegionRow, selected: bool) -> Style {
    if selected {
        return SELECTED_ROW_STYLE;
    }

    if row.changed() {
        Style::default()
            .fg(Color::Green)
            .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]
}