use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Tabs},
};
use super::app::{Selection, Tab, TuiApp};
use super::log_store::LogRef;
fn convert_color(c: colored::Color) -> Color {
match c {
colored::Color::Black => Color::Black,
colored::Color::Red => Color::Red,
colored::Color::Green => Color::Green,
colored::Color::Yellow => Color::Yellow,
colored::Color::Blue => Color::Blue,
colored::Color::Magenta => Color::Magenta,
colored::Color::Cyan => Color::Cyan,
colored::Color::White => Color::White,
colored::Color::BrightBlack => Color::DarkGray,
colored::Color::BrightRed => Color::LightRed,
colored::Color::BrightGreen => Color::LightGreen,
colored::Color::BrightYellow => Color::LightYellow,
colored::Color::BrightBlue => Color::LightBlue,
colored::Color::BrightMagenta => Color::LightMagenta,
colored::Color::BrightCyan => Color::LightCyan,
colored::Color::BrightWhite => Color::White,
colored::Color::TrueColor { r, g, b } => Color::Rgb(r, g, b),
}
}
pub fn render(app: &mut TuiApp, frame: &mut Frame) {
let info_height = if app.show_info { 3 } else { 0 };
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(info_height), Constraint::Length(1), ])
.split(frame.area());
app.set_log_area(chunks[1].y, chunks[1].height);
render_tabs(app, frame, chunks[0]);
render_logs(app, frame, chunks[1]);
if app.show_info {
render_info_pane(app, frame, chunks[2]);
}
render_help_bar(app, frame, chunks[3]);
}
fn render_tabs(app: &TuiApp, frame: &mut Frame, area: Rect) {
let mut titles: Vec<Line> = Vec::new();
if app.show_local_tab() {
titles.push(Line::from("Local"));
}
for service in &app.services {
if !service.is_docker {
titles.push(Line::from(service.name.clone()));
}
}
if app.show_image_tab() {
titles.push(Line::from("Image"));
}
for service in &app.services {
if service.is_docker {
titles.push(Line::from(service.name.clone()));
}
}
let selected = app.tab_index();
let follow_indicator = if app.follow_mode {
Span::styled(" [FOLLOW]", Style::default().fg(Color::Green))
} else {
Span::styled(" [PAUSED]", Style::default().fg(Color::Yellow))
};
let tabs = Tabs::new(titles)
.select(selected)
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.divider("|");
let tab_chunks = Layout::horizontal([Constraint::Min(10), Constraint::Length(10)]).split(area);
frame.render_widget(tabs, tab_chunks[0]);
frame.render_widget(Paragraph::new(Line::from(follow_indicator)), tab_chunks[1]);
}
fn render_logs(app: &mut TuiApp, frame: &mut Frame, area: Rect) {
let visible_height = area.height as usize;
app.set_visible_height(visible_height);
let total = app.current_log_count();
let start_idx = if app.follow_mode {
total.saturating_sub(visible_height)
} else {
app.scroll_offset.min(total.saturating_sub(visible_height))
};
let logs: Vec<LogRef> = match app.current_tab {
Tab::Local => app
.log_store
.local_logs
.iter()
.skip(start_idx)
.take(visible_height)
.map(LogRef::Entry)
.collect(),
Tab::Image => app
.log_store
.image_logs
.iter()
.skip(start_idx)
.take(visible_height)
.map(LogRef::Entry)
.collect(),
Tab::Service(idx) => app
.log_store
.services
.get(idx)
.map(|buf| {
buf.lines
.iter()
.skip(start_idx)
.take(visible_height)
.map(|line| LogRef::Service(idx, line))
.collect()
})
.unwrap_or_default(),
};
let mut lines: Vec<Line> = Vec::with_capacity(visible_height);
for (vis_row, log_ref) in logs.iter().enumerate() {
let (service_idx, service_name, message, log_color) = log_ref.parts();
let service_color = app
.services
.get(service_idx)
.map(|s| convert_color(s.color))
.unwrap_or_else(|| convert_color(log_color));
let line = render_log_line(
service_name,
service_color,
message,
vis_row,
&app.selection,
);
lines.push(line);
}
while lines.len() < visible_height {
lines.push(Line::from(""));
}
let block = Block::default()
.borders(Borders::NONE)
.style(Style::default());
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_log_line<'a>(
service_name: &str,
service_color: Color,
message: &str,
vis_row: usize,
selection: &Option<Selection>,
) -> Line<'a> {
let prefix = format!("[{}] ", service_name);
let prefix_len = prefix.chars().count();
let Some(sel) = selection else {
return Line::from(vec![
Span::styled(prefix, Style::default().fg(service_color)),
Span::raw(message.to_string()),
]);
};
let ((start_row, start_col), (end_row, end_col)) = sel.normalized();
if vis_row < start_row || vis_row > end_row {
return Line::from(vec![
Span::styled(prefix, Style::default().fg(service_color)),
Span::raw(message.to_string()),
]);
}
let full_line = format!("{}{}", prefix, message);
let chars: Vec<char> = full_line.chars().collect();
let line_len = chars.len();
let sel_start = if vis_row == start_row {
start_col.min(line_len)
} else {
0
};
let sel_end = if vis_row == end_row {
(end_col + 1).min(line_len)
} else {
line_len
};
let mut spans = Vec::new();
if sel_start > 0 {
let text: String = chars[..sel_start.min(line_len)].iter().collect();
let style = if sel_start <= prefix_len {
Style::default().fg(service_color)
} else {
Style::default()
};
if sel_start > prefix_len {
let prefix_text: String = chars[..prefix_len].iter().collect();
let msg_text: String = chars[prefix_len..sel_start].iter().collect();
spans.push(Span::styled(
prefix_text,
Style::default().fg(service_color),
));
spans.push(Span::raw(msg_text));
} else {
spans.push(Span::styled(text, style));
}
}
if sel_start < line_len && sel_end > sel_start {
let text: String = chars[sel_start..sel_end.min(line_len)].iter().collect();
spans.push(Span::styled(
text,
Style::default().bg(Color::White).fg(Color::Black),
));
}
if sel_end < line_len {
let text: String = chars[sel_end..].iter().collect();
let style = if sel_end < prefix_len {
Style::default().fg(service_color)
} else {
Style::default()
};
spans.push(Span::styled(text, style));
}
Line::from(spans)
}
fn render_info_pane(app: &TuiApp, frame: &mut Frame, area: Rect) {
let services_to_show: Vec<&super::app::ServiceInfo> = match app.current_tab {
Tab::Local => app.services.iter().filter(|s| !s.is_docker).collect(),
Tab::Image => app.services.iter().filter(|s| s.is_docker).collect(),
Tab::Service(idx) => app.services.get(idx).into_iter().collect(),
};
let lines: Vec<Line> = services_to_show
.iter()
.take(2)
.map(|svc| {
let mut spans = vec![Span::styled(
format!("{}: ", svc.name),
Style::default()
.fg(convert_color(svc.color))
.add_modifier(Modifier::BOLD),
)];
match (&svc.private_url, &svc.public_url) {
(Some(priv_url), Some(pub_url)) => {
spans.push(Span::raw(priv_url.clone()));
spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray)));
spans.push(Span::raw(pub_url.clone()));
}
(Some(url), None) | (None, Some(url)) => {
spans.push(Span::raw(url.clone()));
}
(None, None) => {
if let Some(cmd) = &svc.command {
spans.push(Span::styled(cmd.clone(), Style::default().fg(Color::Gray)));
} else if let Some(img) = &svc.image {
spans.push(Span::styled(img.clone(), Style::default().fg(Color::Gray)));
}
}
}
spans.push(Span::styled(
format!(" ({} vars)", svc.var_count),
Style::default().fg(Color::DarkGray),
));
Line::from(spans)
})
.collect();
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_help_bar(app: &TuiApp, frame: &mut Frame, area: Rect) {
let mut help_text = vec![
Span::styled("1-9", Style::default().fg(Color::Yellow)),
Span::raw(" tab "),
Span::styled("j/k", Style::default().fg(Color::Yellow)),
Span::raw(" scroll "),
Span::styled("drag", Style::default().fg(Color::Yellow)),
Span::raw(" copy "),
Span::styled("i", Style::default().fg(Color::Yellow)),
Span::raw(if app.show_info { " hide" } else { " info" }),
Span::raw(" "),
Span::styled("f", Style::default().fg(Color::Yellow)),
Span::raw(" follow "),
Span::styled("r", Style::default().fg(Color::Yellow)),
Span::raw(" restart "),
Span::styled("q", Style::default().fg(Color::Yellow)),
Span::raw(" quit"),
];
if let Some(instant) = app.copied_feedback {
if instant.elapsed().as_secs() < 2 {
help_text.push(Span::styled(
" [Copied!]",
Style::default().fg(Color::Green),
));
}
} else if let Some(instant) = app.copy_failed {
if instant.elapsed().as_secs() < 2 {
help_text.push(Span::styled(
" [Copy failed]",
Style::default().fg(Color::Red),
));
}
}
let paragraph = Paragraph::new(Line::from(help_text));
frame.render_widget(paragraph, area);
}