During Loading state (before VHI is built), j/k used to jump by logical line, visually skipping multiple wrapped rows. Now uses v_sub_offset to track position within a wrapped line, enabling smooth 1-visual-row scroll. - Add v_sub_offset field to App for sub-line visual position tracking - scroll_down/up_line else branch: advance v_sub_offset, wrap to next line - ensure_viewport_cache Loading path: pass v_sub_offset as offset_in_line - ensure_cursor_visible: skip during Loading (scroll functions manage it) - Reset v_sub_offset on Loading→Ready, scroll_to_top, scroll_to_bottom - Add 3 tests for Loading-state sub-offset scrolling behavior
2655 lines
90 KiB
Rust
2655 lines
90 KiB
Rust
use std::path::Path;
|
||
use std::time::Instant;
|
||
|
||
use log_viewer_core::config::ColorConfig;
|
||
use log_viewer_core::io::progressive_reader::{
|
||
IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height,
|
||
spawn_indexer,
|
||
};
|
||
use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars};
|
||
use log_viewer_core::types::LogLevel;
|
||
use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher};
|
||
|
||
use crate::color::AVAILABLE_COLORS;
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) enum AppMode {
|
||
Normal,
|
||
Settings,
|
||
}
|
||
|
||
pub(crate) enum AppLoadingState {
|
||
Empty,
|
||
Loading {
|
||
reader: ProgressiveFileReader,
|
||
estimated_lines: u64,
|
||
progress_percent: f64,
|
||
},
|
||
Ready {
|
||
reader: ProgressiveFileReader,
|
||
},
|
||
Error(String),
|
||
}
|
||
|
||
// ── Viewport cache (on-demand, viewport-sized) ───────────────────
|
||
|
||
pub(crate) struct ViewportEntry {
|
||
pub(crate) wrapped_rows: Vec<String>,
|
||
pub(crate) level: Option<LogLevel>,
|
||
pub(crate) visual_height: usize,
|
||
}
|
||
|
||
pub(crate) struct ViewportCache {
|
||
pub(crate) entries: Vec<ViewportEntry>,
|
||
pub(crate) logical_start: usize,
|
||
pub(crate) width: usize,
|
||
json_format: bool,
|
||
cached_total_visual_rows: Option<usize>,
|
||
}
|
||
|
||
impl ViewportCache {
|
||
pub(crate) fn new() -> Self {
|
||
Self {
|
||
entries: Vec::new(),
|
||
logical_start: 0,
|
||
width: 0,
|
||
json_format: false,
|
||
cached_total_visual_rows: None,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn invalidate(&mut self) {
|
||
self.entries.clear();
|
||
self.logical_start = 0;
|
||
self.width = 0;
|
||
self.cached_total_visual_rows = None;
|
||
}
|
||
|
||
pub(crate) fn needs_recompute(&self, width: usize, json_format: bool) -> bool {
|
||
self.width != width || self.json_format != json_format
|
||
}
|
||
|
||
pub(crate) fn get_entry(&self, logical_line: usize) -> Option<&ViewportEntry> {
|
||
if logical_line >= self.logical_start {
|
||
let idx = logical_line - self.logical_start;
|
||
self.entries.get(idx)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── App ──────────────────────────────────────────────────────────
|
||
|
||
pub struct App {
|
||
pub should_quit: bool,
|
||
|
||
// File state
|
||
loading_state: AppLoadingState,
|
||
pub(crate) file_path: Option<String>,
|
||
|
||
// Scroll state
|
||
pub(crate) cursor_line: usize,
|
||
pub(crate) v_offset: usize,
|
||
pub(crate) v_sub_offset: usize,
|
||
|
||
// Viewport cache (on-demand, viewport-sized)
|
||
pub(crate) viewport_cache: ViewportCache,
|
||
|
||
// Viewport
|
||
#[allow(dead_code)]
|
||
pub(crate) content_width: u16,
|
||
pub(crate) content_height: u16,
|
||
|
||
// gg state machine
|
||
pub(crate) last_g_press: Option<Instant>,
|
||
|
||
// JSON formatting
|
||
pub(crate) json_format: bool,
|
||
|
||
// Mode & coloring
|
||
pub(crate) mode: AppMode,
|
||
pub(crate) color_config: ColorConfig,
|
||
|
||
// Settings panel state
|
||
pub(crate) settings_cursor: usize,
|
||
pub(crate) settings_draft: ColorConfig,
|
||
|
||
// File watcher
|
||
file_watcher: Option<FileWatcher>,
|
||
}
|
||
|
||
impl App {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
should_quit: false,
|
||
loading_state: AppLoadingState::Empty,
|
||
file_path: None,
|
||
cursor_line: 0,
|
||
v_offset: 0,
|
||
v_sub_offset: 0,
|
||
viewport_cache: ViewportCache::new(),
|
||
content_width: 0,
|
||
content_height: 0,
|
||
last_g_press: None,
|
||
json_format: false,
|
||
mode: AppMode::Normal,
|
||
color_config: ColorConfig::default(),
|
||
settings_cursor: 0,
|
||
settings_draft: ColorConfig::default(),
|
||
file_watcher: None,
|
||
}
|
||
}
|
||
|
||
pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> {
|
||
// Cancel any existing background indexer by dropping the old state
|
||
self.file_watcher = None;
|
||
|
||
let mut pfr = ProgressiveFileReader::open(Path::new(path))
|
||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||
|
||
if pfr.is_sampling() {
|
||
// Cache miss: spawn background indexer
|
||
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
|
||
let generation = pfr.generation();
|
||
let indexer_rx = spawn_indexer(
|
||
pfr.path().to_path_buf(),
|
||
generation,
|
||
80,
|
||
false,
|
||
cancel_rx,
|
||
);
|
||
pfr = ProgressiveFileReader::with_channels(
|
||
Path::new(path),
|
||
cancel_tx,
|
||
indexer_rx,
|
||
generation,
|
||
).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||
|
||
let estimated = pfr.line_count() as u64;
|
||
self.loading_state = AppLoadingState::Loading {
|
||
reader: pfr,
|
||
estimated_lines: estimated,
|
||
progress_percent: 0.0,
|
||
};
|
||
} else {
|
||
// Cache hit: Ready state
|
||
self.loading_state = AppLoadingState::Ready { reader: pfr };
|
||
}
|
||
|
||
self.file_watcher = FileWatcher::watch(Path::new(path)).ok();
|
||
|
||
self.file_path = Some(path.to_string());
|
||
self.cursor_line = 0;
|
||
self.v_offset = 0;
|
||
self.v_sub_offset = 0;
|
||
self.viewport_cache.invalidate();
|
||
self.last_g_press = None; // reset gg state machine
|
||
self.json_format = false;
|
||
self.mode = AppMode::Normal;
|
||
Ok(())
|
||
}
|
||
|
||
// ── On-demand viewport computation ───────────────────────────
|
||
|
||
/// Compute a single line's viewport entry (wrapped rows + level + height).
|
||
fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry {
|
||
let raw = self.get_line(line).unwrap_or_default();
|
||
let level = log_viewer_core::parser::level::detect_level(&raw);
|
||
let display_text = if self.json_format {
|
||
format_json_line(&raw)
|
||
} else {
|
||
raw
|
||
};
|
||
let mut wrapped = Vec::new();
|
||
for sub_line in display_text.split('\n') {
|
||
wrapped.extend(wrap_line_chars(sub_line, width));
|
||
}
|
||
let visual_height = wrapped.len().max(1);
|
||
ViewportEntry {
|
||
wrapped_rows: wrapped,
|
||
level,
|
||
visual_height,
|
||
}
|
||
}
|
||
|
||
/// Compute visual height for a single line without storing it.
|
||
fn compute_visual_height(&self, line: usize, width: usize) -> usize {
|
||
let raw = self.get_line(line).unwrap_or_default();
|
||
let display_text = if self.json_format {
|
||
format_json_line(&raw)
|
||
} else {
|
||
raw
|
||
};
|
||
let mut height = 0;
|
||
for sub_line in display_text.split('\n') {
|
||
height += wrap_line_chars(sub_line, width).len();
|
||
}
|
||
height.max(1)
|
||
}
|
||
|
||
/// Find (logical_line, offset_in_line) for a given visual row offset.
|
||
fn find_logical_line_at_visual_row(&self, visual_row: usize, _width: usize) -> (usize, usize) {
|
||
if let Some(index) = self.get_visual_height_index() {
|
||
return index.visual_row_to_logical_row_with_offset(visual_row as u64);
|
||
}
|
||
(visual_row.min(self.total_lines().saturating_sub(1)), 0)
|
||
}
|
||
|
||
/// Ensure the viewport cache covers the visible range.
|
||
/// Returns (start_logical, offset_in_line) for rendering.
|
||
pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) {
|
||
let viewport_height = self.content_height as usize;
|
||
let v_offset = self.v_offset;
|
||
|
||
if !self.is_loaded() || width == 0 || viewport_height == 0 {
|
||
return (0, 0);
|
||
}
|
||
|
||
let params_changed = self.viewport_cache.needs_recompute(width, self.json_format);
|
||
|
||
if params_changed {
|
||
self.viewport_cache.invalidate();
|
||
self.viewport_cache.width = width;
|
||
self.viewport_cache.json_format = self.json_format;
|
||
self.ensure_visual_height_index(width);
|
||
|
||
let cursor_first = self.cursor_to_first_visual_row(self.cursor_line);
|
||
let half_height = (self.content_height as usize) / 2;
|
||
self.v_offset = cursor_first.saturating_sub(half_height);
|
||
self.clamp_v_offset();
|
||
}
|
||
|
||
// Find start logical line from v_offset
|
||
let (start_logical, offset_in_line) = if self.is_loading() {
|
||
(v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset)
|
||
} else {
|
||
self.find_logical_line_at_visual_row(self.v_offset, width)
|
||
};
|
||
|
||
// Compute viewport entries
|
||
self.viewport_cache.entries.clear();
|
||
self.viewport_cache.logical_start = start_logical;
|
||
|
||
let total = self.total_lines();
|
||
let mut rows_remaining = viewport_height + offset_in_line;
|
||
|
||
for line_idx in start_logical..total {
|
||
if rows_remaining == 0 {
|
||
break;
|
||
}
|
||
let entry = self.compute_line_entry(line_idx, width);
|
||
rows_remaining = rows_remaining.saturating_sub(entry.visual_height);
|
||
self.viewport_cache.entries.push(entry);
|
||
}
|
||
|
||
// Post-check: ensure cursor_line is within rendered entries (Loading + JSON expansion)
|
||
if self.is_loading() {
|
||
let entries = &self.viewport_cache.entries;
|
||
let last_entry_end = self.viewport_cache.logical_start
|
||
+ entries.len()
|
||
+ entries.last().map(|e| e.visual_height).unwrap_or(0).saturating_sub(1);
|
||
let first_entry_start = self.viewport_cache.logical_start;
|
||
if self.cursor_line >= last_entry_end || self.cursor_line < first_entry_start {
|
||
self.v_offset = self.cursor_line;
|
||
self.viewport_cache.entries.clear();
|
||
self.viewport_cache.logical_start = self.cursor_line;
|
||
self.fill_viewport_entries(self.cursor_line, width, viewport_height);
|
||
}
|
||
}
|
||
|
||
(start_logical, offset_in_line)
|
||
}
|
||
|
||
fn fill_viewport_entries(&mut self, start_logical: usize, width: usize, viewport_height: usize) {
|
||
let total = self.total_lines();
|
||
let mut rows_remaining = viewport_height;
|
||
for line_idx in start_logical..total {
|
||
if rows_remaining == 0 {
|
||
break;
|
||
}
|
||
let entry = self.compute_line_entry(line_idx, width);
|
||
rows_remaining = rows_remaining.saturating_sub(entry.visual_height);
|
||
self.viewport_cache.entries.push(entry);
|
||
}
|
||
}
|
||
|
||
/// Compute total visual rows (cached, lazily evaluated).
|
||
/// Returns `total_lines` for sampling mode (1:1 mapping).
|
||
fn total_visual_rows(&mut self) -> usize {
|
||
if self.is_loading() {
|
||
return self.total_lines();
|
||
}
|
||
if let Some(index) = self.get_visual_height_index() {
|
||
return index.total_visual_rows() as usize;
|
||
}
|
||
self.total_lines()
|
||
}
|
||
|
||
// ── Scroll methods ──────────────────────────────────────────────
|
||
|
||
pub fn scroll_down_line(&mut self) {
|
||
if !self.is_loaded() || self.total_lines() == 0 {
|
||
return;
|
||
}
|
||
|
||
// VHI present → visual-row scroll
|
||
if self.get_visual_height_index().is_some() {
|
||
let max_offset = self
|
||
.total_visual_rows()
|
||
.saturating_sub(self.content_height as usize);
|
||
|
||
if self.v_offset < max_offset {
|
||
self.v_offset = self.v_offset.saturating_add(1);
|
||
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();
|
||
} else {
|
||
let last = self.total_lines() - 1;
|
||
if self.cursor_line < last {
|
||
self.cursor_line += 1;
|
||
}
|
||
}
|
||
} else {
|
||
// Loading/no-index: visual-row scroll via v_sub_offset
|
||
let width = self.get_content_width();
|
||
if width > 0 && self.total_lines() > 0 {
|
||
let current_height = self.compute_visual_height(self.v_offset, width);
|
||
if self.v_sub_offset + 1 < current_height {
|
||
self.v_sub_offset += 1;
|
||
} else {
|
||
let last = self.total_lines() - 1;
|
||
if self.v_offset < last {
|
||
self.v_offset += 1;
|
||
self.v_sub_offset = 0;
|
||
}
|
||
}
|
||
self.cursor_line = self.v_offset;
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn scroll_up_line(&mut self) {
|
||
if !self.is_loaded() || self.total_lines() == 0 {
|
||
return;
|
||
}
|
||
|
||
// VHI present → visual-row scroll
|
||
if self.get_visual_height_index().is_some() {
|
||
if self.v_offset > 0 {
|
||
self.v_offset = self.v_offset.saturating_sub(1);
|
||
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();
|
||
} else {
|
||
self.cursor_line = self.cursor_line.saturating_sub(1);
|
||
}
|
||
} else {
|
||
// Loading/no-index: visual-row scroll via v_sub_offset
|
||
if self.v_sub_offset > 0 {
|
||
self.v_sub_offset -= 1;
|
||
} else if self.v_offset > 0 {
|
||
self.v_offset -= 1;
|
||
let width = self.get_content_width();
|
||
self.v_sub_offset = if width > 0 {
|
||
self.compute_visual_height(self.v_offset, width).saturating_sub(1)
|
||
} else {
|
||
0
|
||
};
|
||
}
|
||
self.cursor_line = self.v_offset;
|
||
}
|
||
}
|
||
|
||
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;
|
||
self.v_sub_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.v_sub_offset = 0;
|
||
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;
|
||
}
|
||
if self.is_loading() {
|
||
return;
|
||
}
|
||
let cursor_first = self.cursor_to_first_visual_row(self.cursor_line);
|
||
let height = if let Some(index) = self.get_visual_height_index() {
|
||
index.visual_height_of_line(self.cursor_line)
|
||
} else {
|
||
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 {
|
||
if self.is_loading() {
|
||
return line;
|
||
}
|
||
if let Some(index) = self.get_visual_height_index() {
|
||
return index.cursor_to_first_visual_row(line) as usize;
|
||
}
|
||
line
|
||
}
|
||
|
||
pub(crate) fn visual_row_to_logical_row(&self, visual_row: usize) -> usize {
|
||
if self.is_loading() {
|
||
return visual_row.min(self.total_lines().saturating_sub(1));
|
||
}
|
||
if let Some(index) = self.get_visual_height_index() {
|
||
return index.visual_row_to_logical_row(visual_row as u64);
|
||
}
|
||
visual_row.min(self.total_lines().saturating_sub(1))
|
||
}
|
||
|
||
// ── 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 {
|
||
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;
|
||
}
|
||
KeyCode::Tab => {
|
||
self.json_format = !self.json_format;
|
||
self.viewport_cache.invalidate();
|
||
let width = self.viewport_cache.width;
|
||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||
reader.invalidate_visual_height_index();
|
||
if width > 0 {
|
||
reader.start_visual_height_rebuild(width, self.json_format);
|
||
}
|
||
}
|
||
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)]
|
||
pub fn get_line(&self, idx: usize) -> Option<String> {
|
||
match &self.loading_state {
|
||
AppLoadingState::Ready { reader } => reader.get_line(idx),
|
||
AppLoadingState::Loading { reader, .. } => reader.get_line(idx),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[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 {
|
||
match &self.loading_state {
|
||
AppLoadingState::Ready { reader } => reader.line_count(),
|
||
AppLoadingState::Loading {
|
||
reader,
|
||
estimated_lines,
|
||
..
|
||
} => {
|
||
// Use estimated total lines (not sampled_line_count) so the user can
|
||
// scroll freely during indexing. get_line() incrementally scans
|
||
// forward on demand, so lines beyond the initial 64KB are still
|
||
// accessible. The .max() guards against under-estimates.
|
||
(*estimated_lines as usize).max(reader.sampled_line_count())
|
||
}
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
pub fn is_loaded(&self) -> bool {
|
||
matches!(
|
||
self.loading_state,
|
||
AppLoadingState::Ready { .. } | AppLoadingState::Loading { .. }
|
||
)
|
||
}
|
||
|
||
pub fn is_loading(&self) -> bool {
|
||
matches!(self.loading_state, AppLoadingState::Loading { .. })
|
||
}
|
||
|
||
pub fn is_error(&self) -> bool {
|
||
matches!(self.loading_state, AppLoadingState::Error(_))
|
||
}
|
||
|
||
fn get_visual_height_index(&self) -> Option<&VisualHeightIndex> {
|
||
match &self.loading_state {
|
||
AppLoadingState::Ready { reader } => match &reader.state {
|
||
log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
||
visual_height_index,
|
||
..
|
||
} => visual_height_index.as_ref(),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn ensure_visual_height_index(&mut self, width: usize) {
|
||
let needs_rebuild = match self.get_visual_height_index() {
|
||
Some(idx) => !idx.is_valid_for(self.json_format, width),
|
||
None => true,
|
||
};
|
||
|
||
if needs_rebuild {
|
||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||
reader.invalidate_visual_height_index();
|
||
reader.start_visual_height_rebuild(width, self.json_format);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn error_message(&self) -> Option<&str> {
|
||
match &self.loading_state {
|
||
AppLoadingState::Error(msg) => Some(msg),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn loading_progress(&self) -> Option<f64> {
|
||
match &self.loading_state {
|
||
AppLoadingState::Loading {
|
||
progress_percent, ..
|
||
} => Some(*progress_percent),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn estimated_lines(&self) -> Option<u64> {
|
||
match &self.loading_state {
|
||
AppLoadingState::Loading {
|
||
estimated_lines, ..
|
||
} => Some(*estimated_lines),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub fn set_error_state(&mut self, msg: impl Into<String>) {
|
||
self.loading_state = AppLoadingState::Error(msg.into());
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
pub fn file_size(&self) -> u64 {
|
||
match &self.loading_state {
|
||
AppLoadingState::Ready { reader } => {
|
||
reader.reader().map_or(0, |r| r.file_size())
|
||
}
|
||
AppLoadingState::Loading { reader, .. } => {
|
||
reader.reader().map_or(0, |r| r.file_size())
|
||
}
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
fn get_content_width(&self) -> usize {
|
||
if self.content_width > 0 {
|
||
self.content_width as usize
|
||
} else {
|
||
80
|
||
}
|
||
}
|
||
|
||
pub fn poll_file_watcher(&mut self) {
|
||
let events: Vec<FileEvent> = match &mut self.file_watcher {
|
||
Some(w) => std::iter::from_fn(|| w.try_recv()).collect(),
|
||
None => return,
|
||
};
|
||
|
||
for event in events {
|
||
match event {
|
||
FileEvent::Appended { new_size: _ } => {
|
||
self.handle_file_appended();
|
||
}
|
||
FileEvent::Truncated { new_size: _ } => {
|
||
self.handle_file_truncated();
|
||
}
|
||
FileEvent::Rotated { new_inode: _ } => {
|
||
// Don't auto-switch; old content preserved.
|
||
// User can reload manually if desired.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn handle_file_appended(&mut self) {
|
||
let width = self.get_content_width();
|
||
match &mut self.loading_state {
|
||
AppLoadingState::Ready { reader } => {
|
||
if let Ok(_new_lines) = reader.update_for_append() {
|
||
let _ = reader.save_cache();
|
||
|
||
let (old_line_count, can_extend) = {
|
||
match &reader.state {
|
||
log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
||
visual_height_index: Some(idx),
|
||
..
|
||
} => (idx.line_count(), idx.is_valid_for(self.json_format, width)),
|
||
_ => (0, false),
|
||
}
|
||
};
|
||
let new_line_count = reader.line_count();
|
||
|
||
if can_extend && new_line_count > old_line_count {
|
||
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
||
visual_height_index: Some(index),
|
||
reader: fr,
|
||
} = &mut reader.state
|
||
{
|
||
let mut new_heights = Vec::with_capacity(new_line_count - old_line_count);
|
||
for i in old_line_count..new_line_count {
|
||
let line_text = fr.get_line(i).unwrap_or("");
|
||
new_heights.push(compute_line_visual_height(
|
||
line_text,
|
||
width,
|
||
self.json_format,
|
||
));
|
||
}
|
||
index.extend_from_heights(&new_heights);
|
||
}
|
||
} else {
|
||
reader.invalidate_visual_height_index();
|
||
reader.start_visual_height_rebuild(width, self.json_format);
|
||
}
|
||
|
||
self.viewport_cache.invalidate();
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn handle_file_truncated(&mut self) {
|
||
let width = self.get_content_width();
|
||
match &mut self.loading_state {
|
||
AppLoadingState::Ready { reader } => {
|
||
let _ = reader.reload();
|
||
let _ = reader.save_cache();
|
||
reader.invalidate_visual_height_index();
|
||
reader.start_visual_height_rebuild(width, self.json_format);
|
||
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
|
||
self.viewport_cache.invalidate();
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Poll the background indexer for progress/completion.
|
||
/// Transitions Loading → Ready when indexing completes.
|
||
/// Must be called every frame in the event loop.
|
||
pub fn poll_background_indexer(&mut self) {
|
||
// Poll visual height rebuild (Ready state only)
|
||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||
if let Some(index) = reader.poll_visual_height_rebuild() {
|
||
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
||
visual_height_index,
|
||
..
|
||
} = &mut reader.state
|
||
{
|
||
*visual_height_index = Some(index);
|
||
self.viewport_cache.invalidate();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Poll main indexer (Loading state)
|
||
let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty);
|
||
|
||
if let AppLoadingState::Loading {
|
||
mut reader,
|
||
estimated_lines,
|
||
mut progress_percent,
|
||
} = old_state
|
||
{
|
||
if let Some(msg) = reader.poll_indexer() {
|
||
match msg {
|
||
IndexerMessage::Progress { percent, .. } => {
|
||
progress_percent = percent;
|
||
self.loading_state = AppLoadingState::Loading {
|
||
reader,
|
||
estimated_lines,
|
||
progress_percent,
|
||
};
|
||
}
|
||
IndexerMessage::Complete {
|
||
reader: fr,
|
||
visual_height_index,
|
||
..
|
||
} => {
|
||
let saved_cursor = self.cursor_line;
|
||
|
||
reader.set_ready(fr, visual_height_index);
|
||
self.loading_state = AppLoadingState::Ready { reader };
|
||
self.viewport_cache.invalidate();
|
||
|
||
// Clamp cursor if exact count < estimated
|
||
self.cursor_line = saved_cursor.min(self.total_lines().saturating_sub(1));
|
||
|
||
// Loading uses 1:1 logical-line offsets; Ready uses visual-row
|
||
// offsets derived from the prefix-sum index. Recompute v_offset
|
||
// so the same logical line stays visible (falls back to 1:1 when
|
||
// the index is absent, which is the case right after invalidate).
|
||
self.v_offset = self.cursor_to_first_visual_row(self.cursor_line);
|
||
self.clamp_v_offset();
|
||
self.v_sub_offset = 0;
|
||
|
||
// Gutter width changes (~N → N) shift content_width, so any
|
||
// VisualHeightIndex built with the old width is stale.
|
||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||
reader.invalidate_visual_height_index();
|
||
}
|
||
}
|
||
IndexerMessage::Error { message, .. } => {
|
||
self.loading_state = AppLoadingState::Error(message);
|
||
}
|
||
}
|
||
} else {
|
||
self.loading_state = AppLoadingState::Loading {
|
||
reader,
|
||
estimated_lines,
|
||
progress_percent,
|
||
};
|
||
}
|
||
} else {
|
||
self.loading_state = old_state;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
fn load_file_ready(app: &mut App, path: &std::path::Path) {
|
||
let data = std::fs::read(path).unwrap();
|
||
let index = log_viewer_core::io::line_index::LineIndex::from_bytes(&data);
|
||
let _ = log_viewer_core::io::index_cache::IndexCache::save(path, &index);
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_app_new_defaults() {
|
||
let app = App::new();
|
||
assert!(!app.should_quit);
|
||
assert!(!app.is_loaded());
|
||
assert!(app.file_path.is_none());
|
||
assert_eq!(app.cursor_line, 0);
|
||
assert_eq!(app.v_offset, 0);
|
||
assert!(app.viewport_cache.entries.is_empty());
|
||
assert_eq!(app.viewport_cache.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();
|
||
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.v_offset = 2;
|
||
|
||
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_viewport_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();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 50; // enough viewport
|
||
|
||
app.ensure_viewport_cache(20);
|
||
// Long line should wrap into multiple visual rows
|
||
let entry0 = app.viewport_cache.get_entry(0).unwrap();
|
||
assert!(
|
||
entry0.visual_height > 1,
|
||
"expected wrapping but got height {}",
|
||
entry0.visual_height
|
||
);
|
||
// Short line is 1 row
|
||
let entry1 = app.viewport_cache.get_entry(1).unwrap();
|
||
assert_eq!(entry1.visual_height, 1);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_ensure_viewport_cache_not_loaded() {
|
||
let mut app = App::new();
|
||
app.ensure_viewport_cache(80);
|
||
// Should not panic and cache should stay empty
|
||
assert!(app.viewport_cache.entries.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_ensure_viewport_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.ensure_viewport_cache(0);
|
||
// Should not panic, cache should stay empty (width 0 is guarded)
|
||
assert!(app.viewport_cache.entries.is_empty());
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_file_resets_viewport_cache() {
|
||
let path = make_temp_file("hello\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.viewport_cache.width = 80;
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert_eq!(app.viewport_cache.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());
|
||
}
|
||
|
||
#[test]
|
||
fn test_json_format_default_off() {
|
||
let app = App::new();
|
||
assert!(!app.json_format);
|
||
}
|
||
|
||
#[test]
|
||
fn test_tab_toggles_json_format() {
|
||
let path = make_temp_file("test\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
|
||
app.handle_key(tab);
|
||
assert!(app.json_format, "Tab should toggle json_format to true");
|
||
assert_eq!(app.viewport_cache.width, 0, "Tab should invalidate viewport cache");
|
||
|
||
app.handle_key(tab);
|
||
assert!(!app.json_format, "Second Tab should toggle back to false");
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_file_resets_json_format() {
|
||
let path = make_temp_file("test\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
app.json_format = true;
|
||
|
||
let path2 = make_temp_file("test2\n");
|
||
app.load_file(path2.to_str().unwrap()).unwrap();
|
||
assert!(
|
||
!app.json_format,
|
||
"load_file should reset json_format to false"
|
||
);
|
||
cleanup(&path2);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_json_line_valid() {
|
||
let result = format_json_line(r#"{"a":1,"b":2}"#);
|
||
assert!(
|
||
result.contains('\n'),
|
||
"formatted JSON should contain newlines"
|
||
);
|
||
assert!(
|
||
result.contains(" "),
|
||
"formatted JSON should contain 2-space indentation"
|
||
);
|
||
assert!(
|
||
result.contains("\"a\""),
|
||
"formatted JSON should contain key a"
|
||
);
|
||
assert!(
|
||
result.contains("\"b\""),
|
||
"formatted JSON should contain key b"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_json_line_invalid() {
|
||
let input = "hello world";
|
||
let result = format_json_line(input);
|
||
assert_eq!(result, input, "plain text should be returned unchanged");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_json_line_empty() {
|
||
let result = format_json_line("");
|
||
assert_eq!(result, "", "empty input should return empty string");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_json_line_array() {
|
||
let input = "[1,2,3]";
|
||
let result = format_json_line(input);
|
||
assert_eq!(result, input, "JSON array should be returned unchanged");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_json_line_primitives() {
|
||
assert_eq!(
|
||
format_json_line(r#""hello""#),
|
||
r#""hello""#,
|
||
"JSON string should be unchanged"
|
||
);
|
||
assert_eq!(
|
||
format_json_line("42"),
|
||
"42",
|
||
"JSON number should be unchanged"
|
||
);
|
||
assert_eq!(
|
||
format_json_line("true"),
|
||
"true",
|
||
"JSON boolean should be unchanged"
|
||
);
|
||
assert_eq!(
|
||
format_json_line("null"),
|
||
"null",
|
||
"JSON null should be unchanged"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_json_line_whitespace_prefix() {
|
||
let input = r#" {"a":1}"#;
|
||
let result = format_json_line(input);
|
||
assert!(
|
||
result.contains('\n'),
|
||
"JSON with leading whitespace should be formatted"
|
||
);
|
||
assert!(
|
||
result.contains("\"a\""),
|
||
"formatted result should contain key a"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_viewport_cache_with_json_format() {
|
||
let json_content = r#"{"key1":"value1","key2":"value2"}
|
||
plain text line
|
||
{"nested":{"inner":true}}
|
||
"#;
|
||
let path = make_temp_file(json_content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 50;
|
||
|
||
// First without formatting
|
||
app.ensure_viewport_cache(80);
|
||
let raw_heights: Vec<usize> = app
|
||
.viewport_cache
|
||
.entries
|
||
.iter()
|
||
.map(|e| e.visual_height)
|
||
.collect();
|
||
|
||
// Now with formatting
|
||
app.json_format = true;
|
||
app.viewport_cache.invalidate();
|
||
app.ensure_viewport_cache(80);
|
||
|
||
// JSON lines (0, 2) should have more visual rows when formatted
|
||
let entry0 = app.viewport_cache.get_entry(0).unwrap();
|
||
let entry1 = app.viewport_cache.get_entry(1).unwrap();
|
||
let entry2 = app.viewport_cache.get_entry(2).unwrap();
|
||
|
||
assert!(
|
||
entry0.visual_height > raw_heights[0],
|
||
"JSON line 0 should have more visual rows when formatted"
|
||
);
|
||
assert_eq!(
|
||
entry1.visual_height, raw_heights[1],
|
||
"Plain text line should have same visual rows"
|
||
);
|
||
assert!(
|
||
entry2.visual_height > raw_heights[2],
|
||
"JSON line 2 should have more visual rows when formatted"
|
||
);
|
||
|
||
// Check that wrapped rows contain indentation
|
||
let json_line_wrapped: String = entry0.wrapped_rows.join("");
|
||
assert!(
|
||
json_line_wrapped.contains(" "),
|
||
"Formatted JSON wrap cache should contain indentation"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_viewport_cache_toggle_restore() {
|
||
let json_content = r#"{"a":1,"b":2}
|
||
"#;
|
||
let path = make_temp_file(json_content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 50;
|
||
|
||
// Compute raw wrap cache
|
||
app.ensure_viewport_cache(80);
|
||
let raw_rows: Vec<Vec<String>> = app
|
||
.viewport_cache
|
||
.entries
|
||
.iter()
|
||
.map(|e| e.wrapped_rows.clone())
|
||
.collect();
|
||
|
||
// Toggle on: formatted
|
||
app.json_format = true;
|
||
app.viewport_cache.invalidate();
|
||
app.ensure_viewport_cache(80);
|
||
let formatted_rows: Vec<Vec<String>> = app
|
||
.viewport_cache
|
||
.entries
|
||
.iter()
|
||
.map(|e| e.wrapped_rows.clone())
|
||
.collect();
|
||
|
||
// Formatted should differ from raw
|
||
assert_ne!(
|
||
raw_rows, formatted_rows,
|
||
"Formatted wrap cache should differ from raw"
|
||
);
|
||
|
||
// Toggle off: back to raw
|
||
app.json_format = false;
|
||
app.viewport_cache.invalidate();
|
||
app.ensure_viewport_cache(80);
|
||
let restored_rows: Vec<Vec<String>> = app
|
||
.viewport_cache
|
||
.entries
|
||
.iter()
|
||
.map(|e| e.wrapped_rows.clone())
|
||
.collect();
|
||
|
||
assert_eq!(
|
||
raw_rows, restored_rows,
|
||
"Toggle off should restore original wrap cache"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
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('\n');
|
||
}
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 20;
|
||
|
||
app.ensure_viewport_cache(80);
|
||
let entry0 = app.viewport_cache.get_entry(0).unwrap();
|
||
assert_eq!(entry0.visual_height, 1);
|
||
|
||
app.cursor_line = 50;
|
||
app.ensure_cursor_visible();
|
||
let cursor_visual = app.cursor_to_first_visual_row(50);
|
||
assert!(
|
||
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,
|
||
);
|
||
let v_offset_before = app.v_offset;
|
||
|
||
app.json_format = true;
|
||
app.viewport_cache.invalidate();
|
||
app.ensure_viewport_cache(80);
|
||
|
||
let height_0 = app.compute_visual_height(0, 80);
|
||
assert!(
|
||
height_0 > 1,
|
||
"JSON lines should expand when formatted"
|
||
);
|
||
|
||
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 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,
|
||
);
|
||
|
||
assert_ne!(
|
||
app.v_offset, v_offset_before,
|
||
"v_offset should change after JSON formatting toggle (cursor was on line 50)"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
// ── Task 3 tests: AppMode, 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();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 50;
|
||
app.ensure_viewport_cache(80);
|
||
assert!(!app.viewport_cache.entries.is_empty());
|
||
assert_eq!(app.viewport_cache.entries.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.ensure_viewport_cache(80);
|
||
assert_eq!(app.viewport_cache.entries.len(), 2);
|
||
|
||
app.load_file(path_b.to_str().unwrap()).unwrap();
|
||
app.ensure_viewport_cache(80);
|
||
assert_eq!(app.viewport_cache.entries.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();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 50;
|
||
app.ensure_viewport_cache(80);
|
||
let entry = app.viewport_cache.get_entry(0).unwrap();
|
||
assert_eq!(entry.level, 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();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 50;
|
||
app.ensure_viewport_cache(80);
|
||
let entry = app.viewport_cache.get_entry(0).unwrap();
|
||
assert_eq!(entry.level, 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();
|
||
load_file_ready(&mut app, &path);
|
||
app.json_format = true;
|
||
app.content_height = 50;
|
||
app.ensure_viewport_cache(80);
|
||
let entry = app.viewport_cache.get_entry(0).unwrap();
|
||
assert_eq!(entry.level, Some(LogLevel::Warn));
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_level_cache_entries_cover_all_lines() {
|
||
let path = make_temp_file("ERROR: a\nINFO: b\nDEBUG: c\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 50;
|
||
app.ensure_viewport_cache(80);
|
||
assert_eq!(
|
||
app.viewport_cache.entries.len(),
|
||
3,
|
||
"viewport cache entries should cover all lines"
|
||
);
|
||
});
|
||
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));
|
||
}
|
||
|
||
// ── T14: Seamless Loading→Ready transition ────────────────────────
|
||
|
||
fn app_in_loading_state(
|
||
file_path: &std::path::Path,
|
||
generation: u64,
|
||
) -> (
|
||
App,
|
||
crossbeam_channel::Sender<IndexerMessage>,
|
||
) {
|
||
if let Some(cp) = log_viewer_core::io::cache_util::cache_path(file_path) {
|
||
let _ = std::fs::remove_file(cp);
|
||
}
|
||
|
||
let (tx, rx) = crossbeam_channel::bounded(10);
|
||
let (cancel_tx, _cancel_rx) = crossbeam_channel::bounded(1);
|
||
|
||
let reader = ProgressiveFileReader::with_channels(
|
||
file_path,
|
||
cancel_tx,
|
||
rx,
|
||
generation,
|
||
)
|
||
.unwrap();
|
||
|
||
let estimated = reader.line_count() as u64;
|
||
let mut app = App::new();
|
||
app.file_path = Some(file_path.to_str().unwrap().to_string());
|
||
app.loading_state = AppLoadingState::Loading {
|
||
reader,
|
||
estimated_lines: estimated,
|
||
progress_percent: 0.0,
|
||
};
|
||
(app, tx)
|
||
}
|
||
|
||
fn file_reader_for(path: &std::path::Path) -> log_viewer_core::io::file_reader::FileReader {
|
||
log_viewer_core::io::file_reader::FileReader::open(path).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn test_seamless_transition_preserves_position() {
|
||
let content: String = (0..100)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let (mut app, tx) = app_in_loading_state(&path, 42);
|
||
|
||
app.cursor_line = 50;
|
||
app.v_offset = 50;
|
||
|
||
let fr = file_reader_for(&path);
|
||
tx.send(IndexerMessage::Complete {
|
||
generation: 42,
|
||
reader: fr,
|
||
visual_height_index: None,
|
||
}).unwrap();
|
||
|
||
app.poll_background_indexer();
|
||
|
||
assert!(!app.is_loading(), "should be Ready");
|
||
assert_eq!(app.cursor_line, 50, "cursor preserved at line 50");
|
||
assert_eq!(app.total_lines(), 100);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_seamless_transition_clamps_cursor() {
|
||
let big_content: String = (0..100)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let small_content: String = (0..80)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let big_path = make_temp_file(&big_content);
|
||
let small_path = make_temp_file(&small_content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let (mut app, tx) = app_in_loading_state(&big_path, 42);
|
||
|
||
app.cursor_line = 90;
|
||
app.v_offset = 90;
|
||
|
||
let fr = file_reader_for(&small_path);
|
||
tx.send(IndexerMessage::Complete {
|
||
generation: 42,
|
||
reader: fr,
|
||
visual_height_index: None,
|
||
}).unwrap();
|
||
|
||
app.poll_background_indexer();
|
||
|
||
assert!(!app.is_loading());
|
||
assert_eq!(app.total_lines(), 80);
|
||
assert_eq!(app.cursor_line, 79, "clamped to last valid line");
|
||
assert!(app.v_offset <= app.cursor_line);
|
||
});
|
||
cleanup(&big_path);
|
||
cleanup(&small_path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_seamless_transition_v_offset_converted() {
|
||
let content: String = (0..50)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let (mut app, tx) = app_in_loading_state(&path, 7);
|
||
app.content_height = 20;
|
||
app.cursor_line = 30;
|
||
app.v_offset = 30;
|
||
|
||
let fr = file_reader_for(&path);
|
||
tx.send(IndexerMessage::Complete {
|
||
generation: 7,
|
||
reader: fr,
|
||
visual_height_index: None,
|
||
}).unwrap();
|
||
|
||
app.poll_background_indexer();
|
||
|
||
assert!(!app.is_loading());
|
||
assert_eq!(app.cursor_line, 30);
|
||
assert_eq!(app.v_offset, 30, "v_offset = cursor_to_first_visual_row(30)");
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_seamless_transition_invalidates_visual_height_index() {
|
||
let content: String = (0..30)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let (mut app, tx) = app_in_loading_state(&path, 1);
|
||
|
||
let fr = file_reader_for(&path);
|
||
let visual_heights = vec![1usize; 30];
|
||
let vhi = VisualHeightIndex::build(&visual_heights);
|
||
tx.send(IndexerMessage::Complete {
|
||
generation: 1,
|
||
reader: fr,
|
||
visual_height_index: Some(vhi),
|
||
}).unwrap();
|
||
|
||
app.poll_background_indexer();
|
||
|
||
assert!(!app.is_loading());
|
||
assert!(
|
||
app.get_visual_height_index().is_none(),
|
||
"visual height index should be invalidated (gutter width change)"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
// ── FileWatcher integration tests ────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_file_watcher_started_on_load() {
|
||
let path = make_temp_file("line1\nline2\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(
|
||
app.file_watcher.is_some(),
|
||
"file_watcher should be started after load_file"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_file_watcher_stopped_on_new_file() {
|
||
let path_a = make_temp_file("aaa\n");
|
||
let path_b = make_temp_file("bbb\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path_a.to_str().unwrap()).unwrap();
|
||
assert!(app.file_watcher.is_some());
|
||
|
||
app.load_file(path_b.to_str().unwrap()).unwrap();
|
||
|
||
// After loading a new file, watcher should be watching the new file
|
||
assert!(
|
||
app.file_watcher.is_some(),
|
||
"file_watcher should be started for new file"
|
||
);
|
||
assert_eq!(app.total_lines(), 1);
|
||
assert_eq!(app.get_line(0), Some("bbb".to_string()));
|
||
});
|
||
cleanup(&path_a);
|
||
cleanup(&path_b);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_file_watcher_stopped_on_nonexistent_file() {
|
||
let path = make_temp_file("data\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.file_watcher.is_some());
|
||
|
||
let _ = app.load_file("/tmp/no_such_file_log_viewer_test_xyz_999");
|
||
assert!(
|
||
app.file_watcher.is_none(),
|
||
"file_watcher should be None after failed load_file"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_file_watcher_append_updates_index() {
|
||
let path = make_temp_file("line1\nline2\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
assert_eq!(app.total_lines(), 2);
|
||
|
||
// Append externally
|
||
{
|
||
use std::io::Write;
|
||
let mut f = std::fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&path)
|
||
.unwrap();
|
||
f.write_all(b"line3\nline4\n").unwrap();
|
||
}
|
||
|
||
// Give the watcher time to detect the change
|
||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||
|
||
app.poll_file_watcher();
|
||
|
||
assert_eq!(
|
||
app.total_lines(), 4,
|
||
"total_lines should increase after append"
|
||
);
|
||
assert_eq!(app.get_line(2), Some("line3".to_string()));
|
||
assert_eq!(app.get_line(3), Some("line4".to_string()));
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_file_watcher_truncate_reloads() {
|
||
let path = make_temp_file("aaa\nbbb\nccc\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
assert_eq!(app.total_lines(), 3);
|
||
|
||
app.cursor_line = 2;
|
||
|
||
// Truncate externally
|
||
{
|
||
let _ = std::fs::File::create(&path).unwrap();
|
||
}
|
||
|
||
// Give the watcher time to detect the change
|
||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||
|
||
app.poll_file_watcher();
|
||
|
||
assert_eq!(
|
||
app.total_lines(), 0,
|
||
"total_lines should be 0 after truncate"
|
||
);
|
||
// Cursor should be clamped
|
||
assert!(
|
||
app.cursor_line == 0,
|
||
"cursor should be clamped after truncate"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_file_watcher_no_events_no_change() {
|
||
let path = make_temp_file("stable\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
let lines_before = app.total_lines();
|
||
|
||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||
app.poll_file_watcher();
|
||
|
||
assert_eq!(
|
||
app.total_lines(), lines_before,
|
||
"no events should mean no change"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_poll_file_watcher_no_watcher() {
|
||
let mut app = App::new();
|
||
// Should not panic
|
||
app.poll_file_watcher();
|
||
}
|
||
|
||
// ── Loading + JSON expansion tests ─────────────────────────────
|
||
|
||
#[test]
|
||
fn test_tab_toggle_during_loading() {
|
||
let content = "line1\n{\"ts\":\"2025\",\"level\":\"ERROR\",\"msg\":\"hello\"}\nline3\n";
|
||
let path = make_temp_file(content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading(), "should be in Loading state");
|
||
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
|
||
app.handle_key(tab);
|
||
|
||
assert!(app.json_format, "Tab should toggle json_format to true during Loading");
|
||
assert_eq!(
|
||
app.viewport_cache.width, 0,
|
||
"Tab should invalidate viewport cache (width==0)"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_json_expanded_visual_height() {
|
||
let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#;
|
||
let content = format!("line1\n{json_line}\nline3\n");
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading());
|
||
|
||
app.json_format = true;
|
||
let height = app.compute_visual_height(1, 40);
|
||
assert!(
|
||
height > 1,
|
||
"JSON expanded line should have visual_height > 1, got {height}"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_json_viewport_contains_cursor() {
|
||
let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#;
|
||
let mut lines: Vec<String> = (0..50).map(|i| format!("line{i}")).collect();
|
||
lines.push(json_line.to_string());
|
||
lines.push("end".to_string());
|
||
let content = lines.join("\n") + "\n";
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading());
|
||
|
||
app.json_format = true;
|
||
app.content_height = 24;
|
||
app.cursor_line = 50;
|
||
|
||
app.ensure_viewport_cache(80);
|
||
|
||
let entries = &app.viewport_cache.entries;
|
||
let first = app.viewport_cache.logical_start;
|
||
let last_logical = first + entries.len().saturating_sub(1);
|
||
assert!(
|
||
app.cursor_line >= first && app.cursor_line <= last_logical,
|
||
"cursor_line {} should be within viewport range [{}, {}]",
|
||
app.cursor_line, first, last_logical
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_json_scroll_down_line() {
|
||
let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#;
|
||
let content = format!("line1\n{json_line}\nline3\nline4\n");
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading());
|
||
|
||
app.json_format = true;
|
||
app.content_height = 24;
|
||
assert_eq!(app.cursor_line, 0);
|
||
|
||
app.scroll_down_line();
|
||
assert_eq!(
|
||
app.cursor_line, 1,
|
||
"scroll_down_line should increment cursor_line by 1"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_json_scroll_half_page() {
|
||
let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#;
|
||
let content = format!("line1\n{json_line}\nline3\nline4\nline5\nline6\n");
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading());
|
||
|
||
app.json_format = true;
|
||
app.content_height = 24;
|
||
|
||
// Should not panic
|
||
app.scroll_down_half_page();
|
||
|
||
let total = app.total_lines();
|
||
assert!(
|
||
app.cursor_line < total,
|
||
"cursor_line {} should be < total_lines {}",
|
||
app.cursor_line, total
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_json_scroll_to_bottom() {
|
||
let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#;
|
||
let content = format!("line1\n{json_line}\nline3\nline4\nline5\n");
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading());
|
||
|
||
app.json_format = true;
|
||
app.content_height = 24;
|
||
|
||
// Should not panic
|
||
app.scroll_to_bottom();
|
||
|
||
assert_eq!(
|
||
app.cursor_line,
|
||
app.total_lines() - 1,
|
||
"scroll_to_bottom should set cursor_line to last line"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_to_ready_preserves_json_format() {
|
||
let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#;
|
||
let content = format!("line1\n{json_line}\nline3\n");
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading(), "should start in Loading state");
|
||
|
||
// load_file resets json_format to false
|
||
app.json_format = true;
|
||
app.content_height = 24;
|
||
|
||
// Manually transition Loading → Ready
|
||
let old_state = std::mem::replace(&mut app.loading_state, AppLoadingState::Empty);
|
||
if let AppLoadingState::Loading { reader, .. } = old_state {
|
||
app.loading_state = AppLoadingState::Ready { reader };
|
||
}
|
||
app.viewport_cache.invalidate();
|
||
assert!(!app.is_loading(), "should now be in Ready state");
|
||
|
||
app.ensure_viewport_cache(80);
|
||
|
||
assert!(
|
||
app.json_format,
|
||
"json_format should remain true after Loading→Ready transition"
|
||
);
|
||
|
||
let has_expanded = app.viewport_cache.entries.iter().any(|e| e.visual_height > 1);
|
||
assert!(
|
||
has_expanded,
|
||
"viewport should contain entries with visual_height > 1 (JSON expanded)"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_tab_toggle_off_during_loading() {
|
||
let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#;
|
||
let content = format!("line1\n{json_line}\nline3\n");
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading());
|
||
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
|
||
|
||
// Tab ON
|
||
app.handle_key(tab);
|
||
assert!(app.json_format, "first Tab should set json_format=true");
|
||
|
||
// Tab OFF
|
||
app.handle_key(tab);
|
||
assert!(!app.json_format, "second Tab should set json_format=false");
|
||
|
||
app.content_height = 24;
|
||
app.ensure_viewport_cache(80);
|
||
for (i, entry) in app.viewport_cache.entries.iter().enumerate() {
|
||
assert_eq!(
|
||
entry.visual_height, 1,
|
||
"entry {} should have height 1 with json_format off",
|
||
i
|
||
);
|
||
}
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_json_empty_line() {
|
||
let content = "line1\n\nline3\n";
|
||
let path = make_temp_file(content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading());
|
||
|
||
app.json_format = true;
|
||
let height = app.compute_visual_height(1, 80);
|
||
assert_eq!(
|
||
height, 1,
|
||
"empty line should have visual_height 1, got {height}"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_json_deep_nested() {
|
||
let deep_json = r#"{"a":{"b":{"c":{"d":"value"}}}}"#;
|
||
let content = format!("line1\n{deep_json}\nline3\n");
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
assert!(app.is_loading());
|
||
|
||
app.json_format = true;
|
||
app.content_height = 24;
|
||
|
||
app.ensure_viewport_cache(20);
|
||
|
||
let json_height = app.compute_visual_height(1, 20);
|
||
assert!(
|
||
json_height > 1,
|
||
"deep nested JSON at width 20 should have visual_height > 1, got {json_height}"
|
||
);
|
||
|
||
let total_rows: usize = app.viewport_cache.entries.iter().map(|e| e.visual_height).sum();
|
||
assert!(
|
||
total_rows <= app.content_height as usize + 2,
|
||
"viewport visual rows ({total_rows}) should not wildly exceed content_height ({})",
|
||
app.content_height
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
fn install_vhi(app: &mut App, heights: &[usize]) {
|
||
let vhi = VisualHeightIndex::build(heights);
|
||
if let AppLoadingState::Ready { reader } = &mut app.loading_state {
|
||
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
||
visual_height_index,
|
||
..
|
||
} = &mut reader.state
|
||
{
|
||
*visual_height_index = Some(vhi);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_vhi_scroll_down_line_visual_row() {
|
||
let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
|
||
let path = make_temp_file(content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 10;
|
||
|
||
// Each line wraps to 3 visual rows → 30 total, overflows viewport of 10
|
||
install_vhi(&mut app, &[3usize; 10]);
|
||
|
||
assert_eq!(app.cursor_line, 0);
|
||
assert_eq!(app.v_offset, 0);
|
||
|
||
for _ in 0..5 {
|
||
app.scroll_down_line();
|
||
}
|
||
|
||
assert_eq!(app.v_offset, 5, "v_offset should be 5 after 5 visual scrolls");
|
||
// center_visual = 5 + 10/2 = 10 → maps to logical line 3 (visual rows: line0=0-2, line1=3-5, line2=6-8, line3=9-11)
|
||
assert_eq!(app.cursor_line, 3, "cursor should track center at logical line 3");
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_vhi_scroll_up_line_visual_row() {
|
||
let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
|
||
let path = make_temp_file(content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 10;
|
||
|
||
install_vhi(&mut app, &[3usize; 10]);
|
||
|
||
// Start at v_offset=5, cursor_line=3 (matching center)
|
||
app.v_offset = 5;
|
||
app.cursor_line = 3;
|
||
|
||
for _ in 0..5 {
|
||
app.scroll_up_line();
|
||
}
|
||
|
||
assert_eq!(app.v_offset, 0, "v_offset should return to 0");
|
||
// After scrolling back up, center_visual = 0 + 5 = 5 → line1
|
||
assert!(app.cursor_line <= 2, "cursor should be near top, got {}", app.cursor_line);
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_vhi_scroll_down_line_small_file_fallback() {
|
||
let content = "a\nb\nc\n";
|
||
let path = make_temp_file(content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 24;
|
||
|
||
// 3 lines, 1 row each → 3 visual rows, fits in viewport of 24
|
||
install_vhi(&mut app, &[1usize; 3]);
|
||
|
||
assert_eq!(app.cursor_line, 0);
|
||
assert_eq!(app.v_offset, 0);
|
||
|
||
app.scroll_down_line();
|
||
|
||
assert_eq!(app.cursor_line, 1, "cursor should move to line 1 (logical)");
|
||
assert_eq!(app.v_offset, 0, "v_offset should stay 0 (content fits)");
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_vhi_scroll_j_k_roundtrip() {
|
||
let content: String = (0..20).map(|i| format!("line{}\n", i)).collect();
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.content_height = 10;
|
||
|
||
// 20 lines × 2 rows = 40 visual rows, viewport = 10
|
||
install_vhi(&mut app, &[2usize; 20]);
|
||
|
||
let initial_cursor = app.cursor_line;
|
||
let initial_offset = app.v_offset;
|
||
|
||
for _ in 0..15 {
|
||
app.scroll_down_line();
|
||
}
|
||
assert!(app.v_offset > 0, "v_offset should have moved down");
|
||
|
||
for _ in 0..15 {
|
||
app.scroll_up_line();
|
||
}
|
||
|
||
assert_eq!(app.v_offset, initial_offset, "v_offset should roundtrip to {}", initial_offset);
|
||
assert!(
|
||
app.cursor_line <= initial_cursor + 3,
|
||
"cursor should return near top, got {}, expected <= {}",
|
||
app.cursor_line,
|
||
initial_cursor + 3
|
||
);
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
fn app_in_loading_with_long_lines(app: &mut App, line_count: usize, line_width: usize) -> std::path::PathBuf {
|
||
let content: String = (0..line_count)
|
||
.map(|_| "x".repeat(line_width))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let path = make_temp_file(&content);
|
||
app.load_file(path.to_str().unwrap()).unwrap();
|
||
path
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_scroll_down_advances_sub_offset() {
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let mut app = App::new();
|
||
let path = app_in_loading_with_long_lines(&mut app, 10000, 200);
|
||
app.content_height = 24;
|
||
|
||
if !app.is_loading() {
|
||
cleanup(&path);
|
||
return;
|
||
}
|
||
|
||
assert_eq!(app.v_offset, 0);
|
||
assert_eq!(app.v_sub_offset, 0);
|
||
|
||
app.scroll_down_line();
|
||
assert_eq!(app.v_offset, 0, "v_offset should stay 0 after first j");
|
||
assert_eq!(app.v_sub_offset, 1, "v_sub_offset should advance to 1");
|
||
|
||
app.scroll_down_line();
|
||
assert_eq!(app.v_offset, 0, "v_offset should stay 0 after second j");
|
||
assert_eq!(app.v_sub_offset, 2, "v_sub_offset should advance to 2");
|
||
|
||
app.scroll_down_line();
|
||
assert_eq!(app.v_offset, 1, "v_offset should advance to 1");
|
||
assert_eq!(app.v_sub_offset, 0, "v_sub_offset should reset to 0");
|
||
|
||
cleanup(&path);
|
||
}));
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_scroll_up_decrements_sub_offset() {
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let mut app = App::new();
|
||
let path = app_in_loading_with_long_lines(&mut app, 10000, 200);
|
||
app.content_height = 24;
|
||
|
||
if !app.is_loading() {
|
||
cleanup(&path);
|
||
return;
|
||
}
|
||
|
||
// Scroll down to v_offset=1, v_sub_offset=0
|
||
for _ in 0..3 { app.scroll_down_line(); }
|
||
assert_eq!(app.v_offset, 1);
|
||
assert_eq!(app.v_sub_offset, 0);
|
||
|
||
app.scroll_up_line();
|
||
assert_eq!(app.v_offset, 0, "v_offset should go back to 0");
|
||
assert!(app.v_sub_offset > 0, "v_sub_offset should be at end of line 0");
|
||
|
||
let prev_sub = app.v_sub_offset;
|
||
app.scroll_up_line();
|
||
assert_eq!(app.v_offset, 0, "v_offset should stay 0");
|
||
assert_eq!(app.v_sub_offset, prev_sub - 1);
|
||
|
||
cleanup(&path);
|
||
}));
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_scroll_to_top_resets_sub_offset() {
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let mut app = App::new();
|
||
let path = app_in_loading_with_long_lines(&mut app, 10000, 200);
|
||
app.content_height = 24;
|
||
|
||
if !app.is_loading() {
|
||
cleanup(&path);
|
||
return;
|
||
}
|
||
|
||
for _ in 0..5 { app.scroll_down_line(); }
|
||
assert!(app.v_offset > 0 || app.v_sub_offset > 0);
|
||
|
||
app.scroll_to_top();
|
||
assert_eq!(app.v_offset, 0, "v_offset should be 0 after scroll_to_top");
|
||
assert_eq!(app.v_sub_offset, 0, "v_sub_offset should be 0 after scroll_to_top");
|
||
|
||
cleanup(&path);
|
||
}));
|
||
assert!(result.is_ok());
|
||
}
|
||
}
|