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:
@@ -10,3 +10,6 @@ clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
log-viewer-core.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user