use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use super::theme;
use crate::logging::reader::{LogEntry, LogLevel};
use crate::tui::app::App;
use crate::tui::render::theme::{self as rtheme, Role};
fn level_color(level: LogLevel) -> Color {
match level {
LogLevel::Error => rtheme::role(Role::Error),
LogLevel::Warn => rtheme::role(Role::Warning),
LogLevel::Info => rtheme::role(Role::AccentTeal),
LogLevel::Debug => rtheme::role(Role::TextDim),
LogLevel::Trace => rtheme::role(Role::GrayDim),
}
}
fn short_target(target: &str) -> String {
let parts: Vec<&str> = target.split("::").collect();
match parts.len() {
0 => String::new(),
1 => parts[0].to_string(),
n => format!("{}::{}", parts[n - 2], parts[n - 1]),
}
}
fn clock(timestamp: &str) -> &str {
timestamp
.split_once('T')
.map(|(_, rest)| rest)
.and_then(|rest| rest.get(..12))
.unwrap_or(timestamp)
}
fn entry_lines(entry: &LogEntry) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(vec![
Span::styled(
format!("{} ", clock(&entry.timestamp)),
Style::default().fg(theme::text_dim()),
),
Span::styled(
format!("{:<5} ", entry.level.label()),
Style::default()
.fg(level_color(entry.level))
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", short_target(&entry.target)),
Style::default().fg(theme::teal()),
),
Span::styled(
entry.message.clone(),
Style::default().fg(theme::text_primary()),
),
])];
for cont in &entry.continuation {
lines.push(Line::from(Span::styled(
format!(" {cont}"),
Style::default().fg(theme::text_dim()),
)));
}
lines
}
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(2)])
.split(area);
let (body_area, bar_area) = (rows[0], rows[1]);
let viewport = body_area.height.saturating_sub(2) as usize;
if let Some(viewer) = app.mc.log_viewer.as_mut() {
viewer.viewport_rows = viewport;
}
let Some(viewer) = app.mc.log_viewer.as_ref() else {
return;
};
frame.render_widget(Clear, area);
let visible = viewer.visible();
let mut lines: Vec<Line> = Vec::new();
if let Some(err) = &viewer.error {
lines.push(Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(rtheme::role(Role::Error)),
)));
} else if visible.is_empty() {
lines.push(Line::from(Span::styled(
if viewer.entries.is_empty() {
" This log file is empty.".to_string()
} else {
format!(
" No entry matches the current filters ({} hidden).",
viewer.entries.len()
)
},
Style::default().fg(theme::text_dim()),
)));
} else {
for entry in &visible {
lines.extend(entry_lines(entry));
}
}
let title = format!(
" 📜 {} — {} shown of {} ",
viewer.file_name(),
visible.len(),
viewer.entries.len()
);
let body = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
title,
Style::default()
.fg(theme::orange())
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(theme::border_activity_focus())),
)
.scroll((viewer.scroll.min(viewer.max_scroll()) as u16, 0));
frame.render_widget(body, body_area);
draw_bar(frame, viewer, bar_area);
}
fn draw_bar(
frame: &mut Frame,
viewer: &crate::tui::app::mission_control::log_viewer::LogViewerState,
area: Rect,
) {
if area.height == 0 {
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
let mut status = vec![
Span::styled(" level ≥ ", theme::dim()),
Span::styled(
viewer.min_level.label(),
Style::default()
.fg(level_color(viewer.min_level))
.add_modifier(Modifier::BOLD),
),
];
if viewer.search_active || !viewer.search.is_empty() {
status.push(Span::styled(" filter ", theme::dim()));
status.push(Span::styled(
if viewer.search_active {
format!("{}▏", viewer.search)
} else {
viewer.search.clone()
},
Style::default()
.fg(theme::orange())
.add_modifier(Modifier::BOLD),
));
}
if viewer.files.len() > 1 {
status.push(Span::styled(
format!(" file {}/{}", viewer.file_index + 1, viewer.files.len()),
theme::dim(),
));
}
let bold =
|s: &'static str| Span::styled(s, theme::help_bar_style().add_modifier(Modifier::BOLD));
let keys = Line::from(vec![
bold(" ↑↓/jk"),
Span::styled(": scroll ", theme::dim()),
bold("g/G"),
Span::styled(": top/end ", theme::dim()),
bold("e/w/i/d"),
Span::styled(": level ", theme::dim()),
bold("/"),
Span::styled(": search ", theme::dim()),
bold("[ ]"),
Span::styled(": prev/next day ", theme::dim()),
bold("Esc"),
Span::styled(": back", theme::dim()),
]);
frame.render_widget(Paragraph::new(Line::from(status)), rows[0]);
if rows.len() > 1 {
frame.render_widget(Paragraph::new(keys), rows[1]);
}
}