use std::cell::Cell;
use log::Level;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, List, ListItem, Paragraph},
};
use crate::prelude::*;
use input::{Key, KeyEvent};
use log_buffer::LogEntry;
use operation::{Event, LogViewOp, Operation};
use settings::Settings;
use views::View;
use widgets::focus::FocusRing;
use widgets::focusable::FocusOp;
use widgets::input_field::InputField;
const FOCUS_FILTER: &str = "filter";
const FOCUS_LOG_LIST: &str = "log_list";
#[derive(Debug)]
pub struct LogView {
entries: Vec<LogEntry>,
filter: InputField,
focus: FocusRing,
scroll: Cell<usize>,
auto_scroll: bool,
}
impl LogView {
pub fn new(snapshot: Vec<LogEntry>) -> Self {
Self {
entries: snapshot,
filter: InputField::new("Filter"),
focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_LOG_LIST]),
scroll: Cell::new(0),
auto_scroll: true,
}
}
pub fn with_filter(snapshot: Vec<LogEntry>, filter_text: impl Into<String>) -> Self {
let mut v = Self::new(snapshot);
v.filter.set_text(filter_text.into());
v.scroll.set(usize::MAX);
v
}
pub fn push(&mut self, entry: LogEntry) {
self.entries.push(entry);
if self.auto_scroll {
self.scroll.set(usize::MAX);
}
}
fn filtered(&self) -> Vec<&LogEntry> {
let q = self.filter.text().to_lowercase();
self.entries
.iter()
.filter(|e| {
q.is_empty()
|| e.message.to_lowercase().contains(&q)
|| e.target.to_lowercase().contains(&q)
|| level_name(e.level).to_lowercase().contains(&q)
})
.collect()
}
}
impl View for LogView {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Modal;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
if key.key == Key::Tab {
return vec![Operation::Focus(FocusOp::Next)];
}
if key.key == Key::BackTab {
return vec![Operation::Focus(FocusOp::Prev)];
}
match self.focus.current() {
FOCUS_FILTER => {
if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
return vec![Operation::LogViewLocal(LogViewOp::FilterInput(field_op))];
}
match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
_ => vec![],
}
}
FOCUS_LOG_LIST => {
match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
_ => {
if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
return vec![Operation::LogViewLocal(LogViewOp::FilterInput(
field_op,
))];
}
vec![]
}
}
}
_ => vec![],
}
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::Focus(focus_op) => {
match focus_op {
FocusOp::Next => self.focus.focus_next(),
FocusOp::Prev => self.focus.focus_prev(),
_ => {}
}
Some(Event::applied("log_view", op.clone()))
}
Operation::LogViewLocal(lv_op) => {
match lv_op {
LogViewOp::FilterInput(field_op) => {
self.filter.apply(field_op);
self.scroll.set(usize::MAX);
self.auto_scroll = true;
}
}
Some(Event::applied("log_view", op.clone()))
}
Operation::NavigateUp => {
self.auto_scroll = false;
self.scroll.set(self.scroll.get().saturating_sub(1));
Some(Event::applied("log_view", op.clone()))
}
Operation::NavigateDown => {
self.scroll.set(self.scroll.get() + 1);
Some(Event::applied("log_view", op.clone()))
}
Operation::NavigatePageUp => {
self.auto_scroll = false;
self.scroll.set(self.scroll.get().saturating_sub(10));
Some(Event::applied("log_view", op.clone()))
}
Operation::NavigatePageDown => {
self.scroll.set(self.scroll.get() + 10);
Some(Event::applied("log_view", op.clone()))
}
_ => None,
}
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
let filter_focused = self.focus.is_focused(FOCUS_FILTER);
self.filter.render(frame, chunks[0], filter_focused, theme);
if self.auto_scroll {
let indicator_x = chunks[0].x + chunks[0].width.saturating_sub(8);
let indicator_area = Rect {
x: indicator_x,
y: chunks[0].y,
width: 8.min(chunks[0].width),
height: 1,
};
frame.render_widget(
Paragraph::new(Span::styled(
" [auto]",
Style::new().fg(theme.fg_dim()).bg(if filter_focused {
theme.bg_active()
} else {
theme.bg_inactive()
}),
)),
indicator_area,
);
}
let list_focused = self.focus.is_focused(FOCUS_LOG_LIST);
let list_bg = if list_focused {
theme.bg_active()
} else {
theme.bg_inactive()
};
let list_area = chunks[1];
frame.render_widget(Block::default().style(Style::new().bg(list_bg)), list_area);
let title_area = Rect {
height: 1,
..list_area
};
let content_area = Rect {
y: list_area.y + 1,
height: list_area.height.saturating_sub(1),
..list_area
};
let filtered: Vec<LogEntry> = self.filtered().into_iter().cloned().collect();
let total = filtered.len();
let visible_rows = content_area.height as usize;
let max_scroll = total.saturating_sub(visible_rows);
let scroll = if self.auto_scroll && total > 0 {
max_scroll
} else {
self.scroll.get().min(max_scroll)
};
self.scroll.set(scroll);
frame.render_widget(
Paragraph::new(Span::styled(
format!(" {} entries ({} matching)", total, filtered.len()),
Style::new()
.fg(theme.accent())
.bg(list_bg)
.add_modifier(Modifier::BOLD),
)),
title_area,
);
let items: Vec<ListItem> = filtered
.iter()
.skip(scroll)
.take(visible_rows)
.map(|e| {
let time = format_time(e.time);
let level_str = format!("{:<5}", level_name(e.level));
let level_color = level_color(e.level, theme);
let target_short: String = e
.target
.split("::")
.last()
.unwrap_or(&e.target)
.chars()
.take(12)
.collect();
let line = Line::from(vec![
Span::styled(format!("{time} "), Style::default().fg(theme.fg_dim())),
Span::styled(
format!("{level_str} "),
Style::default()
.fg(level_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:<12} ", target_short),
Style::default().fg(Color::Gray),
),
Span::raw(e.message.as_str()),
]);
ListItem::new(line)
})
.collect();
frame.render_widget(List::new(items), content_area);
}
}
fn level_name(level: Level) -> &'static str {
match level {
Level::Error => "ERROR",
Level::Warn => "WARN",
Level::Info => "INFO",
Level::Debug => "DEBUG",
Level::Trace => "TRACE",
}
}
fn level_color(level: Level, theme: &crate::theme::Theme) -> Color {
match level {
Level::Error => theme.level_error(),
Level::Warn => theme.level_warn(),
Level::Info => theme.level_info(),
Level::Debug => theme.level_debug(),
Level::Trace => theme.level_trace(),
}
}
fn format_time(t: std::time::SystemTime) -> String {
use std::time::{Duration, UNIX_EPOCH};
let secs = t
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02}")
}