feat(tui): expand App state with file loading and scroll

This commit is contained in:
dailz
2026-04-10 23:28:23 +08:00
parent 37bebc1a26
commit 555ffc0836
5 changed files with 554 additions and 14 deletions

View File

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

View File

@@ -26,7 +26,7 @@ pub fn parse_line(line: &str) -> Option<LogEntry> {
.iter()
.find_map(|key| fields.remove(*key))
.and_then(|v| v.as_str().map(String::from))
.map(|s| s.parse::<LogLevel>().unwrap());
.map(|s| s.parse::<LogLevel>().unwrap_or_else(|e| match e {}));
Some(LogEntry {
line_number: 0,

View File

@@ -9,3 +9,4 @@ crossterm.workspace = true
clap.workspace = true
anyhow.workspace = true
log-viewer-core.workspace = true
textwrap.workspace = true

View File

@@ -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<FileReader>,
pub(crate) file_path: Option<String>,
// Scroll state
pub(crate) cursor_line: usize,
pub(crate) v_offset: usize,
// Soft wrap cache
pub(crate) wrap_cache: Vec<Vec<String>>,
pub(crate) visual_heights: Vec<usize>,
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<Instant>,
}
impl App {
pub fn new() -> Self {
Self { should_quit: false }
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<std::borrow::Cow<'_, str>> = textwrap::wrap(line, width);
let wrapped: Vec<String> = 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 {
crossterm::event::KeyCode::Char('q') => self.should_quit = true,
crossterm::event::KeyCode::Esc => self.should_quit = true,
_ => {}
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());
}
}

View File

@@ -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) => {}
_ => {}
}
}
}