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::Min(0), Constraint::Length(1)])
.split(area);
render_body(frame, rows[0], app);
render_footer(frame, rows[1], app);
}
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.active_timings();
let items: Vec<ListItem> = app
.active_services()
.iter()
.map(|(source, svc)| {
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(
if active { "โถ" } else { " " },
Style::default().fg(Color::Cyan),
),
Span::styled(
format!("{} ", svc.status.indicator()),
Style::default().fg(svc.status.color()),
),
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 title = if let Some(name) = app.instance_filter_label() {
format!(" {} v{} [{}] ", app.app_name, app.app_version, name)
} else {
format!(" {} v{} ", app.app_name, app.app_version)
};
let list = List::new(items).block(Block::default().borders(Borders::RIGHT).title(title));
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 => {
let builtins = t.builtin_apps_build_ms
.map(|ms| format!("builtins {} ", fmt_ms(ms)))
.unwrap_or_default();
match t.last_build_ms {
Some(ms) => format!("{}last {} ร{} builds", builtins, fmt_ms(ms), t.build_count),
None if !builtins.is_empty() => builtins.trim_end().to_string(),
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 => {
let build_part = t.platform_build_ms
.map(|ms| format!("build {} ", fmt_ms(ms)))
.unwrap_or_default();
if let Some(started) = t.daemon_started_at {
format!("{}up {}", build_part, fmt_duration(started.elapsed().as_secs()))
} else if !build_part.is_empty() {
build_part.trim_end().to_string()
} 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.log_scroll = rows[1];
render_timings_bar(frame, rows[0], app);
render_log_scroll(frame, rows[1], app);
}
fn render_timings_bar(frame: &mut Frame, area: Rect, app: &AppState) {
let t = app.active_timings();
let dim = Style::default().fg(Color::DarkGray);
let val = Style::default().fg(Color::Cyan);
let sep = Style::default().fg(Color::DarkGray);
let mut spans: Vec<Span> = Vec::new();
macro_rules! kv {
($label:expr, $value:expr) => {
if !spans.is_empty() {
spans.push(Span::styled(" ", sep));
}
spans.push(Span::styled(concat!($label, ":"), dim));
spans.push(Span::styled($value, val));
};
}
if let Some(ms) = t.platform_build_ms {
kv!("platform", fmt_ms(ms));
} else if t.platform_build_started_at.is_some() {
let elapsed = t.platform_build_started_at.map(|s| s.elapsed().as_millis()).unwrap_or(0);
kv!("platform", format!("{}โฆ", fmt_ms(elapsed as u64)));
}
if let Some(ms) = t.builtin_apps_build_ms {
kv!("builtins", fmt_ms(ms));
}
match (t.last_build_ms, t.build_count) {
(Some(ms), n) => {
let v = if n > 1 { format!("{} ร{}", fmt_ms(ms), n) } else { fmt_ms(ms) };
kv!("app", v);
}
(None, _) if t.build_started_at.is_some() => {
let elapsed = t.build_started_at.map(|s| s.elapsed().as_millis()).unwrap_or(0);
kv!("app", format!("{}โฆ", fmt_ms(elapsed as u64)));
}
_ => {}
}
if let Some(ms) = t.last_reload_ms {
kv!("reload", fmt_ms(ms));
}
if let Some(started) = t.daemon_started_at {
kv!("up", fmt_duration(started.elapsed().as_secs()));
}
let pane_label = {
let src = app.active_pane;
if !app.search_query.is_empty() {
let total = app.log_lines(src).len();
let matched = app.log_lines(src).iter().filter(|l| app.matches(l)).count();
format!(" {matched}/{total} ")
} else {
format!(" {} ", src.tab_label())
}
};
let mut all_spans: Vec<Span> = vec![Span::raw(" ")];
all_spans.extend(spans);
let content_width: usize = all_spans.iter().map(|s| s.content.len()).sum();
let label_width = pane_label.len();
let area_width = area.width as usize;
if content_width + label_width < area_width {
let pad = area_width - content_width - label_width;
all_spans.push(Span::raw(" ".repeat(pad)));
}
all_spans.push(Span::styled(
pane_label,
Style::default().fg(Color::Black).bg(Color::Cyan),
));
frame.render_widget(Paragraph::new(Line::from(all_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(" [/]", Style::default().fg(Color::Cyan)),
Span::raw(" edit "),
Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
Span::raw(" clear "),
Span::styled("[โโ]", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
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("[i]", Style::default().fg(Color::Cyan)),
Span::raw(" inst "),
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);
}