use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use super::design;
use super::theme;
use crate::app::{App, ContainerLogsSearch, Screen};
use crate::handler::container_logs as logs_handler;
use crate::messages::footer as fl;
pub fn render(frame: &mut Frame, app: &mut App) {
let (alias, container_name, body, fetched_at, error, scroll, search) = {
let Screen::ContainerLogs {
alias,
container_name,
body,
fetched_at,
error,
scroll,
search,
..
} = &app.screen
else {
return;
};
(
alias.clone(),
container_name.clone(),
body.clone(),
*fetched_at,
error.clone(),
*scroll,
search.clone(),
)
};
let area = frame.area();
frame.render_widget(Clear, area);
let search_bar_h: u16 = if search.is_some() { 1 } else { 0 };
let [body_area, search_area, footer_area] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(search_bar_h),
Constraint::Length(1),
])
.areas(area);
let title_line = build_title(&alias, &container_name, fetched_at, error.as_deref(), app);
let block = if search.is_some() {
design::search_overlay_block_line(title_line)
} else {
design::overlay_block_line(title_line)
};
let inner = block.inner(body_area);
frame.render_widget(block, body_area);
let max_scroll = logs_handler::tail_scroll(body.len(), inner.height);
let effective_scroll = scroll.min(max_scroll);
render_body(
frame,
inner,
&body,
error.as_deref(),
effective_scroll,
search.as_ref(),
);
if let Some(s) = search.as_ref() {
render_search_bar(frame, search_area, s);
}
if let Screen::ContainerLogs {
last_render_height, ..
} = &mut app.screen
{
*last_render_height = inner.height;
}
let footer_spans = build_footer_spans(search.as_ref());
frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer_area);
}
fn build_footer_spans(search: Option<&ContainerLogsSearch>) -> Vec<Span<'static>> {
match search {
Some(_) => design::Footer::new()
.primary("Esc", fl::ACTION_CLOSE)
.action("Tab/Shift-Tab", fl::ACTION_MATCH)
.action("←/→", fl::ACTION_MOVE)
.action("Bksp", fl::ACTION_DEL)
.into_spans(),
None => design::Footer::new()
.primary("Esc", fl::ACTION_BACK)
.action("/", fl::ACTION_SEARCH)
.action("g", fl::ACTION_TOP)
.action("G", fl::ACTION_BOTTOM)
.action("j/k", fl::ACTION_SCROLL)
.action("PgUp/PgDn", fl::ACTION_PAGE)
.action("r", fl::ACTION_REFRESH)
.into_spans(),
}
}
fn build_title(
alias: &str,
container_name: &str,
fetched_at: u64,
error: Option<&str>,
app: &App,
) -> Line<'static> {
let now = if app.demo_mode {
crate::demo_flag::now_secs()
} else {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
};
let mut spans = vec![
Span::styled(" logs ", theme::bold()),
Span::styled(format!("· {} ", container_name), theme::bold()),
Span::styled(format!("· on {} ", alias), theme::muted()),
];
if let Some(e) = error {
spans.push(Span::styled(
format!("· {} ", crate::messages::container_logs_failed(e)),
theme::error(),
));
} else if fetched_at == 0 {
spans.push(Span::styled(
format!("· {} ", crate::messages::CONTAINER_LOGS_LOADING),
theme::muted(),
));
} else {
let age = now.saturating_sub(fetched_at);
spans.push(Span::styled(
format!("· {} ", crate::messages::container_logs_fetched(age)),
theme::muted(),
));
}
Line::from(spans)
}
fn render_search_bar(frame: &mut Frame, area: Rect, search: &ContainerLogsSearch) {
use unicode_width::UnicodeWidthStr;
let suffix = if search.query.is_empty() {
String::new()
} else if search.matches.is_empty() {
format!(" ({})", crate::messages::CONTAINER_LOGS_SEARCH_NO_MATCHES)
} else {
format!(
" ({})",
crate::messages::container_logs_search_position(
search.current + 1,
search.matches.len()
)
)
};
let spans = vec![
Span::styled(" / ", theme::brand_badge()),
Span::raw(" "),
Span::raw(search.query.clone()),
Span::styled(suffix, theme::muted()),
];
frame.render_widget(Paragraph::new(Line::from(spans)), area);
const PREFIX_W: u16 = 4; let prefix: String = search.query.chars().take(search.cursor_pos).collect();
let cursor_x = area
.x
.saturating_add(PREFIX_W)
.saturating_add(prefix.width() as u16);
if area.width > 0 && cursor_x < area.x.saturating_add(area.width) {
frame.set_cursor_position((cursor_x, area.y));
}
}
fn render_body(
frame: &mut Frame,
area: Rect,
body: &[String],
error: Option<&str>,
scroll: u16,
search: Option<&ContainerLogsSearch>,
) {
if let Some(e) = error {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
format!(" fetch failed: {}", e),
theme::error(),
)),
];
frame.render_widget(Paragraph::new(lines), area);
return;
}
if body.is_empty() {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
format!(" {}", crate::messages::CONTAINER_LOGS_LOADING),
theme::muted(),
)),
];
frame.render_widget(Paragraph::new(lines), area);
return;
}
let current_line = search.and_then(|s| s.matches.get(s.current).copied());
let lines: Vec<Line<'_>> = body
.iter()
.enumerate()
.map(|(idx, line)| highlight_line(idx, line, search, current_line))
.collect();
let paragraph = Paragraph::new(lines).scroll((scroll, 0));
frame.render_widget(paragraph, area);
}
fn highlight_line<'a>(
idx: usize,
line: &'a str,
search: Option<&ContainerLogsSearch>,
current_line: Option<usize>,
) -> Line<'a> {
let Some(s) = search.filter(|s| !s.query.is_empty()) else {
return Line::from(line);
};
let positions = logs_handler::match_indices_smart(line, &s.query);
if positions.is_empty() {
return Line::from(line);
}
let q_len = s.query.len();
let style = if current_line == Some(idx) {
theme::selected_row()
} else {
theme::highlight_bold()
};
let mut spans: Vec<Span<'a>> = Vec::with_capacity(positions.len() * 2 + 1);
let mut cursor = 0;
for start in positions {
if start > cursor {
spans.push(Span::raw(&line[cursor..start]));
}
let end = (start + q_len).min(line.len());
spans.push(Span::styled(&line[start..end], style));
cursor = end;
}
if cursor < line.len() {
spans.push(Span::raw(&line[cursor..]));
}
Line::from(spans)
}