diff --git a/Cargo.lock b/Cargo.lock index 8ec3d45..08d7e2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2305,6 +2305,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "toml", ] @@ -2328,6 +2329,7 @@ dependencies = [ "log-viewer-core", "ratatui", "serde_json", + "tempfile", ] [[package]] diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index c7ff44d..8f1b3f0 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1,8 +1,35 @@ -use crate::app::App; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; + +use crate::app::{App, AppMode}; +use crate::color::level_fg; +use log_viewer_core::config::ColorConfig; +use log_viewer_core::types::LogLevel; + +pub(crate) fn build_line_spans( + gutter_text: String, + content_text: String, + is_cursor: bool, + level: Option<&LogLevel>, + config: &ColorConfig, +) -> Line<'static> { + let bg_color = if is_cursor { + Color::DarkGray + } else { + Color::Reset + }; + let level_fg = level_fg(level, config).unwrap_or(Color::White); + Line::from(vec![ + Span::styled( + gutter_text, + Style::default().fg(Color::DarkGray).bg(bg_color), + ), + Span::styled(content_text, Style::default().fg(level_fg).bg(bg_color)), + ]) +} pub fn render(frame: &mut ratatui::Frame, app: &mut App) { use ratatui::layout::{Constraint, Layout}; - use ratatui::style::Style; use ratatui::widgets::Paragraph; let outer = Layout::vertical([ @@ -13,7 +40,9 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { .split(frame.area()); // ── Title bar ────────────────────────────────────────────────── - let title_text = if app.is_loaded() { + let title_text = if app.mode == AppMode::Settings { + " Color Settings".to_string() + } else if app.is_loaded() { let name = app.file_name().unwrap_or("unknown"); let cursor_display = if app.total_lines() == 0 { 0 @@ -30,22 +59,98 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { ); // ── Content area ─────────────────────────────────────────────── - if !app.is_loaded() { + if app.mode == AppMode::Settings { + render_settings(frame, app, outer[1]); + } else if !app.is_loaded() { frame.render_widget(Paragraph::new(" No file loaded"), outer[1]); } else { render_content(frame, app, outer[1]); } // ── Status bar ───────────────────────────────────────────────── - frame.render_widget( - Paragraph::new(" j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format q:quit"), - outer[2], - ); + let status_text = if app.mode == AppMode::Settings { + " j/k:navigate ←/→:change 1-8:jump Enter:save Esc:cancel" + } else { + " j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit" + }; + frame.render_widget(Paragraph::new(status_text), outer[2]); +} + +pub fn render_settings(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layout::Rect) { + use ratatui::style::{Color, Modifier, Style}; + use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + + use crate::color::AVAILABLE_COLORS; + + frame.render_widget(Clear, area); + + let popup_w = ((area.width as u32 * 4 / 5).max(40)).min(area.width as u32) as u16; + let popup_h = ((area.height as u32 * 4 / 5).max(14)).min(area.height as u32) as u16; + let popup_x = area.width.saturating_sub(popup_w) / 2; + let popup_y = area.height.saturating_sub(popup_h) / 2; + let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h); + + let block = Block::new().borders(Borders::ALL).title(" Color Settings "); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let levels = [ + ("ERROR", &app.settings_draft.error), + ("WARN", &app.settings_draft.warn), + ("INFO", &app.settings_draft.info), + ("DEBUG", &app.settings_draft.debug), + ("TRACE", &app.settings_draft.trace), + ("UNKNOWN", &app.settings_draft.unknown), + ]; + + let mut lines = Vec::new(); + for (i, (level_name, color_name)) in levels.iter().enumerate() { + let is_selected = i == app.settings_cursor; + let cursor_marker = if is_selected { "▶ " } else { " " }; + + let preview_color = color_name.parse::().unwrap_or(Color::White); + let color_block = format!("██ {}", color_name); + + let mut spans = vec![ + Span::styled( + cursor_marker.to_string(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{:<8}", level_name), + Style::default() + .fg(preview_color) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(color_block, Style::default().fg(preview_color)), + ]; + + if is_selected { + spans.push(Span::raw(" ")); + spans.push(Span::styled("←/→ ", Style::default().fg(Color::DarkGray))); + for &c in AVAILABLE_COLORS { + let marker = if c == *color_name { "●" } else { "○" }; + spans.push(Span::styled( + format!("{}{} ", marker, c), + Style::default().fg(c.parse::().unwrap_or(Color::White)), + )); + } + } + + lines.push(Line::from(spans)); + } + + lines.push(Line::raw("")); + lines.push(Line::from(vec![Span::styled( + " j/k: navigate ←/→: change 1-8: jump Enter: save Esc: cancel", + Style::default().fg(Color::DarkGray), + )])); + + frame.render_widget(Paragraph::new(lines), inner); } fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layout::Rect) { - use ratatui::style::{Color, Style}; - use ratatui::text::Line; use ratatui::widgets::Paragraph; let content_width = area.width as usize; @@ -109,11 +214,7 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo } let is_cursor = logical_line == app.cursor_line; - let style = if is_cursor { - Style::default().bg(Color::DarkGray) - } else { - Style::default() - }; + let level = app.level_cache.get(logical_line).and_then(|l| l.as_ref()); let gutter_text = if visual_row == 0 { format!( @@ -125,8 +226,13 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo format!("{:width$} \u{2502}", "", width = line_num_width) }; - let full_line = format!("{}{}", gutter_text, text); - lines.push(Line::styled(full_line, style)); + lines.push(build_line_spans( + gutter_text, + text.clone(), + is_cursor, + level, + &app.color_config, + )); current_visual_offset += 1; } @@ -137,8 +243,65 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo } while lines.len() < available_rows { - lines.push(Line::styled(String::new(), Style::default())); + lines.push(Line::default()); } frame.render_widget(Paragraph::new(lines), area); } + +#[cfg(test)] +mod tests { + use super::*; + use log_viewer_core::config::ColorConfig; + use log_viewer_core::types::LogLevel; + + #[test] + fn test_build_line_spans_gutter_fg_darkgray() { + let line = build_line_spans( + "1 │".to_string(), + "hello".to_string(), + false, + Some(&LogLevel::Error), + &ColorConfig::default(), + ); + assert_eq!(line.spans[0].style.fg, Some(Color::DarkGray)); + } + + #[test] + fn test_build_line_spans_content_fg_level_color() { + let line = build_line_spans( + "1 │".to_string(), + "hello".to_string(), + false, + Some(&LogLevel::Error), + &ColorConfig::default(), + ); + assert_eq!(line.spans[1].style.fg, Some(Color::Red)); + } + + #[test] + fn test_build_line_spans_cursor_bg_darkgray() { + let line = build_line_spans( + "1 │".to_string(), + "hello".to_string(), + true, + Some(&LogLevel::Error), + &ColorConfig::default(), + ); + assert_eq!(line.spans[0].style.bg, Some(Color::DarkGray)); + assert_eq!(line.spans[1].style.bg, Some(Color::DarkGray)); + } + + #[test] + fn test_build_line_spans_non_cursor_bg_reset() { + let line = build_line_spans( + "1 │".to_string(), + "hello".to_string(), + false, + Some(&LogLevel::Error), + &ColorConfig::default(), + ); + assert_eq!(line.spans[0].style.bg, Some(Color::Reset)); + assert_eq!(line.spans[1].style.bg, Some(Color::Reset)); + } +}