use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use super::state::{AppState, LogSource, ShutdownPhase};
pub fn draw(frame: &mut Frame, app: &mut AppState) {
let area = frame.area();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
render_header(frame, rows[0], app);
render_body(frame, rows[1], app);
render_footer(frame, rows[2], app);
}
fn render_header(frame: &mut Frame, area: Rect, app: &AppState) {
let now = utc_hms();
let text = match app.shutdown_phase {
ShutdownPhase::ShuttingDown => Line::from(vec![
Span::styled(
" ◐ shutting down",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" — "),
Span::styled(
app.app_name.clone(),
Style::default().fg(Color::DarkGray),
),
Span::styled(
" waiting for processes to exit…",
Style::default().fg(Color::DarkGray),
),
]),
ShutdownPhase::Done => Line::from(vec![
Span::styled(
" ✓ shutdown complete",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]),
ShutdownPhase::Running => Line::from(vec![
Span::styled(
" node-app dev",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" — "),
Span::styled(
app.app_name.clone(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(" v{}", app.app_version)),
Span::styled(
format!(" [{}]", app.mode),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!(" {now}"),
Style::default().fg(Color::DarkGray),
),
]),
};
frame.render_widget(Paragraph::new(text), area);
}
fn render_body(frame: &mut Frame, area: Rect, app: &mut AppState) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(26), Constraint::Min(0)])
.split(area);
app.layout.service_list = cols[0];
render_service_list(frame, cols[0], app);
render_log_pane(frame, cols[1], app);
}
fn render_service_list(frame: &mut Frame, area: Rect, app: &AppState) {
let t = &app.timings;
let items: Vec<ListItem> = app
.services()
.iter()
.map(|(source, svc)| {
let ind_style = Style::default().fg(svc.status.color());
let active = *source == app.active_pane;
let lbl_style = if active {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let dim = Style::default().fg(Color::DarkGray);
let line1 = Line::from(vec![
Span::styled(format!(" {} ", svc.status.indicator()), ind_style),
Span::styled(format!("{:<10}", svc.label), lbl_style),
Span::styled(svc.status.summary(), Style::default().fg(svc.status.color())),
]);
let line2 = if svc.detail.is_empty() {
Line::raw("")
} else {
Line::from(Span::styled(format!(" {}", &svc.detail), dim))
};
let timing_str = build_timing_line(*source, svc, t);
let line3 = if timing_str.is_empty() {
Line::raw("")
} else {
Line::from(vec![
Span::styled(" ", dim),
Span::styled(timing_str, Style::default().fg(Color::Cyan)),
])
};
ListItem::new(vec![line1, line2, line3])
})
.collect();
let list =
List::new(items).block(Block::default().borders(Borders::RIGHT).title(" services "));
frame.render_widget(list, area);
}
fn build_timing_line(
source: LogSource,
svc: &super::state::ServiceState,
t: &super::state::Timings,
) -> String {
match source {
LogSource::Build => {
match &svc.status {
super::state::ServiceStatus::Building => {
let elapsed = t
.build_started_at
.map(|s| s.elapsed().as_millis())
.unwrap_or(0);
let count_hint = if t.build_count > 0 {
format!(" ×{} prev", t.build_count)
} else {
String::new()
};
format!("elapsed {}{}", fmt_ms(elapsed as u64), count_hint)
}
super::state::ServiceStatus::Ready => {
match t.last_build_ms {
Some(ms) => format!("last {} ×{} builds", fmt_ms(ms), t.build_count),
None => String::new(),
}
}
super::state::ServiceStatus::Failed(_) => {
match t.last_build_ms {
Some(ms) => format!("failed {} ×{} total", fmt_ms(ms), t.build_count),
None => String::new(),
}
}
_ => String::new(),
}
}
LogSource::App => {
if let Some(ms) = t.last_reload_ms {
format!("reload {}", fmt_ms(ms))
} else {
String::new()
}
}
LogSource::Daemon => {
if let Some(started) = t.daemon_started_at {
format!("up {}", fmt_duration(started.elapsed().as_secs()))
} else {
String::new()
}
}
_ => String::new(),
}
}
fn fmt_ms(ms: u64) -> String {
if ms < 1_000 {
format!("{ms}ms")
} else {
format!("{:.1}s", ms as f64 / 1_000.0)
}
}
fn fmt_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else {
format!("{}m {}s", secs / 60, secs % 60)
}
}
fn render_log_pane(frame: &mut Frame, area: Rect, app: &mut AppState) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
app.layout.tab_bar = rows[0];
app.layout.log_scroll = rows[1];
let all_lines_len = app.log_lines(app.active_pane).len();
let filtered_len = if app.search_query.is_empty() {
all_lines_len
} else {
app.log_lines(app.active_pane)
.iter()
.filter(|l| app.matches(l))
.count()
};
let mut tx = rows[0].x;
for (i, src) in LogSource::all().iter().enumerate() {
app.layout.tab_edges[i] = tx;
let w = if *src == app.active_pane && !app.search_query.is_empty() {
format!(" {} ({}/{}) ", src.tab_label(), filtered_len, all_lines_len).len()
} else {
src.tab_label().len() + 2
};
tx += w as u16;
}
render_tab_bar(frame, rows[0], app);
render_log_scroll(frame, rows[1], app);
}
fn render_tab_bar(frame: &mut Frame, area: Rect, app: &AppState) {
let all_lines = app.log_lines(app.active_pane);
let total = all_lines.len();
let filtered_count = if app.search_query.is_empty() {
total
} else {
all_lines.iter().filter(|l| app.matches(l)).count()
};
let spans: Vec<Span> = LogSource::all()
.iter()
.map(|src| {
let base = format!(" {} ", src.tab_label());
let label = if *src == app.active_pane && !app.search_query.is_empty() {
format!(" {} ({}/{}) ", src.tab_label(), filtered_count, total)
} else {
base
};
if *src == app.active_pane {
Span::styled(label, Style::default().fg(Color::Black).bg(Color::Cyan))
} else {
Span::styled(label, Style::default().fg(Color::DarkGray))
}
})
.collect();
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_log_scroll(frame: &mut Frame, area: Rect, app: &mut AppState) {
let height = area.height as usize;
app.last_render_height = height;
let source = app.active_pane;
let search_query = app.search_query.clone();
let selection = app.selection;
let text: Vec<Line> = if search_query.is_empty() {
let total = app.log_lines(source).len();
let start = app.visible_start_for(total, height);
app.log_scroll_start = start;
app.log_lines(source)
.iter()
.enumerate()
.skip(start)
.take(height)
.map(|(abs_idx, l)| {
if selection.is_some_and(|s| s.contains(abs_idx)) {
colorize_selected(l.as_str())
} else {
colorize(l.as_str(), source)
}
})
.collect()
} else {
app.log_scroll_start = 0;
let matched: Vec<(String, Vec<usize>)> = app
.log_lines(source)
.iter()
.filter_map(|l| {
app.match_positions(l.as_str())
.map(|pos| (l.clone(), pos))
})
.collect();
let start = app.visible_start_for(matched.len(), height);
matched
.into_iter()
.skip(start)
.take(height)
.map(|(l, pos)| render_fuzzy_highlighted(&l, &pos, source))
.collect()
};
frame.render_widget(Paragraph::new(text).block(Block::default()), area);
}
fn colorize_selected(line: &str) -> Line<'static> {
Line::from(Span::styled(
line.to_owned(),
Style::default()
.bg(Color::Rgb(50, 80, 130))
.fg(Color::White),
))
}
fn render_fuzzy_highlighted(line: &str, positions: &[usize], source: LogSource) -> Line<'static> {
let base = base_style(line, source);
let hl = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
if positions.is_empty() {
return Line::from(Span::styled(line.to_owned(), base));
}
let chars: Vec<char> = line.chars().collect();
let pos_set: std::collections::HashSet<usize> =
positions.iter().copied().collect();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut segment = String::new();
let mut in_hl = false;
for (i, c) in chars.iter().enumerate() {
let want_hl = pos_set.contains(&i);
if want_hl != in_hl {
if !segment.is_empty() {
spans.push(Span::styled(
segment.clone(),
if in_hl { hl } else { base },
));
segment.clear();
}
in_hl = want_hl;
}
segment.push(*c);
}
if !segment.is_empty() {
spans.push(Span::styled(segment, if in_hl { hl } else { base }));
}
Line::from(spans)
}
fn colorize(line: &str, source: LogSource) -> Line<'static> {
Line::from(Span::styled(line.to_owned(), base_style(line, source)))
}
fn base_style(line: &str, source: LogSource) -> Style {
if line.contains(" ERROR") || line.contains("error[") || line.contains("✗") {
Style::default().fg(Color::Red)
} else if line.contains(" WARN") || line.contains("warning[") || line.contains("⚠") {
Style::default().fg(Color::Yellow)
} else if line.contains("✓") || line.contains(" INFO") {
Style::default().fg(Color::Green)
} else if source == LogSource::Build
&& (line.contains("Compiling") || line.contains("Finished") || line.contains("→"))
{
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::Gray)
}
}
fn render_footer(frame: &mut Frame, area: Rect, app: &AppState) {
if app
.copy_flash
.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2))
{
let lines = app.selection.map_or(0, |s| {
let (a, b) = s.range();
b - a + 1
});
let text = Line::from(vec![
Span::styled(
" ✓ copied",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {lines} line{}", if lines == 1 { "" } else { "s" }),
Style::default().fg(Color::White),
),
Span::styled(
" to clipboard",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(Paragraph::new(text), area);
return;
}
if app.shutdown_phase == ShutdownPhase::ShuttingDown {
let text = Line::from(vec![
Span::styled(
" ◐ shutting down — ",
Style::default().fg(Color::Yellow),
),
Span::styled(
"stopping daemon, ui-server and cleaning up…",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(Paragraph::new(text), area);
return;
}
if app.shutdown_phase == ShutdownPhase::Done {
let text = Line::from(Span::styled(
" ✓ all processes stopped",
Style::default().fg(Color::Green),
));
frame.render_widget(Paragraph::new(text), area);
return;
}
let text = if app.search_input_active {
Line::from(vec![
Span::styled(" /", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
app.search_query.clone(),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(Color::Yellow)), Span::styled(
" [Enter/Esc] lock filter [Backspace] delete",
Style::default().fg(Color::DarkGray),
),
])
} else if !app.search_query.is_empty() {
Line::from(vec![
Span::styled(" filter: ", Style::default().fg(Color::Yellow)),
Span::styled(
app.search_query.clone(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" [/] edit [Esc] clear ",
Style::default().fg(Color::DarkGray),
),
Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("[⇧↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(" page "),
Span::styled("[q]", Style::default().fg(Color::Cyan)),
Span::raw(" quit "),
])
} else {
Line::from(vec![
Span::styled(" [tab]", Style::default().fg(Color::Cyan)),
Span::raw(" pane "),
Span::styled("[/]", Style::default().fg(Color::Cyan)),
Span::raw(" search "),
Span::styled("[r]", Style::default().fg(Color::Cyan)),
Span::raw(" restart "),
Span::styled("[b]", Style::default().fg(Color::Cyan)),
Span::raw(" build "),
Span::styled("[c]", Style::default().fg(Color::Cyan)),
Span::raw(" clear "),
Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("[⇧↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(" page "),
Span::styled("[g/G]", Style::default().fg(Color::Cyan)),
Span::raw(" top/btm "),
Span::styled("[q]", Style::default().fg(Color::Cyan)),
Span::raw(" quit "),
Span::styled("[drag]", Style::default().fg(Color::Cyan)),
Span::raw(" copy "),
])
};
frame.render_widget(Paragraph::new(text), area);
}
fn utc_hms() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let h = (secs % 86400) / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02} UTC")
}