use ratatui::{
backend::Backend,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
use crate::shared::config::Config;
use ansi_to_tui::IntoText;
use std::collections::VecDeque;
use std::time::Instant;
use tailspin::Highlighter;
pub struct LogEntry {
pub timestamp: Instant,
pub message: String,
pub level: LogLevel,
}
pub enum LogLevel {
Info,
Warning,
Error,
Debug,
}
pub struct LogView {
logs: VecDeque<LogEntry>,
max_logs: usize,
scroll_position: usize,
follow_mode: bool,
parsed_cache: Vec<Option<Vec<Line<'static>>>>,
last_recolor_time: Instant,
}
impl LogView {
pub fn new(max_logs: usize) -> Self {
Self {
logs: VecDeque::with_capacity(max_logs),
max_logs,
scroll_position: 0,
follow_mode: true,
parsed_cache: Vec::new(),
last_recolor_time: Instant::now(),
}
}
pub fn add_log(&mut self, message: String, level: LogLevel) {
let log_entry = LogEntry {
timestamp: Instant::now(),
message,
level,
};
self.logs.push_back(log_entry);
self.parsed_cache.push(None);
if self.logs.len() > self.max_logs {
self.logs.pop_front();
if !self.parsed_cache.is_empty() {
self.parsed_cache.remove(0);
}
}
if self.follow_mode {
self.scroll_to_bottom();
}
}
pub fn scroll_up(&mut self) {
if self.scroll_position > 0 {
self.scroll_position -= 1;
self.follow_mode = false;
}
}
pub fn scroll_down(&mut self) {
if self.scroll_position < self.logs.len().saturating_sub(1) {
self.scroll_position += 1;
self.follow_mode = false;
}
}
pub fn page_up(&mut self, visible_height: usize) {
if self.scroll_position > visible_height {
self.scroll_position -= visible_height;
} else {
self.scroll_position = 0;
}
self.follow_mode = false;
}
pub fn page_down(&mut self, visible_height: usize) {
let max_scroll = self.logs.len().saturating_sub(1);
self.scroll_position = (self.scroll_position + visible_height).min(max_scroll);
self.follow_mode = false;
}
pub fn enable_follow(&mut self) {
self.follow_mode = true;
self.scroll_to_bottom();
}
pub fn scroll_to_bottom(&mut self) {
self.scroll_position = self.logs.len().saturating_sub(1);
}
pub fn scroll_to_top(&mut self) {
self.scroll_position = 0;
}
pub fn get_scroll_position(&mut self) -> usize {
self.scroll_position
}
pub fn get_log_count(&self) -> usize {
self.logs.len()
}
pub fn set_scroll_position(&mut self, position: usize) {
self.scroll_position = position.min(self.logs.len().saturating_sub(1));
}
}
fn parse_log_with_tailspin(message: &str, config: &Config) -> Vec<Line<'static>> {
let highlighter = Highlighter::default();
let highlighted_message = highlighter.apply(message);
match highlighted_message.as_ref().into_text() {
Ok(text) => {
text.lines
.into_iter()
.map(|line| {
let owned_spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| Span::styled(span.content.into_owned(), span.style))
.collect();
Line::from(owned_spans)
})
.collect()
}
Err(_) => {
vec![Line::from(vec![Span::styled(
message.to_string(),
Style::default()
.bg(config.get_color("background_main"))
.fg(config.get_color("text_main")),
)])]
}
}
}
pub fn render_log_view<B: Backend>(
f: &mut Frame,
log_view: &mut LogView,
area: Rect,
config: &Config,
) {
let logs = &log_view.logs;
let now = Instant::now();
let should_recolor = now.duration_since(log_view.last_recolor_time).as_millis() >= 100;
while log_view.parsed_cache.len() < logs.len() {
log_view.parsed_cache.push(None);
}
if should_recolor {
for (idx, entry) in logs.iter().enumerate() {
if log_view.parsed_cache[idx].is_none() {
let parsed = parse_log_with_tailspin(&entry.message, config);
log_view.parsed_cache[idx] = Some(parsed);
}
}
log_view.last_recolor_time = now;
}
let log_lines: Vec<Line> = logs
.iter()
.enumerate()
.map(|(idx, entry)| {
if let Some(Some(cached_lines)) = log_view.parsed_cache.get(idx) {
if cached_lines.is_empty() {
Line::from(vec![Span::raw("")])
} else if cached_lines.len() == 1 {
cached_lines[0].clone()
} else {
let all_spans: Vec<Span> = cached_lines
.iter()
.flat_map(|line| line.spans.clone())
.collect();
Line::from(all_spans)
}
} else {
Line::from(vec![Span::styled(
entry.message.clone(),
Style::default()
.bg(config.get_color("background_main"))
.fg(config.get_color("text_main")),
)])
}
})
.collect();
let current_position = if logs.is_empty() {
"No logs".to_string()
} else {
format!("{}/{}", log_view.scroll_position + 1, logs.len())
};
let available_width = area.width.saturating_sub(2) as usize; let mut wrapped_lines_before_scroll = 0;
for (idx, entry) in logs.iter().enumerate() {
if idx >= log_view.scroll_position {
break;
}
let line_to_measure = if let Some(Some(cached_lines)) = log_view.parsed_cache.get(idx) {
if !cached_lines.is_empty() {
cached_lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.chars().count())
.sum()
} else {
0
}
} else {
entry.message.chars().count()
};
let lines_needed = if available_width > 0 {
line_to_measure.div_ceil(available_width)
} else {
1
};
wrapped_lines_before_scroll += lines_needed.max(1);
}
let log_widget = Paragraph::new(log_lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Logs ({})", current_position)),
)
.style(Style::default().bg(config.get_color("background_main")))
.wrap(Wrap { trim: false })
.scroll((wrapped_lines_before_scroll as u16, 0));
f.render_widget(log_widget, area);
}