From c6e83cb8c2a8480cd9a1b93dd6e86b69fb1412f7 Mon Sep 17 00:00:00 2001 From: dailz Date: Sun, 12 Apr 2026 10:52:03 +0800 Subject: [PATCH] feat(tui): add AppMode, level_cache and config integration Add AppMode enum (Normal/Settings), level_cache field populated in recompute_wrap_cache via detect_level(), color_config loaded from TOML in main.rs. Refactor handle_key for mode dispatch with S key to enter settings. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- Cargo.toml | 1 + crates/tui/Cargo.toml | 3 + crates/tui/src/app.rs | 456 ++++++++++++++++++++++++++++++++++++++++- crates/tui/src/main.rs | 2 + 4 files changed, 451 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 41bcf8c..418a26f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ crossterm = "0.29" clap = { version = "4", features = ["derive"] } log-viewer-core = { path = "crates/core" } textwrap = "0.16" +tempfile = "3" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 30808c6..7912dc8 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -10,3 +10,6 @@ clap.workspace = true anyhow.workspace = true log-viewer-core.workspace = true serde_json.workspace = true + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 37f03b0..1c83175 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,7 +1,17 @@ use std::path::Path; use std::time::Instant; +use log_viewer_core::config::ColorConfig; use log_viewer_core::io::file_reader::FileReader; +use log_viewer_core::types::LogLevel; + +use crate::color::AVAILABLE_COLORS; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AppMode { + Normal, + Settings, +} /// Split a line into chunks of exactly `width` characters (display columns). /// For a log viewer, we want character-level wrapping, not word-level. @@ -87,6 +97,15 @@ pub struct App { // JSON formatting pub(crate) json_format: bool, + + // Mode & level coloring + pub(crate) mode: AppMode, + pub(crate) level_cache: Vec>, + pub(crate) color_config: ColorConfig, + + // Settings panel state + pub(crate) settings_cursor: usize, + pub(crate) settings_draft: ColorConfig, } impl App { @@ -105,6 +124,11 @@ impl App { content_height: 0, last_g_press: None, json_format: false, + mode: AppMode::Normal, + level_cache: Vec::new(), + color_config: ColorConfig::default(), + settings_cursor: 0, + settings_draft: ColorConfig::default(), } } @@ -120,6 +144,8 @@ impl App { self.cached_width = 0; // force wrap cache rebuild on next render self.last_g_press = None; // reset gg state machine self.json_format = false; + self.level_cache.clear(); + self.mode = AppMode::Normal; Ok(()) } @@ -136,13 +162,16 @@ impl App { self.wrap_cache.clear(); self.visual_heights.clear(); self.visual_heights.reserve(line_count); + self.level_cache.clear(); for i in 0..line_count { - let raw = self.get_line(i).unwrap_or(""); + let raw = self.get_line(i).unwrap_or("").to_owned(); + let level = log_viewer_core::parser::level::detect_level(&raw); + self.level_cache.push(level); let display_text = if self.json_format { - format_json_line(raw) + format_json_line(&raw) } else { - raw.to_string() + raw }; // Critical: to_string_pretty returns a single string with \n, // but wrap_line_chars doesn't handle \n. Must split on \n first, @@ -313,6 +342,13 @@ impl App { // ── Key handling ──────────────────────────────────────────────── pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) { + match self.mode { + AppMode::Normal => self.handle_normal_key(key), + AppMode::Settings => self.handle_settings_key(key), + } + } + + fn handle_normal_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::{KeyCode, KeyModifiers}; match key.code { @@ -375,12 +411,98 @@ impl App { self.cached_width = 0; // Force wrap cache rebuild self.last_g_press = None; } + KeyCode::Char('s') | KeyCode::Char('S') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.settings_draft = self.color_config.clone(); + self.mode = AppMode::Settings; + } _ => { self.last_g_press = None; } } } + fn handle_settings_key(&mut self, key: crossterm::event::KeyEvent) { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.mode = AppMode::Normal; + } + KeyCode::Enter => { + self.color_config = self.settings_draft.clone(); + let _ = self.color_config.save(); + self.mode = AppMode::Normal; + } + KeyCode::Char('j') | KeyCode::Down => { + if self.settings_cursor < 5 { + self.settings_cursor += 1; + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.settings_cursor = self.settings_cursor.saturating_sub(1); + } + KeyCode::Left => { + self.cycle_color(self.settings_cursor, false); + } + KeyCode::Right => { + self.cycle_color(self.settings_cursor, true); + } + KeyCode::Char(c) if ('1'..='8').contains(&c) => { + let idx = (c as usize) - ('1' as usize); + self.set_color(self.settings_cursor, AVAILABLE_COLORS[idx]); + } + _ => {} + } + } + + fn cycle_color(&mut self, level_idx: usize, forward: bool) { + let current = self.get_settings_color(level_idx).to_string(); + let colors = AVAILABLE_COLORS; + let pos = colors.iter().position(|&c| c == current); + let new_pos = match pos { + Some(p) => { + if forward { + (p + 1) % colors.len() + } else { + p.saturating_sub(1).min(colors.len() - 1) + } + } + None => { + if forward { + 0 + } else { + colors.len() - 1 + } + } + }; + self.set_color(level_idx, colors[new_pos]); + } + + fn get_settings_color(&self, level_idx: usize) -> &str { + match level_idx { + 0 => &self.settings_draft.error, + 1 => &self.settings_draft.warn, + 2 => &self.settings_draft.info, + 3 => &self.settings_draft.debug, + 4 => &self.settings_draft.trace, + 5 => &self.settings_draft.unknown, + _ => "white", + } + } + + fn set_color(&mut self, level_idx: usize, color_name: &str) { + match level_idx { + 0 => self.settings_draft.error = color_name.to_string(), + 1 => self.settings_draft.warn = color_name.to_string(), + 2 => self.settings_draft.info = color_name.to_string(), + 3 => self.settings_draft.debug = color_name.to_string(), + 4 => self.settings_draft.trace = color_name.to_string(), + 5 => self.settings_draft.unknown = color_name.to_string(), + _ => {} + } + } + // ── Utility methods ───────────────────────────────────────────── #[allow(dead_code)] @@ -844,10 +966,7 @@ plain text line fn test_tab_toggle_keeps_cursor_visible() { let mut content = String::new(); for i in 0..100 { - content.push_str(&format!( - r#"{{"line":{},"data":"value{}"}}"#, - i, i - )); + content.push_str(&format!(r#"{{"line":{},"data":"value{}"}}"#, i, i)); content.push('\n'); } let path = make_temp_file(&content); @@ -866,7 +985,9 @@ plain text line cursor_visual >= app.v_offset && cursor_visual < app.v_offset + app.content_height as usize, "cursor should be visible before Tab: cursor_visual={}, v_offset={}, content_height={}", - cursor_visual, app.v_offset, app.content_height, + cursor_visual, + app.v_offset, + app.content_height, ); let v_offset_before = app.v_offset; @@ -882,10 +1003,12 @@ plain text line let cursor_visual_after = app.cursor_to_first_visual_row(50); assert!( cursor_visual_after >= app.v_offset - && cursor_visual_after - < app.v_offset + app.content_height as usize, + && cursor_visual_after < app.v_offset + app.content_height as usize, "cursor should be visible after Tab: cursor_visual={}, v_offset={}, content_height={}, v_offset_before={}", - cursor_visual_after, app.v_offset, app.content_height, v_offset_before, + cursor_visual_after, + app.v_offset, + app.content_height, + v_offset_before, ); assert_ne!( @@ -896,4 +1019,315 @@ plain text line cleanup(&path); assert!(result.is_ok()); } + + // ── Task 3 tests: AppMode, level_cache, color_config ─────────── + + #[test] + fn test_color_config_default_in_new() { + let app = App::new(); + assert_eq!(app.color_config, ColorConfig::default()); + } + + #[test] + fn test_app_mode_default_normal() { + let app = App::new(); + assert_eq!(app.mode, AppMode::Normal); + } + + #[test] + fn test_s_key_enters_settings() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut app = App::new(); + let s_key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE); + app.handle_key(s_key); + assert_eq!(app.mode, AppMode::Settings); + } + + #[test] + fn test_load_file_resets_mode() { + let path = make_temp_file("test\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.mode = AppMode::Settings; + app.load_file(path.to_str().unwrap()).unwrap(); + assert_eq!(app.mode, AppMode::Normal); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_level_cache_populated() { + let path = make_temp_file("ERROR: fail\nWARN: maybe\njust text\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.content_height = 50; + app.recompute_wrap_cache(80); + assert!(!app.level_cache.is_empty()); + assert_eq!(app.level_cache.len(), 3); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_level_cache_cleared_on_reload() { + let path_a = make_temp_file("ERROR: a\nINFO: b\n"); + let path_b = make_temp_file("WARN: c\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path_a.to_str().unwrap()).unwrap(); + app.content_height = 50; + app.recompute_wrap_cache(80); + assert_eq!(app.level_cache.len(), 2); + + app.load_file(path_b.to_str().unwrap()).unwrap(); + app.recompute_wrap_cache(80); + assert_eq!(app.level_cache.len(), 1); + cleanup(&path_b); + }); + cleanup(&path_a); + assert!(result.is_ok()); + } + + #[test] + fn test_level_cache_json_level() { + let line = r#"{"level":"ERROR","message":"fail"}"#; + let path = make_temp_file(&format!("{line}\n")); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.content_height = 50; + app.recompute_wrap_cache(80); + assert_eq!(app.level_cache[0], Some(LogLevel::Error)); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_level_cache_plain_text() { + let path = make_temp_file("ERROR: something\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.content_height = 50; + app.recompute_wrap_cache(80); + assert_eq!(app.level_cache[0], Some(LogLevel::Error)); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_level_cache_uses_raw_line() { + let json_line = r#"{"level":"WARN","msg":"careful"}"#; + let path = make_temp_file(&format!("{json_line}\n")); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.json_format = true; + app.content_height = 50; + app.recompute_wrap_cache(80); + assert_eq!(app.level_cache[0], Some(LogLevel::Warn)); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_level_cache_wrap_cache_length_match() { + let path = make_temp_file("ERROR: a\nINFO: b\nDEBUG: c\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.content_height = 50; + app.recompute_wrap_cache(80); + assert_eq!( + app.level_cache.len(), + app.wrap_cache.len(), + "level_cache and wrap_cache must have same length" + ); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + // ── Task 5 tests: Settings panel ──────────────────────────────── + + fn make_key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent { + crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE) + } + + fn enter_settings(app: &mut App) { + app.handle_key(make_key(crossterm::event::KeyCode::Char('s'))); + } + + #[test] + fn test_settings_enter() { + let mut app = App::new(); + enter_settings(&mut app); + assert_eq!(app.mode, AppMode::Settings); + } + + #[test] + fn test_settings_esc_cancel() { + let mut app = App::new(); + enter_settings(&mut app); + assert_eq!(app.settings_draft, app.color_config); + + app.handle_key(make_key(crossterm::event::KeyCode::Right)); + app.handle_key(make_key(crossterm::event::KeyCode::Esc)); + + assert_eq!(app.mode, AppMode::Normal); + assert_eq!( + app.color_config, + ColorConfig::default(), + "Esc should NOT save changes" + ); + } + + #[test] + fn test_settings_enter_save() { + let mut app = App::new(); + let original = app.color_config.clone(); + enter_settings(&mut app); + + app.handle_key(make_key(crossterm::event::KeyCode::Right)); + assert_ne!( + app.settings_draft.error, original.error, + "Right should change draft" + ); + + app.handle_key(make_key(crossterm::event::KeyCode::Enter)); + assert_eq!(app.mode, AppMode::Normal); + assert_eq!( + app.color_config, app.settings_draft, + "Enter should save draft to config" + ); + } + + #[test] + fn test_settings_navigation() { + let mut app = App::new(); + enter_settings(&mut app); + assert_eq!(app.settings_cursor, 0); + + app.handle_key(make_key(crossterm::event::KeyCode::Char('j'))); + assert_eq!(app.settings_cursor, 1); + + app.handle_key(make_key(crossterm::event::KeyCode::Down)); + assert_eq!(app.settings_cursor, 2); + + app.handle_key(make_key(crossterm::event::KeyCode::Char('k'))); + assert_eq!(app.settings_cursor, 1); + + app.handle_key(make_key(crossterm::event::KeyCode::Up)); + assert_eq!(app.settings_cursor, 0); + } + + #[test] + fn test_settings_cycle_color() { + let mut app = App::new(); + enter_settings(&mut app); + assert_eq!(app.settings_draft.error, "red"); + + app.handle_key(make_key(crossterm::event::KeyCode::Right)); + assert_eq!(app.settings_draft.error, "green"); + + app.handle_key(make_key(crossterm::event::KeyCode::Left)); + assert_eq!(app.settings_draft.error, "red"); + } + + #[test] + fn test_settings_number_key() { + let mut app = App::new(); + enter_settings(&mut app); + + app.handle_key(make_key(crossterm::event::KeyCode::Char('1'))); + assert_eq!(app.settings_draft.error, "red"); + + app.handle_key(make_key(crossterm::event::KeyCode::Char('4'))); + assert_eq!(app.settings_draft.error, "blue"); + + app.handle_key(make_key(crossterm::event::KeyCode::Char('8'))); + assert_eq!(app.settings_draft.error, "white"); + } + + #[test] + fn test_settings_cursor_boundary() { + let mut app = App::new(); + enter_settings(&mut app); + + app.handle_key(make_key(crossterm::event::KeyCode::Char('k'))); + assert_eq!(app.settings_cursor, 0, "k at 0 should stay 0"); + + app.settings_cursor = 5; + app.handle_key(make_key(crossterm::event::KeyCode::Char('j'))); + assert_eq!(app.settings_cursor, 5, "j at 5 should stay 5"); + } + + #[test] + fn test_settings_q_cancels() { + let mut app = App::new(); + let original = app.color_config.clone(); + enter_settings(&mut app); + + app.handle_key(make_key(crossterm::event::KeyCode::Right)); + app.handle_key(make_key(crossterm::event::KeyCode::Char('q'))); + + assert_eq!(app.mode, AppMode::Normal); + assert_eq!(app.color_config, original, "q should NOT save changes"); + } + + #[test] + fn test_settings_cycle_unknown_color() { + let mut app = App::new(); + enter_settings(&mut app); + + app.settings_draft.error = "not_in_list".to_string(); + app.handle_key(make_key(crossterm::event::KeyCode::Right)); + assert_eq!( + app.settings_draft.error, "red", + "unknown color Right should go to first (red)" + ); + + app.settings_draft.error = "not_in_list".to_string(); + app.handle_key(make_key(crossterm::event::KeyCode::Left)); + assert_eq!( + app.settings_draft.error, "white", + "unknown color Left should go to last (white)" + ); + } + + #[test] + fn test_settings_draft_synced_on_enter() { + let mut app = App::new(); + app.color_config.error = "magenta".to_string(); + let expected_draft = app.color_config.clone(); + + enter_settings(&mut app); + assert_eq!( + app.settings_draft, expected_draft, + "draft should sync from color_config on S press" + ); + } + + #[test] + fn test_color_change_affects_build_line_spans() { + use crate::ui::build_line_spans; + use ratatui::style::Color; + + let mut config = ColorConfig::default(); + config.error = "blue".to_string(); + let line = build_line_spans( + "1 │".to_string(), + "hello".to_string(), + false, + Some(&LogLevel::Error), + &config, + ); + assert_eq!(line.spans[1].style.fg, Some(Color::Blue)); + } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 00ba847..ee3587a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1,6 +1,7 @@ use clap::Parser; mod app; +mod color; mod ui; #[derive(Parser)] @@ -20,6 +21,7 @@ fn main() -> anyhow::Result<()> { let mut terminal = ratatui::Terminal::new(backend)?; let mut app = app::App::new(); + app.color_config = log_viewer_core::config::ColorConfig::load(); if let Some(file) = cli.files.first() && let Err(e) = app.load_file(file)