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