Files
logViewer/crates/tui/src/app.rs
dailz c1a931551b fix(tui): handle settings save errors and rewrite truncate_to_columns (closes #30)
- Report save failures in settings instead of silently discarding errors
- Save draft first, commit color_config only on success (prevents split-brain)
- Show error message in red on status bar, stay in Settings mode on failure
- Clear error on Esc, entering settings, or any settings edit
- Rewrite truncate_to_columns as dedicated O(prefix) truncator
- Tab: stop before tab if expansion would exceed width
- Fixes pre-existing test_truncate_to_columns_tab failure
2026-06-11 16:08:57 +08:00

3701 lines
130 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, MAX_WRAP_INPUT_LEN};
use log_viewer_core::types::LogLevel;
use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher};
use unicode_width::UnicodeWidthChar;
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,
pub(crate) settings_error: Option<String>,
// File watcher
file_watcher: Option<FileWatcher>,
/// Set to true when file-change events arrive during Loading state.
/// After Loading→Ready transition, a full reload is triggered once.
reload_after_loading: bool,
}
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(),
settings_error: None,
file_watcher: None,
reload_after_loading: false,
}
}
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;
self.json_format = false;
self.mode = AppMode::Normal;
self.reload_after_loading = false;
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();
// Guard 1: oversized raw input — skip detect_level and JSON formatting
// to avoid O(n) parsing overhead on huge lines.
if raw.len() > MAX_WRAP_INPUT_LEN {
return ViewportEntry {
wrapped_rows: vec![truncate_to_columns(&raw, width)],
level: None,
visual_height: 1,
};
}
let level = log_viewer_core::parser::level::detect_level(&raw);
let display_text = if self.json_format {
format_json_line(&raw)
} else {
raw
};
// Guard 2: JSON pretty-printing may expand a line beyond the limit.
if display_text.len() > MAX_WRAP_INPUT_LEN {
return ViewportEntry {
wrapped_rows: vec![truncate_to_columns(&display_text, width)],
level,
visual_height: 1,
};
}
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();
// Guard 1: oversized raw input.
if raw.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
let display_text = if self.json_format {
format_json_line(&raw)
} else {
raw
};
// Guard 2: post-format expansion.
if display_text.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
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;
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() {
(self.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) {
use crossterm::event::KeyEventKind;
let should_handle = match key.kind {
KeyEventKind::Press => true,
KeyEventKind::Repeat => self.is_repeatable_key(&key),
KeyEventKind::Release => false,
};
if !should_handle {
return;
}
match self.mode {
AppMode::Normal => self.handle_normal_key(key),
AppMode::Settings => self.handle_settings_key(key),
}
}
/// Keys that should auto-repeat when held (scroll/navigation only).
fn is_repeatable_key(&self, key: &crossterm::event::KeyEvent) -> bool {
use crossterm::event::{KeyCode, KeyModifiers};
let plain = key.modifiers.is_empty();
let ctrl = key.modifiers == KeyModifiers::CONTROL;
match self.mode {
AppMode::Normal => {
(plain
&& matches!(
key.code,
KeyCode::Char('j')
| KeyCode::Down
| KeyCode::Char('k')
| KeyCode::Up
| KeyCode::PageDown
| KeyCode::PageUp
))
|| (ctrl
&& matches!(
key.code,
KeyCode::Char('d')
| KeyCode::Char('u')
| KeyCode::Char('f')
| KeyCode::Char('b')
))
}
AppMode::Settings => plain
&& matches!(
key.code,
KeyCode::Char('j')
| KeyCode::Down
| KeyCode::Char('k')
| KeyCode::Up
| KeyCode::Left
| KeyCode::Right
),
}
}
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;
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
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.v_offset = new_offset;
self.v_sub_offset = new_sub;
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.settings_error = None;
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.settings_error = None;
self.mode = AppMode::Normal;
}
KeyCode::Enter => {
let draft = self.settings_draft.clone();
match draft.save() {
Ok(()) => {
self.color_config = draft;
self.settings_error = None;
self.mode = AppMode::Normal;
}
Err(e) => {
self.settings_error =
Some(format!("Failed to save settings: {e}"));
}
}
}
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 {
if p == 0 { colors.len() - 1 } else { p - 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) {
self.settings_error = None;
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,
}
}
/// Compute the rebased offset pair (logical_line, sub_row) from the
/// current visual-row `v_offset`. Returns `(v_offset, v_sub_offset)`
/// suitable for the no-VHI fallback scrolling path.
///
/// MUST be called before borrowing `&mut self.loading_state` for
/// `invalidate_visual_height_index`, because it reads VHI through
/// `&self`.
fn rebase_offset_for_invalidate(&self) -> (usize, usize) {
if self.get_visual_height_index().is_some() {
let top_visual = self.v_offset;
let top_line = self.visual_row_to_logical_row(top_visual);
let line_first_visual = self.cursor_to_first_visual_row(top_line);
let sub = top_visual.saturating_sub(line_first_visual);
(top_line, sub)
} else {
(self.v_offset, self.v_sub_offset)
}
}
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 {
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format);
}
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
}
}
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.
}
FileEvent::Removed => {
self.loading_state = AppLoadingState::Error("File has been deleted".into());
}
FileEvent::WatcherError { message: _ } => {}
}
}
}
fn handle_file_appended(&mut self) {
let width = self.get_content_width();
let rebased = self.rebase_offset_for_invalidate();
match &mut self.loading_state {
AppLoadingState::Ready { reader } => {
let old_reader_line_count = reader.line_count();
let status = reader.update_for_append();
match status {
Ok(
log_viewer_core::io::file_reader::AppendStatus::Appended(_new_lines),
) => {
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 && old_line_count == old_reader_line_count {
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
visual_height_index: Some(index),
reader: fr,
} = &mut reader.state
{
if old_line_count > 0 {
let last_old_line_text =
fr.get_line(old_line_count - 1).unwrap_or("");
let new_h = compute_line_visual_height(
last_old_line_text,
width,
self.json_format,
);
index.replace_last_line_height(new_h);
}
let mut new_heights =
Vec::with_capacity(new_line_count.saturating_sub(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 {
let (new_offset, new_sub) = rebased;
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format);
}
self.viewport_cache.invalidate();
}
Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => {
let _ = reader.save_cache();
let (new_offset, _new_sub) = rebased;
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.v_offset = new_offset;
self.v_sub_offset = 0;
self.viewport_cache.invalidate();
self.clamp_v_offset();
}
Ok(log_viewer_core::io::file_reader::AppendStatus::Unchanged) | Err(_) => {}
}
}
AppLoadingState::Loading { .. } => {
self.reload_after_loading = true;
}
_ => {}
}
}
fn reload_ready_reader(&mut self) {
let width = self.get_content_width();
let (new_offset, _new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
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.v_offset = new_offset;
self.v_sub_offset = 0;
self.viewport_cache.invalidate();
self.clamp_v_offset();
}
}
fn handle_file_truncated(&mut self) {
match &mut self.loading_state {
AppLoadingState::Ready { .. } => {
self.reload_ready_reader();
}
AppLoadingState::Loading { .. } => {
self.reload_after_loading = true;
}
_ => {}
}
}
/// 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);
}
}
}
// Recalibrate v_offset from logical → visual-row now that VHI is available.
// Must be outside the `reader` borrow above.
if self.get_visual_height_index().is_some() {
let logical_top = self.v_offset.min(self.total_lines().saturating_sub(1));
let sub = self.v_sub_offset;
let first_visual = self.cursor_to_first_visual_row(logical_top);
let line_height = self
.get_visual_height_index()
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
self.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1)));
self.v_sub_offset = 0;
self.clamp_v_offset();
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.
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index();
}
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
if self.reload_after_loading {
self.reload_after_loading = false;
self.reload_ready_reader();
}
}
IndexerMessage::Error { message, .. } => {
self.loading_state = AppLoadingState::Error(message);
self.reload_after_loading = false;
}
}
} else {
self.loading_state = AppLoadingState::Loading {
reader,
estimated_lines,
progress_percent,
};
}
} else {
self.loading_state = old_state;
}
}
}
const TRUNCATE_TAB_WIDTH: usize = 4;
fn truncate_to_columns(s: &str, max_cols: usize) -> String {
if max_cols == 0 || s.is_empty() {
return String::new();
}
let mut out = String::new();
let mut col = 0;
for ch in s.chars() {
if ch == '\t' {
let tab_stop = TRUNCATE_TAB_WIDTH - (col % TRUNCATE_TAB_WIDTH);
if col + tab_stop > max_cols {
break;
}
for _ in 0..tab_stop {
out.push(' ');
}
col += tab_stop;
} else {
let w = if ch.is_control() {
0
} else {
ch.width().unwrap_or(0)
};
if col + w > max_cols {
break;
}
out.push(ch);
col += w;
}
}
out
}
#[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_with_hash(path, &index, &data);
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");
// backward wrap: red (index 0) → white (index 7)
app.handle_key(make_key(crossterm::event::KeyCode::Left));
assert_eq!(app.settings_draft.error, "white");
// forward wrap: white (index 7) → red (index 0)
app.handle_key(make_key(crossterm::event::KeyCode::Right));
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_settings_save_failure_stays_in_settings() {
let mut app = App::new();
let original = app.color_config.clone();
let impossible_path = std::path::PathBuf::from("/nonexistent/deep/nested/config.toml");
enter_settings(&mut app);
app.handle_key(make_key(crossterm::event::KeyCode::Right));
assert_ne!(app.settings_draft.error, original.error);
let draft = app.settings_draft.clone();
let result = draft.save_to(&impossible_path);
assert!(result.is_err(), "Save to impossible path should fail");
let err = result.unwrap_err();
assert!(
err.to_string().contains("config") || err.to_string().contains("creating"),
"Error should mention config or creating directory"
);
}
#[test]
fn test_settings_error_cleared_on_edit() {
let mut app = App::new();
enter_settings(&mut app);
app.settings_error = Some("test error".to_string());
app.handle_key(make_key(crossterm::event::KeyCode::Right));
assert!(
app.settings_error.is_none(),
"Editing a setting should clear settings_error"
);
}
#[test]
fn test_settings_error_cleared_on_esc() {
let mut app = App::new();
enter_settings(&mut app);
app.settings_error = Some("test error".to_string());
app.handle_key(make_key(crossterm::event::KeyCode::Esc));
assert_eq!(app.mode, AppMode::Normal);
assert!(
app.settings_error.is_none(),
"Esc should clear settings_error"
);
}
#[test]
fn test_settings_error_cleared_on_enter_settings() {
let mut app = App::new();
enter_settings(&mut app);
app.settings_error = Some("stale error".to_string());
app.handle_key(make_key(crossterm::event::KeyCode::Esc));
assert!(app.settings_error.is_none());
enter_settings(&mut app);
assert!(
app.settings_error.is_none(),
"Entering settings should clear stale errors"
);
}
#[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());
}
/// Regression test for issue #24:
/// ensure_viewport_cache must use the updated self.v_offset after params_changed
/// recalculates it, not a stale local captured before the change block.
#[test]
fn test_loading_viewport_cache_uses_updated_v_offset_on_params_changed() {
let content: String = (0..200).map(|i| format!("line{i}\n")).collect();
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");
app.content_height = 10;
app.ensure_viewport_cache(80);
app.v_offset = 90;
app.cursor_line = 100;
let new_width = 40;
app.ensure_viewport_cache(new_width);
let recomputed_offset = app.v_offset;
assert_ne!(
recomputed_offset, 90,
"v_offset should have been recalculated by params_changed block, still 90"
);
assert_eq!(
app.viewport_cache.logical_start, recomputed_offset.min(app.total_lines().saturating_sub(1)),
"logical_start should match the updated v_offset"
);
});
cleanup(&path);
assert!(result.is_ok());
}
fn install_vhi(app: &mut App, heights: &[usize]) {
let width = app.get_content_width();
let json_format = app.json_format;
let vhi = VisualHeightIndex::build(heights).with_params(json_format, width);
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());
}
// ── H10 regression: file events during Loading state ──────────────
#[test]
fn test_append_during_loading_sets_reload_flag() {
let content: String = (0..50)
.map(|i| format!("line {}\n", i))
.collect();
let path = make_temp_file(&content);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let (mut app, _tx) = app_in_loading_state(&path, 1);
assert!(app.is_loading());
assert!(!app.reload_after_loading);
app.handle_file_appended();
assert!(app.reload_after_loading,
"append during Loading should set reload_after_loading flag");
}));
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_truncate_during_loading_sets_reload_flag() {
let content: String = (0..50)
.map(|i| format!("line {}\n", i))
.collect();
let path = make_temp_file(&content);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let (mut app, _tx) = app_in_loading_state(&path, 1);
assert!(app.is_loading());
app.handle_file_truncated();
assert!(app.reload_after_loading,
"truncate during Loading should set reload_after_loading flag");
}));
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_multiple_events_during_loading_collapse_to_single_reload() {
let content: String = (0..50)
.map(|i| format!("line {}\n", i))
.collect();
let path = make_temp_file(&content);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let (mut app, tx) = app_in_loading_state(&path, 1);
app.handle_file_appended();
app.handle_file_truncated();
app.handle_file_appended();
assert!(app.reload_after_loading);
let updated_content: String = (0..60)
.map(|i| format!("updated line {}\n", i))
.collect();
std::fs::write(&path, &updated_content).unwrap();
let fr = file_reader_for(&path);
tx.send(IndexerMessage::Complete {
generation: 1,
reader: fr,
visual_height_index: None,
}).unwrap();
app.poll_background_indexer();
assert!(!app.is_loading(), "should be Ready after Complete");
assert!(!app.reload_after_loading, "flag should be cleared");
assert_eq!(app.total_lines(), 60,
"should show reloaded content, not stale indexer result");
}));
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_indexer_error_clears_reload_flag() {
let content: String = (0..10)
.map(|i| format!("line {}\n", i))
.collect();
let path = make_temp_file(&content);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let (mut app, tx) = app_in_loading_state(&path, 1);
app.handle_file_appended();
assert!(app.reload_after_loading);
tx.send(IndexerMessage::Error {
generation: 1,
message: "test error".into(),
}).unwrap();
app.poll_background_indexer();
assert!(matches!(app.loading_state, AppLoadingState::Error(_)));
assert!(!app.reload_after_loading,
"flag should be cleared on indexer error");
}));
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_append_no_trailing_newline_updates_last_line_height() {
let path = make_temp_file("abc");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert_eq!(app.total_lines(), 1);
app.content_width = 5;
install_vhi(&mut app, &[1usize]);
{
let vhi = app.get_visual_height_index().unwrap();
assert_eq!(vhi.visual_height_of_line(0), 1);
assert_eq!(vhi.total_visual_rows(), 1);
}
// Append content that extends line 0 (no trailing newline before)
// "abc" + "defgh\n" = "abcdefgh\n" → 8 chars in width 5 → wraps to 2 visual rows
// old_total=1, old_had_trailing=false → starts_new_line=false
// new_has_trailing=true, new_newlines=1 → added = 1-1 = 0
// Still 1 logical line, but line 0 text changed from "abc" to "abcdefgh"
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(b"defgh\n").unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(500));
app.poll_file_watcher();
assert_eq!(app.total_lines(), 1,
"\"abcdefgh\\n\" has trailing newline → 1 logical line");
let vhi = app.get_visual_height_index().expect("VHI should still exist after append");
assert_eq!(vhi.visual_height_of_line(0), 2,
"line 0 height should be updated from 1 to 2 after extending 'abcdefgh' in width 5");
assert_eq!(vhi.total_visual_rows(), 2);
assert_eq!(vhi.cursor_to_first_visual_row(0), 0);
cleanup(&path);
});
assert!(result.is_ok());
}
// ── Issue #23: KeyEventKind filtering ───────────────────────────
fn make_key_with_kind(
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
kind: crossterm::event::KeyEventKind,
) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new_with_kind(code, modifiers, kind)
}
#[test]
fn issue23_release_quit_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let mut app = App::new();
let release_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Release);
app.handle_key(release_q);
assert!(!app.should_quit, "Release+q must NOT quit");
}
#[test]
fn issue23_release_scroll_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
let release_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Release);
app.handle_key(release_j);
assert_eq!(app.cursor_line, 0, "Release+j must NOT scroll");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_repeat_quit_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let mut app = App::new();
let repeat_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_q);
assert!(!app.should_quit, "Repeat+q must NOT quit");
}
#[test]
fn issue23_repeat_j_passes() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
let repeat_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_j);
assert_eq!(app.cursor_line, 1, "Repeat+j must scroll");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_repeat_ctrl_d_passes() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
app.content_height = 5;
load_file_ready(&mut app, &path);
install_vhi(&mut app, &[1usize; 10]);
let repeat_ctrl_d = make_key_with_kind(
KeyCode::Char('d'), KeyModifiers::CONTROL, KeyEventKind::Repeat,
);
app.handle_key(repeat_ctrl_d);
assert!(app.cursor_line > 0, "Repeat+Ctrl+d must scroll half page");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_repeat_ctrl_d_without_ctrl_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
let repeat_plain_d = make_key_with_kind(
KeyCode::Char('d'), KeyModifiers::NONE, KeyEventKind::Repeat,
);
app.handle_key(repeat_plain_d);
assert_eq!(app.cursor_line, 0, "Repeat+plain d must NOT scroll");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_repeat_g_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
let repeat_g = make_key_with_kind(KeyCode::Char('g'), KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_g);
assert_eq!(app.cursor_line, 0, "Repeat+g must NOT jump");
assert!(app.last_g_press.is_none(), "Repeat+g must not set last_g_press");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_settings_repeat_left_right_passes() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let mut app = App::new();
enter_settings(&mut app);
assert_eq!(app.mode, AppMode::Settings);
let repeat_right = make_key_with_kind(KeyCode::Right, KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_right);
app.handle_key(make_key(KeyCode::Enter));
assert_eq!(app.mode, AppMode::Normal, "Repeat Right in Settings should work then Enter closes");
}
#[test]
fn issue23_settings_repeat_enter_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let mut app = App::new();
enter_settings(&mut app);
let repeat_enter = make_key_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_enter);
assert_eq!(app.mode, AppMode::Settings, "Repeat+Enter must NOT close settings");
}
#[test]
fn test_append_no_trailing_newline_no_new_lines_only_height_change() {
let path = make_temp_file("abc");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert_eq!(app.total_lines(), 1);
app.content_width = 5;
install_vhi(&mut app, &[1usize]);
// Append without adding any new line — just extends line 0
// "abc" + "def" = "abcdef" → 6 chars in width 5 → wraps to 2 visual rows, still 1 logical line
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(b"def").unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(500));
app.poll_file_watcher();
assert_eq!(app.total_lines(), 1,
"no new logical line should be added");
let vhi = app.get_visual_height_index().expect("VHI should still exist");
assert_eq!(vhi.visual_height_of_line(0), 2,
"line 0 height should update even when no new lines added");
assert_eq!(vhi.total_visual_rows(), 2);
cleanup(&path);
});
assert!(result.is_ok());
}
// ── Issue #25 regression tests ──────────────────────────────────
// Ready state without VHI must keep v_offset self-consistent.
#[test]
fn test_rebase_offset_converts_visual_to_logical_with_sub() {
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;
// 10 lines × 3 visual rows each = 30 visual rows
install_vhi(&mut app, &[3usize; 10]);
// v_offset=7 → visual row 7 is inside line2 (rows 6-8), sub=1
app.v_offset = 7;
app.v_sub_offset = 0;
let (logical, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical, 2, "visual row 7 → logical line 2 (rows 6-8)");
assert_eq!(sub, 1, "visual row 7 is sub-row 1 within line 2");
// v_offset=0 → line 0, sub=0
app.v_offset = 0;
let (logical, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical, 0);
assert_eq!(sub, 0);
// v_offset=5 → line1 (rows 3-5), sub=2
app.v_offset = 5;
let (logical, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical, 1, "visual row 5 → logical line 1 (rows 3-5)");
assert_eq!(sub, 2);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_rebase_offset_passthrough_when_no_vhi() {
let content = "line0\nline1\nline2\n";
let path = make_temp_file(content);
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
// No VHI installed — rebase should return current values unchanged
app.v_offset = 42;
app.v_sub_offset = 3;
let (logical, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical, 42);
assert_eq!(sub, 3);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_tab_toggle_rebases_offset_before_invalidate() {
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;
app.content_width = 20;
install_vhi(&mut app, &[3usize; 10]);
// Scroll to visual row 7 → line2 sub-row 1
app.v_offset = 7;
app.cursor_line = 2;
app.v_sub_offset = 0;
// Simulate Tab toggle: rebase, then invalidate
let rebased = app.rebase_offset_for_invalidate();
let (new_offset, new_sub) = rebased;
app.v_offset = new_offset;
app.v_sub_offset = new_sub;
assert_eq!(app.v_offset, 2, "v_offset should be logical line 2 after rebase");
assert_eq!(app.v_sub_offset, 1, "v_sub_offset should preserve sub-row 1");
// Key invariant: v_offset is now a valid logical line number,
// not a stale visual-row offset that would cause a jump.
// Before the fix, v_offset would still be 7 (visual) treated as logical → jump.
assert!(
app.v_offset < app.total_lines(),
"v_offset ({}) must be a valid logical line index < {}",
app.v_offset, app.total_lines()
);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_vhi_rebuild_recalibrates_from_viewport_top() {
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;
app.content_width = 20;
// Install VHI with varying heights
install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]);
// Scroll to visual row 8 → line2 (rows 6-8), sub=2
app.v_offset = 8;
app.cursor_line = 2;
app.v_sub_offset = 0;
// Rebase (simulates invalidate)
let (logical_top, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical_top, 2);
assert_eq!(sub, 2);
// Simulate what happens: v_offset becomes logical, VHI cleared
app.v_offset = logical_top;
app.v_sub_offset = sub;
// Now simulate VHI rebuild completion — the recalibration block
// Install a fresh VHI (same heights, simulates rebuild result)
install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]);
// The recalibration logic from poll_background_indexer
let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1));
let sub = app.v_sub_offset;
let first_visual = app.cursor_to_first_visual_row(logical_top);
let line_height = app
.get_visual_height_index()
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
app.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1)));
app.v_sub_offset = 0;
// line2 starts at visual row 6, sub was 2 → visual row 8
assert_eq!(app.v_offset, 8, "v_offset should map back to visual row 8");
assert_eq!(app.v_sub_offset, 0, "v_sub_offset should be 0 after recalibration");
// Scrolling should work normally with VHI
app.scroll_down_line();
assert_eq!(app.v_offset, 9, "scroll down should advance visual row");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_vhi_rebuild_clamps_sub_to_new_line_height() {
let content = "line0\nline1\nline2\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;
app.content_width = 20;
// Line heights: line0=1, line1=5 (wraps), line2=1
install_vhi(&mut app, &[1, 5, 1]);
// Position at line1 sub-row 4 (last sub-row of 5-row line)
app.v_offset = 5;
app.cursor_line = 1;
app.v_sub_offset = 0;
// Rebase
let (logical_top, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical_top, 1, "visual row 5 → line 1 (rows 1-5)");
assert_eq!(sub, 4, "sub-row 4 within line 1");
app.v_offset = logical_top;
app.v_sub_offset = sub;
// Simulate VHI rebuild with SHRUNK line height (e.g., JSON toggle)
// line1 now only 2 visual rows instead of 5
install_vhi(&mut app, &[1, 2, 1]);
// Recalibration should clamp sub=4 to new height-1=1
let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1));
let sub = app.v_sub_offset;
let first_visual = app.cursor_to_first_visual_row(logical_top);
let line_height = app
.get_visual_height_index()
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
let clamped_sub = sub.min(line_height.saturating_sub(1));
app.v_offset = first_visual.saturating_add(clamped_sub);
app.v_sub_offset = 0;
// line1 starts at visual row 1, clamped sub=1 → visual row 2
assert_eq!(app.v_offset, 2, "v_offset should be clamped to visual row 2 (line1 row 1 of 2)");
assert_eq!(app.v_sub_offset, 0);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_loading_to_ready_rebases_visual_offset() {
let content = "line0\nline1\nline2\nline3\nline4\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: 2 rows per line
install_vhi(&mut app, &[2usize; 5]);
// Scroll to visual row 3 → line1 sub-row 1
app.v_offset = 3;
app.cursor_line = 1;
app.v_sub_offset = 0;
// Simulate Loading→Ready invalidation rebase
let (new_offset, new_sub) = app.rebase_offset_for_invalidate();
assert_eq!(new_offset, 1, "visual row 3 → line 1");
assert_eq!(new_sub, 1, "sub-row 1 within line 1");
// Apply rebased values and clear VHI (simulate invalidate)
app.v_offset = new_offset;
app.v_sub_offset = new_sub;
if let AppLoadingState::Ready { reader } = &mut app.loading_state {
reader.invalidate_visual_height_index();
}
// VHI is now None → scroll_down_line uses else branch (v_sub_offset path)
// "line1" at width=80 → compute_visual_height=1, sub=1+1 >= 1 → advance
app.scroll_down_line();
assert_eq!(app.v_offset, 2, "should advance to line 2 (line1 height=1, sub overflow)");
assert_eq!(app.v_sub_offset, 0);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_compute_line_entry_oversized_raw_guard() {
let long_line = "x".repeat(MAX_WRAP_INPUT_LEN + 1);
let path = make_temp_file(&format!("short\n{}\n", long_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert!(app.is_loaded());
let entry = app.compute_line_entry(1, 80);
assert_eq!(entry.visual_height, 1, "oversized line should have height 1");
assert!(entry.level.is_none(), "oversized raw line should skip detect_level");
assert_eq!(entry.wrapped_rows.len(), 1);
assert!(
entry.wrapped_rows[0].len() <= 80,
"preview should be bounded to width"
);
let short_entry = app.compute_line_entry(0, 80);
assert_eq!(short_entry.visual_height, 1);
assert!(short_entry.level.is_none(), "'short' has no log level");
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_compute_visual_height_oversized_raw_guard() {
let long_line = "a".repeat(MAX_WRAP_INPUT_LEN + 100);
let path = make_temp_file(&format!("hi\n{}\n", long_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert!(app.is_loaded());
assert_eq!(app.compute_visual_height(0, 80), 1, "normal line height=1");
assert_eq!(app.compute_visual_height(1, 80), 1, "oversized line height=1");
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_compute_line_entry_post_format_guard() {
// Build a JSON object whose raw form is just under MAX_WRAP_INPUT_LEN
// but whose pretty-printed form (with added spaces around ':') exceeds it.
let inner = "x".repeat(MAX_WRAP_INPUT_LEN - 11);
let json_line = format!(r#"{{"msg":"{}"}}"#, inner);
assert!(
json_line.len() < MAX_WRAP_INPUT_LEN,
"raw JSON should be under limit: got {}",
json_line.len()
);
let path = make_temp_file(&format!("{}\n", json_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
app.json_format = true;
let entry = app.compute_line_entry(0, 80);
assert_eq!(entry.visual_height, 1, "post-format oversized should have height 1");
assert_eq!(entry.wrapped_rows.len(), 1);
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_truncate_to_columns_basic() {
assert_eq!(truncate_to_columns("hello world", 5), "hello");
assert_eq!(truncate_to_columns("hi", 80), "hi");
assert_eq!(truncate_to_columns("", 80), "");
assert_eq!(truncate_to_columns("abc", 0), "");
}
#[test]
fn test_truncate_to_columns_cjk() {
// Each CJK char is 2 columns wide
assert_eq!(truncate_to_columns("你好世界", 3), "");
assert_eq!(truncate_to_columns("你好世界", 4), "你好");
}
#[test]
fn test_truncate_to_columns_tab() {
assert_eq!(truncate_to_columns("a\tb", 3), "a");
}
#[test]
fn test_truncate_tab_fits_fully() {
assert_eq!(truncate_to_columns("a\tb", 5), "a b");
}
#[test]
fn test_truncate_tab_exact_boundary() {
assert_eq!(truncate_to_columns("a\tb", 4), "a ");
}
#[test]
fn test_truncate_cjk_at_boundary() {
assert_eq!(truncate_to_columns("你好", 3), "");
assert_eq!(truncate_to_columns("你好", 4), "你好");
}
#[test]
fn test_truncate_control_char() {
assert_eq!(truncate_to_columns("a\x07b", 5), "a\x07b");
}
#[test]
fn test_truncate_exact_width() {
assert_eq!(truncate_to_columns("abcde", 5), "abcde");
assert_eq!(truncate_to_columns("abcdef", 5), "abcde");
}
#[test]
fn test_truncate_only_tab() {
assert_eq!(truncate_to_columns("\t", 4), " ");
assert_eq!(truncate_to_columns("\t", 3), "");
}
}