Files
logViewer/crates/tui/src/app.rs
dailz fb23e4c7cb fix(tui): smooth visual-row scrolling during Loading state
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
2026-04-24 19:04:30 +08:00

2655 lines
90 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}