diff --git a/Cargo.toml b/Cargo.toml index 51269b9..41bcf8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ ratatui = "0.30" crossterm = "0.29" clap = { version = "4", features = ["derive"] } log-viewer-core = { path = "crates/core" } +textwrap = "0.16" diff --git a/crates/core/src/parser/json.rs b/crates/core/src/parser/json.rs index 0e7227b..bd78578 100644 --- a/crates/core/src/parser/json.rs +++ b/crates/core/src/parser/json.rs @@ -26,7 +26,7 @@ pub fn parse_line(line: &str) -> Option { .iter() .find_map(|key| fields.remove(*key)) .and_then(|v| v.as_str().map(String::from)) - .map(|s| s.parse::().unwrap()); + .map(|s| s.parse::().unwrap_or_else(|e| match e {})); Some(LogEntry { line_number: 0, diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index dc46fb9..8ce5d1a 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -9,3 +9,4 @@ crossterm.workspace = true clap.workspace = true anyhow.workspace = true log-viewer-core.workspace = true +textwrap.workspace = true diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 1699767..74664c7 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,17 +1,546 @@ +use std::path::Path; +use std::time::Instant; + +use log_viewer_core::io::file_reader::FileReader; + pub struct App { pub should_quit: bool, + + // File state + file_reader: Option, + pub(crate) file_path: Option, + + // Scroll state + pub(crate) cursor_line: usize, + pub(crate) v_offset: usize, + + // Soft wrap cache + pub(crate) wrap_cache: Vec>, + pub(crate) visual_heights: Vec, + pub(crate) total_visual_rows: usize, + pub(crate) cached_width: usize, + + // Viewport + #[allow(dead_code)] + pub(crate) content_width: u16, + pub(crate) content_height: u16, + + // gg state machine + pub(crate) last_g_press: Option, } impl App { pub fn new() -> Self { - Self { should_quit: false } - } - - pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) { - match key.code { - crossterm::event::KeyCode::Char('q') => self.should_quit = true, - crossterm::event::KeyCode::Esc => self.should_quit = true, - _ => {} + Self { + should_quit: false, + file_reader: None, + file_path: None, + cursor_line: 0, + v_offset: 0, + wrap_cache: Vec::new(), + visual_heights: Vec::new(), + total_visual_rows: 0, + cached_width: 0, + content_width: 0, + content_height: 0, + last_g_press: None, } } + + pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> { + let reader = FileReader::open(Path::new(path)).map_err(|e| anyhow::anyhow!("{e}"))?; + self.file_reader = Some(reader); + self.file_path = Some(path.to_string()); + self.cursor_line = 0; + self.v_offset = 0; + self.wrap_cache.clear(); + self.visual_heights.clear(); + self.total_visual_rows = 0; + self.cached_width = 0; // force wrap cache rebuild on next render + self.last_g_press = None; // reset gg state machine + Ok(()) + } + + #[allow(dead_code)] + pub fn recompute_wrap_cache(&mut self, width: usize) { + if !self.is_loaded() || width == 0 { + return; + } + if self.cached_width == width { + return; + } + + let line_count = self.total_lines(); + self.wrap_cache.clear(); + self.visual_heights.clear(); + self.visual_heights.reserve(line_count); + + for i in 0..line_count { + let line = self.get_line(i).unwrap_or(""); + let wrapped: Vec> = textwrap::wrap(line, width); + let wrapped: Vec = wrapped.into_iter().map(|c| c.into_owned()).collect(); + let height = wrapped.len().max(1); + self.wrap_cache.push(wrapped); + self.visual_heights.push(height); + } + + self.total_visual_rows = self.visual_heights.iter().sum(); + self.cached_width = width; + + // Clamp v_offset + let max_offset = self + .total_visual_rows + .saturating_sub(self.content_height as usize); + self.v_offset = self.v_offset.min(max_offset); + } + + // ── Scroll methods ────────────────────────────────────────────── + + pub fn scroll_down_line(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let last = self.total_lines() - 1; + if self.cursor_line < last { + self.cursor_line += 1; + } + self.ensure_cursor_visible(); + } + + pub fn scroll_up_line(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + self.cursor_line = self.cursor_line.saturating_sub(1); + self.ensure_cursor_visible(); + } + + pub fn scroll_down_half_page(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let half = self.content_height as usize / 2; + self.v_offset = self.v_offset.saturating_add(half); + let center_visual = self + .v_offset + .saturating_add(self.content_height as usize / 2); + self.cursor_line = self.visual_row_to_logical_row(center_visual); + self.clamp_v_offset(); + } + + pub fn scroll_up_half_page(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let half = self.content_height as usize / 2; + self.v_offset = self.v_offset.saturating_sub(half); + let center_visual = self + .v_offset + .saturating_add(self.content_height as usize / 2); + self.cursor_line = self.visual_row_to_logical_row(center_visual); + self.clamp_v_offset(); + } + + pub fn scroll_down_page(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let page = self.content_height as usize; + self.v_offset = self.v_offset.saturating_add(page); + let center_visual = self + .v_offset + .saturating_add(self.content_height as usize / 2); + self.cursor_line = self.visual_row_to_logical_row(center_visual); + self.clamp_v_offset(); + } + + pub fn scroll_up_page(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let page = self.content_height as usize; + self.v_offset = self.v_offset.saturating_sub(page); + let center_visual = self + .v_offset + .saturating_add(self.content_height as usize / 2); + self.cursor_line = self.visual_row_to_logical_row(center_visual); + self.clamp_v_offset(); + } + + pub fn scroll_to_top(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + self.cursor_line = 0; + self.v_offset = 0; + } + + pub fn scroll_to_bottom(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + self.cursor_line = self.total_lines().saturating_sub(1); + self.ensure_cursor_visible(); + self.clamp_v_offset(); + } + + // ── Internal helpers ──────────────────────────────────────────── + + fn ensure_cursor_visible(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let cursor_first = self.cursor_to_first_visual_row(self.cursor_line); + let height = self + .visual_heights + .get(self.cursor_line) + .copied() + .unwrap_or(1); + let cursor_last = cursor_first + height.saturating_sub(1); + + let content_h = self.content_height as usize; + + if cursor_first < self.v_offset { + self.v_offset = cursor_first; + } else if cursor_last >= self.v_offset.saturating_add(content_h) { + self.v_offset = cursor_last.saturating_sub(content_h).saturating_add(1); + } + self.clamp_v_offset(); + } + + fn clamp_v_offset(&mut self) { + let max_offset = self + .total_visual_rows + .saturating_sub(self.content_height as usize); + self.v_offset = self.v_offset.min(max_offset); + } + + pub(crate) fn cursor_to_first_visual_row(&self, line: usize) -> usize { + self.visual_heights.iter().take(line).sum() + } + + pub(crate) fn visual_row_to_logical_row(&self, visual_row: usize) -> usize { + let mut acc: usize = 0; + for (i, &h) in self.visual_heights.iter().enumerate() { + if acc.saturating_add(h) > visual_row { + return i; + } + acc += h; + } + // Boundary: visual_row >= total_visual_rows → return last line + self.visual_heights.len().saturating_sub(1) + } + + // ── Key handling ──────────────────────────────────────────────── + + pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + self.should_quit = true; + self.last_g_press = None; + } + KeyCode::Char('j') | KeyCode::Down => { + self.scroll_down_line(); + self.last_g_press = None; + } + KeyCode::Char('k') | KeyCode::Up => { + self.scroll_up_line(); + self.last_g_press = None; + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.scroll_down_half_page(); + self.last_g_press = None; + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.scroll_up_half_page(); + self.last_g_press = None; + } + KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.scroll_down_page(); + self.last_g_press = None; + } + KeyCode::PageDown => { + self.scroll_down_page(); + self.last_g_press = None; + } + KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.scroll_up_page(); + self.last_g_press = None; + } + KeyCode::PageUp => { + self.scroll_up_page(); + self.last_g_press = None; + } + KeyCode::Char('G') | KeyCode::End => { + self.scroll_to_bottom(); + self.last_g_press = None; + } + KeyCode::Char('g') => { + if let Some(instant) = self.last_g_press + && instant.elapsed().as_millis() < 500 + { + self.scroll_to_top(); + self.last_g_press = None; + return; + } + self.last_g_press = Some(Instant::now()); + } + KeyCode::Home => { + self.scroll_to_top(); + self.last_g_press = None; + } + _ => { + self.last_g_press = None; + } + } + } + + // ── Utility methods ───────────────────────────────────────────── + + #[allow(dead_code)] + pub fn get_line(&self, idx: usize) -> Option<&str> { + self.file_reader.as_ref().and_then(|r| r.get_line(idx)) + } + + #[allow(dead_code)] + pub fn file_name(&self) -> Option<&str> { + self.file_path + .as_ref() + .and_then(|p| std::path::Path::new(p).file_name().and_then(|n| n.to_str())) + } + + pub fn total_lines(&self) -> usize { + self.file_reader.as_ref().map_or(0, |r| r.line_count()) + } + + pub fn is_loaded(&self) -> bool { + self.file_reader.is_some() + } + + #[allow(dead_code)] + pub fn file_size(&self) -> u64 { + self.file_reader.as_ref().map_or(0, |r| r.file_size()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn make_temp_file(content: &str) -> std::path::PathBuf { + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir(); + let name = format!("log_viewer_test_{}_{}", std::process::id(), id); + let path = dir.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + fn cleanup(path: &std::path::Path) { + let _ = std::fs::remove_file(path); + } + + #[test] + fn test_app_new_defaults() { + let app = App::new(); + assert!(!app.should_quit); + assert!(app.file_reader.is_none()); + assert!(app.file_path.is_none()); + assert_eq!(app.cursor_line, 0); + assert_eq!(app.v_offset, 0); + assert!(app.wrap_cache.is_empty()); + assert!(app.visual_heights.is_empty()); + assert_eq!(app.total_visual_rows, 0); + assert_eq!(app.cached_width, 0); + assert_eq!(app.content_width, 0); + assert_eq!(app.content_height, 0); + assert!(app.last_g_press.is_none()); + } + + #[test] + fn test_load_file_success() { + let path = make_temp_file("line1\nline2\nline3\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + assert!(app.load_file(path.to_str().unwrap()).is_ok()); + assert!(app.is_loaded()); + assert_eq!(app.total_lines(), 3); + assert_eq!(app.cursor_line, 0); + assert_eq!(app.v_offset, 0); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_load_file_resets_state() { + let path = make_temp_file("a\nb\nc\nd\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.cursor_line = 3; + app.v_offset = 2; + + let path2 = make_temp_file("x\ny\n"); + app.load_file(path2.to_str().unwrap()).unwrap(); + assert_eq!(app.cursor_line, 0); + assert_eq!(app.v_offset, 0); + assert_eq!(app.total_lines(), 2); + cleanup(&path2); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_load_file_nonexistent() { + let mut app = App::new(); + let result = app.load_file("/tmp/no_such_file_log_viewer_test_xyz"); + assert!(result.is_err()); + assert!(!app.is_loaded()); + } + + #[test] + fn test_unloaded_scroll_safety() { + let mut app = App::new(); + // None of these should panic + app.scroll_down_line(); + app.scroll_up_line(); + app.scroll_down_half_page(); + app.scroll_up_half_page(); + app.scroll_down_page(); + app.scroll_up_page(); + app.scroll_to_top(); + app.scroll_to_bottom(); + assert_eq!(app.cursor_line, 0); + assert_eq!(app.v_offset, 0); + } + + #[test] + fn test_empty_file_scroll_safety() { + let path = make_temp_file(""); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + // FileReader on empty file should have 0 lines (or 1 empty line depending on impl) + app.load_file(path.to_str().unwrap()).unwrap(); + + app.scroll_down_line(); + app.scroll_up_line(); + app.scroll_down_half_page(); + app.scroll_up_half_page(); + app.scroll_down_page(); + app.scroll_up_page(); + app.scroll_to_top(); + app.scroll_to_bottom(); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_scroll_down_at_bottom() { + let path = make_temp_file("a\nb\nc\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.cursor_line = 2; // last line + + app.scroll_down_line(); + assert_eq!(app.cursor_line, 2); // stays at last + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_scroll_up_at_top() { + let path = make_temp_file("a\nb\nc\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + assert_eq!(app.cursor_line, 0); + + app.scroll_up_line(); + assert_eq!(app.cursor_line, 0); // stays at 0 + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_wrap_cache_correctness() { + let long_line = "a".repeat(200); + let path = make_temp_file(&format!("{long_line}\nshort\n")); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.content_height = 50; // enough viewport + + app.recompute_wrap_cache(20); + // Long line should wrap into multiple visual rows + assert!( + app.visual_heights[0] > 1, + "expected wrapping but got height {}", + app.visual_heights[0] + ); + // Short line is 1 row + assert_eq!(app.visual_heights[1], 1); + assert!(app.total_visual_rows > 2); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_recompute_wrap_cache_not_loaded() { + let mut app = App::new(); + app.recompute_wrap_cache(80); + // Should not panic and cache should stay empty + assert!(app.wrap_cache.is_empty()); + } + + #[test] + fn test_recompute_wrap_cache_zero_width() { + let path = make_temp_file("hello\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + app.recompute_wrap_cache(0); + // Should not panic, cache should stay empty (width 0 is guarded) + assert!(app.wrap_cache.is_empty()); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_load_file_resets_cached_width() { + let path = make_temp_file("hello\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.cached_width = 80; + app.load_file(path.to_str().unwrap()).unwrap(); + assert_eq!(app.cached_width, 0); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_load_file_resets_gg_state() { + let path = make_temp_file("hello\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.last_g_press = Some(Instant::now()); + app.load_file(path.to_str().unwrap()).unwrap(); + assert!(app.last_g_press.is_none()); + }); + cleanup(&path); + assert!(result.is_ok()); + } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index a021895..67c09c4 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -11,7 +11,7 @@ struct Cli { } fn main() -> anyhow::Result<()> { - let _cli = Cli::parse(); + let cli = Cli::parse(); crossterm::terminal::enable_raw_mode()?; let mut stdout = std::io::stdout(); @@ -21,12 +21,21 @@ fn main() -> anyhow::Result<()> { let mut app = app::App::new(); + if let Some(file) = cli.files.first() + && let Err(e) = app.load_file(file) + { + eprintln!("Error loading file: {e}"); + std::process::exit(1); + } + while !app.should_quit { terminal.draw(|frame| ui::render(frame, &app))?; - if crossterm::event::poll(std::time::Duration::from_millis(100))? - && let crossterm::event::Event::Key(key) = crossterm::event::read()? - { - app.handle_key(key); + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + match crossterm::event::read()? { + crossterm::event::Event::Key(key) => app.handle_key(key), + crossterm::event::Event::Resize(_w, _h) => {} + _ => {} + } } }