feat(tui): expand App state with file loading and scroll
This commit is contained in:
@@ -22,3 +22,4 @@ ratatui = "0.30"
|
|||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
log-viewer-core = { path = "crates/core" }
|
log-viewer-core = { path = "crates/core" }
|
||||||
|
textwrap = "0.16"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub fn parse_line(line: &str) -> Option<LogEntry> {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|key| fields.remove(*key))
|
.find_map(|key| fields.remove(*key))
|
||||||
.and_then(|v| v.as_str().map(String::from))
|
.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 {
|
Some(LogEntry {
|
||||||
line_number: 0,
|
line_number: 0,
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ crossterm.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log-viewer-core.workspace = true
|
log-viewer-core.workspace = true
|
||||||
|
textwrap.workspace = true
|
||||||
|
|||||||
@@ -1,17 +1,546 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use log_viewer_core::io::file_reader::FileReader;
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub should_quit: bool,
|
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 {
|
impl App {
|
||||||
pub fn new() -> Self {
|
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) {
|
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
crossterm::event::KeyCode::Char('q') => self.should_quit = true,
|
KeyCode::Char('q') | KeyCode::Esc => {
|
||||||
crossterm::event::KeyCode::Esc => self.should_quit = true,
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct Cli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let _cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
let mut stdout = std::io::stdout();
|
let mut stdout = std::io::stdout();
|
||||||
@@ -21,12 +21,21 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let mut app = app::App::new();
|
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 {
|
while !app.should_quit {
|
||||||
terminal.draw(|frame| ui::render(frame, &app))?;
|
terminal.draw(|frame| ui::render(frame, &app))?;
|
||||||
if crossterm::event::poll(std::time::Duration::from_millis(100))?
|
if crossterm::event::poll(std::time::Duration::from_millis(100))? {
|
||||||
&& let crossterm::event::Event::Key(key) = crossterm::event::read()?
|
match crossterm::event::read()? {
|
||||||
{
|
crossterm::event::Event::Key(key) => app.handle_key(key),
|
||||||
app.handle_key(key);
|
crossterm::event::Event::Resize(_w, _h) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user