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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-04-12 10:52:03 +08:00
parent ef4c2e7383
commit c6e83cb8c2
4 changed files with 451 additions and 11 deletions

View File

@@ -23,3 +23,4 @@ crossterm = "0.29"
clap = { version = "4", features = ["derive"] }
log-viewer-core = { path = "crates/core" }
textwrap = "0.16"
tempfile = "3"

View File

@@ -10,3 +10,6 @@ clap.workspace = true
anyhow.workspace = true
log-viewer-core.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -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<Option<LogLevel>>,
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));
}
}

View File

@@ -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)