use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::tui::app::App;
use crate::tui::theme::{Glyphs, Motion, Palette, breathing_brightness, color_lerp};
pub fn render(frame: &mut Frame, area: Rect, app: &App, palette: &Palette) {
let active_shifts = app
.runs
.iter()
.filter(|r| r.status == "running" || r.status == "awaiting_approval")
.count();
let total_shifts = app.runs.len() + app.approvals.len();
let tools = app.tool_count;
let blueprints = if app.blueprints.is_empty() {
app.daemon_blueprint_count as usize
} else {
app.blueprints.len()
};
let uptime_str = format_uptime(app.daemon_uptime_secs);
let spinner_frame = Glyphs::SPINNER[(app.tick_count as usize) % Glyphs::SPINNER.len()];
let sparkline = render_sparkline(&app.throughput_window, 16);
let version = env!("CARGO_PKG_VERSION");
let brand = vec![
Span::styled(
"darq",
Style::new()
.fg(palette.brand_green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(format!("v{version}"), Style::new().fg(palette.fg_3)),
];
let mut mid = vec![
Span::styled(" shifts ", Style::new().fg(palette.fg_3)),
Span::styled(
format!("{active_shifts}/{total_shifts}"),
Style::new().fg(palette.fg_0),
),
Span::styled(" tools ", Style::new().fg(palette.fg_3)),
Span::styled(tools.to_string(), Style::new().fg(palette.fg_0)),
Span::styled(" blueprints ", Style::new().fg(palette.fg_3)),
Span::styled(blueprints.to_string(), Style::new().fg(palette.fg_0)),
Span::styled(" throughput ", Style::new().fg(palette.fg_3)),
Span::styled(sparkline, Style::new().fg(palette.cyan)),
];
if !app.is_replay {
mid.push(Span::styled(" uptime ", Style::new().fg(palette.fg_3)));
mid.push(Span::styled(uptime_str, Style::new().fg(palette.fg_0)));
}
let pulse_b = breathing_brightness(app.elapsed_ms(), Motion::BEAT.as_millis() as u64);
let pulse_t = ((1.0 - pulse_b) / 0.14 * 0.6).clamp(0.0, 0.6);
let dot_color = color_lerp(palette.heartbeat, palette.bg_1, pulse_t);
let mut right = vec![
Span::styled(spinner_frame, Style::new().fg(palette.heartbeat)),
Span::raw(" "),
Span::styled(
Glyphs::HEARTBEAT_DOT,
Style::new().fg(dot_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
"HEARTBEAT",
Style::new()
.fg(palette.heartbeat)
.add_modifier(Modifier::BOLD),
),
];
if app.failure_dot_until.is_some() {
right.push(Span::raw(" "));
right.push(Span::styled(
"●",
Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
));
}
let right_width: u16 = right
.iter()
.map(|s| s.content.chars().count())
.sum::<usize>() as u16;
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(right_width)])
.split(area);
let bg = Style::new().bg(palette.bg_0);
let mut left_spans = brand;
left_spans.extend(mid);
frame.render_widget(
Paragraph::new(Line::from(left_spans))
.style(bg)
.alignment(Alignment::Left),
cols[0],
);
frame.render_widget(
Paragraph::new(Line::from(right))
.style(bg)
.alignment(Alignment::Right),
cols[1],
);
}
fn format_uptime(secs: u64) -> String {
if secs == 0 {
return "—".into();
}
let days = secs / 86_400;
let hours = (secs % 86_400) / 3600;
let mins = (secs % 3600) / 60;
if days > 0 {
format!("{days}d {hours:02}h")
} else if hours > 0 {
format!("{hours}h {mins:02}m")
} else {
format!("{mins}m")
}
}
fn render_sparkline(window: &std::collections::VecDeque<u32>, width: usize) -> String {
if window.is_empty() {
return "▁".repeat(width);
}
let max = *window.iter().max().unwrap_or(&1).max(&1) as f32;
let cells = Glyphs::SPARKLINE.len();
let n = window.len();
let start = n.saturating_sub(width);
let mut out = String::with_capacity(width * 3);
if n < width {
for _ in 0..(width - n) {
out.push('▁');
}
}
for &v in window.iter().skip(start) {
let normalized = (v as f32 / max).clamp(0.0, 1.0);
let idx = ((normalized * (cells as f32 - 1.0)).round() as usize).min(cells - 1);
out.push_str(Glyphs::SPARKLINE[idx]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
#[test]
fn sparkline_empty_returns_low_cells() {
let window: VecDeque<u32> = VecDeque::new();
let s = render_sparkline(&window, 8);
assert_eq!(s.chars().count(), 8);
}
#[test]
fn sparkline_normalizes_to_max() {
let window: VecDeque<u32> = vec![0, 5, 10].into();
let s = render_sparkline(&window, 3);
assert_eq!(s.chars().count(), 3);
assert!(s.ends_with("█"));
}
#[test]
fn sparkline_pads_left_when_window_short() {
let window: VecDeque<u32> = vec![5].into();
let s = render_sparkline(&window, 4);
assert_eq!(s.chars().count(), 4);
assert!(s.starts_with("▁"));
}
}