use crate::app::{ActiveTab, App, Channel};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Row, Table, Tabs},
Frame,
};
pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(1), ])
.split(f.size());
draw_header(f, app, chunks[0]);
draw_main_area(f, app, chunks[1]);
draw_footer(f, app, chunks[2]);
if app.is_entering_password {
draw_password_modal(f, app);
}
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let style = Style::default().bg(Color::Rgb(15, 23, 42)).fg(Color::White);
let header_block = Block::default()
.style(style)
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::Rgb(51, 65, 85)));
let title = Span::styled(
" ☕ PIOPULSE ESP32 FLASHER v0.1.0 ",
Style::default()
.fg(Color::Rgb(251, 146, 60)) .add_modifier(Modifier::BOLD),
);
let mode_span = if app.admin_mode {
Span::styled(" [ADMIN MODE] ", Style::default().fg(Color::Rgb(239, 68, 68)).add_modifier(Modifier::BOLD)) } else {
Span::styled(" [OPERATOR MODE] ", Style::default().fg(Color::Rgb(34, 197, 94)).add_modifier(Modifier::BOLD)) };
let sim_span = if app.simulation_mode {
Span::styled(" [SIMULATION ACTIVE] ", Style::default().fg(Color::Rgb(234, 179, 8))) } else {
Span::styled(" [HARDWARE DIRECT] ", Style::default().fg(Color::Rgb(59, 130, 246))) };
let header_text = Line::from(vec![
title,
Span::raw(" | "),
mode_span,
Span::raw(" | "),
sim_span,
]);
let header = Paragraph::new(header_text)
.block(header_block)
.style(style);
f.render_widget(header, area);
}
fn draw_footer(f: &mut Frame, _app: &App, area: Rect) {
let footer_text = Span::styled(
" F1/Tab: Toggle Admin | Space: Start Flash | s: Toggle Simulation | c: Clear Stats | 1/2/3: Tabs | Esc: Quit",
Style::default().fg(Color::Rgb(148, 163, 184)), );
f.render_widget(Paragraph::new(footer_text), area);
}
fn draw_main_area(f: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(area);
draw_workspace(f, app, chunks[0]);
draw_sidebar(f, app, chunks[1]);
}
fn draw_workspace(f: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(area);
let tab_titles = vec![" [1] Channels ", " [2] Logs ", " [3] Configuration "];
let active_index = match app.active_tab {
ActiveTab::Channels => 0,
ActiveTab::Logs => 1,
ActiveTab::Configuration => 2,
};
let tabs = Tabs::new(tab_titles)
.block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(Color::Rgb(51, 65, 85))))
.select(active_index)
.style(Style::default().fg(Color::Rgb(148, 163, 184)))
.highlight_style(
Style::default()
.fg(Color::Rgb(251, 146, 60))
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, chunks[0]);
match app.active_tab {
ActiveTab::Channels => draw_channels_tab(f, app, chunks[1]),
ActiveTab::Logs => draw_logs_tab(f, app, chunks[1]),
ActiveTab::Configuration => draw_config_tab(f, app, chunks[1]),
}
}
fn draw_channels_tab(f: &mut Frame, app: &mut App, area: Rect) {
if app.channels.is_empty() {
let empty_msg = Paragraph::new(
"\n\n\n\nNo serial devices detected.\n\nEnsure ESP32 devices are plugged in and press 's' to toggle Simulation Mode.",
)
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(Color::Rgb(148, 163, 184)));
f.render_widget(empty_msg, area);
return;
}
let num_channels = app.channels.len();
let num_cols = 2;
let num_rows = (num_channels + num_cols - 1) / num_cols;
let row_constraints = vec![Constraint::Ratio(1, num_rows as u32); num_rows];
let row_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(row_constraints)
.split(area);
for r in 0..num_rows {
let col_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(row_chunks[r]);
for c in 0..num_cols {
let channel_idx = r * num_cols + c;
if channel_idx < num_channels {
draw_channel_card(f, app, &app.channels[channel_idx], col_chunks[c]);
}
}
}
}
fn draw_channel_card(f: &mut Frame, _app: &App, channel: &Channel, area: Rect) {
let (border_color, status_style) = if channel.finished {
if channel.success {
(Color::Rgb(34, 197, 94), Style::default().fg(Color::Rgb(34, 197, 94)).add_modifier(Modifier::BOLD)) } else {
(Color::Rgb(239, 68, 68), Style::default().fg(Color::Rgb(239, 68, 68)).add_modifier(Modifier::BOLD)) }
} else if channel.status == "Idle" {
(Color::Rgb(71, 85, 105), Style::default().fg(Color::Rgb(148, 163, 184))) } else {
(Color::Rgb(234, 179, 8), Style::default().fg(Color::Rgb(234, 179, 8)).add_modifier(Modifier::BOLD)) };
let title = format!(" Channel: {} ", channel.port);
let card_block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let card_area = card_block.inner(area);
f.render_widget(card_block, area);
let detail_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Min(0), ])
.split(card_area);
let chip_str = channel.chip.as_deref().unwrap_or("Detecting...");
let mac_str = channel.mac.as_deref().unwrap_or("XX:XX:XX:XX:XX:XX");
let line1 = Line::from(vec![
Span::styled("Chip: ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(chip_str, Style::default().fg(Color::White)),
Span::raw(" "),
Span::styled("MAC: ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(mac_str, Style::default().fg(Color::White)),
]);
f.render_widget(Paragraph::new(line1), detail_chunks[0]);
let line2 = Line::from(vec![
Span::styled("Status: ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(&channel.status, status_style),
Span::raw(" "),
Span::styled("Speed: ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(&channel.speed, Style::default().fg(Color::White)),
]);
f.render_widget(Paragraph::new(line2), detail_chunks[1]);
let gauge = Gauge::default()
.block(Block::default())
.gauge_style(Style::default().fg(border_color).bg(Color::Rgb(30, 41, 59)))
.percent(channel.progress as u16);
f.render_widget(gauge, detail_chunks[2]);
if let Some(err) = &channel.error {
let err_block = Paragraph::new(format!("Error: {}", err))
.style(Style::default().fg(Color::Rgb(239, 68, 68)))
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(err_block, detail_chunks[3]);
}
}
fn draw_logs_tab(f: &mut Frame, app: &App, area: Rect) {
let logs_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(51, 65, 85)))
.title(" System Event Log ");
let list_items: Vec<ListItem> = app
.logs
.iter()
.rev() .map(|log| {
let style = if log.contains("FAILED") || log.contains("Error") {
Style::default().fg(Color::Rgb(239, 68, 68))
} else if log.contains("PASSED") || log.contains("Success") {
Style::default().fg(Color::Rgb(34, 197, 94))
} else if log.contains("Start Batch") {
Style::default().fg(Color::Rgb(59, 130, 246)).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Rgb(226, 232, 240))
};
ListItem::new(log.as_str()).style(style)
})
.collect();
let list = List::new(list_items)
.block(logs_block)
.style(Style::default().bg(Color::Rgb(9, 15, 29)));
f.render_widget(list, area);
}
fn draw_config_tab(f: &mut Frame, app: &App, area: Rect) {
let config_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(51, 65, 85)))
.title(" Firmware Flashing Configuration ");
let block_inner = config_block.inner(area);
f.render_widget(config_block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(3)])
.split(block_inner);
let cfg = &app.config;
let fields = vec![
("Project Name:", cfg.name.clone()),
("Chip Type:", cfg.chip_type.clone()),
("Baud Rate:", cfg.baud_rate.to_string()),
("Flash Mode:", cfg.flash_mode.clone()),
("Flash Freq:", cfg.flash_freq.clone()),
("Flash Size:", cfg.flash_size.clone()),
("Bootloader Offset:", cfg.bootloader_offset.clone()),
("Bootloader Path:", cfg.bootloader_path.clone()),
("Partitions Offset:", cfg.partitions_offset.clone()),
("Partitions Path:", cfg.partitions_path.clone()),
("OTA Data Offset:", cfg.otadata_offset.clone()),
("OTA Data Path:", cfg.otadata_path.clone()),
("App Offset:", cfg.app_offset.clone()),
("App Path:", cfg.app_path.clone()),
];
let mut rows = Vec::new();
for (i, (label, val)) in fields.iter().enumerate() {
let is_selected = app.admin_mode && app.selected_config_field == i;
let label_span = Span::styled(
*label,
Style::default().fg(Color::Rgb(148, 163, 184)),
);
let val_span = if is_selected {
if app.is_editing_config {
Span::styled(
format!("{} █", val), Style::default()
.fg(Color::Rgb(251, 146, 60))
.add_modifier(Modifier::REVERSED),
)
} else {
Span::styled(
val,
Style::default()
.fg(Color::Black)
.bg(Color::Rgb(251, 146, 60))
.add_modifier(Modifier::BOLD),
)
}
} else {
Span::styled(val, Style::default().fg(Color::White))
};
rows.push(Row::new(vec![
table_cell(label_span),
table_cell(val_span),
]));
}
let widths = [Constraint::Percentage(30), Constraint::Percentage(70)];
let table = Table::new(rows, widths)
.block(Block::default().borders(Borders::NONE))
.style(Style::default().bg(Color::Rgb(9, 15, 29)));
f.render_widget(table, chunks[0]);
let admin_help_text = if !app.admin_mode {
Line::from(vec![
Span::styled("🛡️ Admin Mode is Locked. ", Style::default().fg(Color::Rgb(239, 68, 68))),
Span::styled("Unlock (F1/Tab) to edit config fields.", Style::default().fg(Color::Rgb(148, 163, 184))),
])
} else if app.is_editing_config {
Line::from(vec![
Span::styled("✏️ EDITING MODE: ", Style::default().fg(Color::Rgb(251, 146, 60)).add_modifier(Modifier::BOLD)),
Span::styled("Type value, press Enter to Save, Esc to Cancel.", Style::default().fg(Color::Rgb(226, 232, 240))),
])
} else {
Line::from(vec![
Span::styled("⚙️ ADMIN CONTROL: ", Style::default().fg(Color::Rgb(239, 68, 68)).add_modifier(Modifier::BOLD)),
Span::styled("Use ↑↓ to navigate, Enter to Edit, Tab to lock.", Style::default().fg(Color::Rgb(226, 232, 240))),
])
};
let admin_help = Paragraph::new(admin_help_text)
.block(Block::default().borders(Borders::TOP).border_style(Style::default().fg(Color::Rgb(51, 65, 85))));
f.render_widget(admin_help, chunks[1]);
}
fn table_cell<'a>(span: Span<'a>) -> Line<'a> {
Line::from(span)
}
fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
draw_stats(f, app, chunks[0]);
draw_instructions(f, app, chunks[1]);
}
fn draw_stats(f: &mut Frame, app: &App, area: Rect) {
let stats_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(51, 65, 85)))
.title(" Production Statistics ");
let total = app.stats.total_passed + app.stats.total_failed;
let yield_rate = if total > 0 {
(app.stats.total_passed as f32 / total as f32) * 100.0
} else {
0.0
};
let elapsed_str = format!(
"{:02}:{:02}",
app.elapsed_time.as_secs() / 60,
app.elapsed_time.as_secs() % 60
);
let stats_text = vec![
Line::from(vec![
Span::styled("Total Attempted: ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(app.stats.total_attempted.to_string(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled("Passed (OK): ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(app.stats.total_passed.to_string(), Style::default().fg(Color::Rgb(34, 197, 94)).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled("Failed (FAIL): ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(app.stats.total_failed.to_string(), Style::default().fg(Color::Rgb(239, 68, 68)).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled("Yield Rate: ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(format!("{:.1}%", yield_rate), Style::default().fg(if yield_rate >= 95.0 || total == 0 { Color::Rgb(34, 197, 94) } else { Color::Rgb(239, 68, 68) }).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled("Elapsed Time: ", Style::default().fg(Color::Rgb(148, 163, 184))),
Span::styled(elapsed_str, Style::default().fg(Color::White)),
]),
];
let inner_area = stats_block.inner(area);
f.render_widget(stats_block, area);
let stats_widget = Paragraph::new(stats_text)
.block(Block::default())
.style(Style::default().bg(Color::Rgb(9, 15, 29)));
f.render_widget(stats_widget, inner_area);
}
fn draw_instructions(f: &mut Frame, _app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(51, 65, 85)))
.title(" User Interface Help ");
let help_text = vec![
Line::from(vec![
Span::styled("Space", Style::default().fg(Color::Rgb(251, 146, 60)).add_modifier(Modifier::BOLD)),
Span::styled(" - Trigger Flashing Process", Style::default().fg(Color::Rgb(226, 232, 240))),
]),
Line::from(vec![
Span::styled("Tab", Style::default().fg(Color::Rgb(251, 146, 60)).add_modifier(Modifier::BOLD)),
Span::styled(" - Toggle Admin Auth Mode", Style::default().fg(Color::Rgb(226, 232, 240))),
]),
Line::from(vec![
Span::styled("s", Style::default().fg(Color::Rgb(251, 146, 60)).add_modifier(Modifier::BOLD)),
Span::styled(" - Toggle Hardware/Sim Mode", Style::default().fg(Color::Rgb(226, 232, 240))),
]),
Line::from(vec![
Span::styled("c", Style::default().fg(Color::Rgb(251, 146, 60)).add_modifier(Modifier::BOLD)),
Span::styled(" - Clear Statistics Counters", Style::default().fg(Color::Rgb(226, 232, 240))),
]),
Line::from(vec![
Span::styled("1/2/3", Style::default().fg(Color::Rgb(251, 146, 60)).add_modifier(Modifier::BOLD)),
Span::styled(" - Switch Active Workspace Tab", Style::default().fg(Color::Rgb(226, 232, 240))),
]),
Line::from(vec![
Span::styled("Esc", Style::default().fg(Color::Rgb(251, 146, 60)).add_modifier(Modifier::BOLD)),
Span::styled(" - Exit Application Safely", Style::default().fg(Color::Rgb(226, 232, 240))),
]),
];
let inner_area = block.inner(area);
f.render_widget(block, area);
let help_widget = Paragraph::new(help_text)
.block(Block::default())
.style(Style::default().bg(Color::Rgb(9, 15, 29)));
f.render_widget(help_widget, inner_area);
}
fn draw_password_modal(f: &mut Frame, app: &App) {
let block = Block::default()
.title(" Admin Authorization Required ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(239, 68, 68)));
let area = center_rect(45, 12, f.size());
let inner_area = block.inner(area);
f.render_widget(Clear, area);
f.render_widget(block, area);
let text_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Min(0),
])
.split(inner_area);
let msg = Paragraph::new("Enter administrator password: (Default: admin)")
.style(Style::default().fg(Color::Rgb(226, 232, 240)));
f.render_widget(msg, text_chunks[0]);
let masked_input: String = std::iter::repeat('*').take(app.password_input.len()).collect();
let input_widget = Paragraph::new(masked_input)
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Rgb(251, 146, 60))))
.style(Style::default().fg(Color::White));
f.render_widget(input_widget, text_chunks[1]);
if app.password_incorrect {
let err_msg = Paragraph::new("Incorrect password. Press Enter to retry.")
.style(Style::default().fg(Color::Rgb(239, 68, 68)));
f.render_widget(err_msg, text_chunks[2]);
}
}
fn center_rect(percent_x: u16, height_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((r.height.saturating_sub(height_y)) / 2),
Constraint::Length(height_y),
Constraint::Min(0),
])
.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]
}