diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 0ea62c8..c1dcc5f 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -6,7 +6,7 @@ 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::io::wrap::{MAX_WRAP_INPUT_LEN, format_json_line, wrap_line_chars}; use log_viewer_core::types::LogLevel; use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher}; use unicode_width::UnicodeWidthChar; @@ -70,6 +70,7 @@ impl ViewportCache { self.width != width || self.json_format != json_format } + #[allow(dead_code)] pub(crate) fn get_entry(&self, logical_line: usize) -> Option<&ViewportEntry> { if logical_line >= self.logical_start { let idx = logical_line - self.logical_start; @@ -93,6 +94,9 @@ pub struct App { pub(crate) cursor_line: usize, pub(crate) v_offset: usize, pub(crate) v_sub_offset: usize, + // Cursor sub-row is intentionally separate from v_sub_offset: v_sub_offset + // tracks the viewport-top row during VHI invalidation/rebuild rebasing. + pub(crate) cursor_sub_offset: usize, // Viewport cache (on-demand, viewport-sized) pub(crate) viewport_cache: ViewportCache, @@ -134,6 +138,7 @@ impl App { cursor_line: 0, v_offset: 0, v_sub_offset: 0, + cursor_sub_offset: 0, viewport_cache: ViewportCache::new(), content_width: 0, content_height: 0, @@ -153,26 +158,22 @@ impl App { // ── Phase 1: Pure computation, no mutation of self ────────── // If any step fails and returns ?, self remains completely untouched, // preserving the old file's watcher, loading_state, and file_path. - let mut pfr = ProgressiveFileReader::open(Path::new(path)) - .map_err(|e| anyhow::anyhow!("{e}"))?; + let mut pfr = + ProgressiveFileReader::open(Path::new(path)).map_err(|e| anyhow::anyhow!("{e}"))?; let new_loading_state = 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, - ); + 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}"))?; + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; let estimated = pfr.line_count() as u64; AppLoadingState::Loading { @@ -199,6 +200,7 @@ impl App { self.cursor_line = 0; self.v_offset = 0; self.v_sub_offset = 0; + self.cursor_sub_offset = 0; self.viewport_cache.invalidate(); self.last_g_press = None; self.json_format = false; @@ -224,10 +226,10 @@ impl App { } let level = log_viewer_core::parser::level::detect_level(&raw); - let display_text = if self.json_format { + let display_text: std::borrow::Cow<'_, str> = if self.json_format { format_json_line(&raw) } else { - raw + std::borrow::Cow::Borrowed(raw.as_str()) }; // Guard 2: JSON pretty-printing may expand a line beyond the limit. @@ -260,13 +262,12 @@ impl App { return 1; } - let display_text = if self.json_format { + let display_text: std::borrow::Cow<'_, str> = if self.json_format { format_json_line(&raw) } else { - raw + std::borrow::Cow::Borrowed(raw.as_str()) }; - // Guard 2: post-format expansion. if display_text.len() > MAX_WRAP_INPUT_LEN { return 1; } @@ -286,6 +287,123 @@ impl App { (visual_row.min(self.total_lines().saturating_sub(1)), 0) } + fn line_visual_height(&self, line: usize, width: usize) -> usize { + if line >= self.total_lines() { + return 1; + } + self.compute_visual_height(line, width).max(1) + } + + fn advance_visual_pos( + &self, + mut line: usize, + mut sub: usize, + mut n: usize, + width: usize, + ) -> (usize, usize) { + let last = self.total_lines().saturating_sub(1); + while n > 0 && line < last { + let h = self.line_visual_height(line, width); + let remaining_in_line = h.saturating_sub(sub); + if n < remaining_in_line { + sub += n; + return (line, sub); + } + n -= remaining_in_line; + line += 1; + sub = 0; + } + if line >= last { + let h = self.line_visual_height(last, width); + sub = (sub + n).min(h.saturating_sub(1)); + line = last; + } + (line, sub) + } + + fn retreat_visual_pos( + &self, + mut line: usize, + mut sub: usize, + mut n: usize, + width: usize, + ) -> (usize, usize) { + while n > 0 { + if sub >= n { + sub -= n; + return (line, sub); + } + n -= sub + 1; + if line == 0 { + return (0, 0); + } + line -= 1; + sub = self.line_visual_height(line, width).saturating_sub(1); + } + (line, sub) + } + + fn clamp_cursor_sub_offset_no_vhi(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let width = self.get_content_width(); + if width == 0 { + return; + } + let h = self.line_visual_height(self.cursor_line, width); + if self.cursor_sub_offset >= h { + self.cursor_sub_offset = h.saturating_sub(1); + } + } + + fn ensure_cursor_visible_no_vhi(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 || self.content_height == 0 { + return; + } + let width = self.get_content_width(); + if width == 0 { + return; + } + let content_h = self.content_height as usize; + let cur_line = self.cursor_line; + let cur_sub = self.cursor_sub_offset; + let v_top_line = self.v_offset; + let v_top_sub = self.v_sub_offset; + + if cur_line < v_top_line || (cur_line == v_top_line && cur_sub < v_top_sub) { + self.v_offset = cur_line; + self.v_sub_offset = cur_sub; + return; + } + + let mut line = v_top_line; + let mut sub = v_top_sub; + let mut walked = 0usize; + let total = self.total_lines(); + while walked < content_h { + if line == cur_line && sub == cur_sub { + return; + } + let h = self.line_visual_height(line, width); + if sub + 1 < h { + sub += 1; + } else if line + 1 < total { + line += 1; + sub = 0; + } else { + return; + } + walked += 1; + } + + let target_distance = content_h.saturating_sub(1); + let (new_line, new_sub) = + self.retreat_visual_pos(cur_line, cur_sub, target_distance, width); + self.v_offset = new_line; + self.v_sub_offset = new_sub; + } + /// 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) { @@ -303,15 +421,19 @@ impl App { 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(); + if self.get_visual_height_index().is_some() { + self.ensure_cursor_visible(); + } else { + self.ensure_cursor_visible_no_vhi(); + } } // 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) + ( + 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) }; @@ -332,37 +454,9 @@ impl App { 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 { @@ -382,40 +476,24 @@ impl App { 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; - } + if let Some(index) = self.get_visual_height_index() { + let last = self.total_lines().saturating_sub(1); + let current_height = index.visual_height_of_line(self.cursor_line); + if self.cursor_sub_offset + 1 < current_height { + self.cursor_sub_offset += 1; + } else if self.cursor_line < last { + self.cursor_line += 1; + self.cursor_sub_offset = 0; } + self.ensure_cursor_visible(); } 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; + let (new_line, new_sub) = + self.advance_visual_pos(self.cursor_line, self.cursor_sub_offset, 1, width); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible_no_vhi(); } } } @@ -425,32 +503,26 @@ impl App { 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); + if let Some(index) = self.get_visual_height_index() { + if self.cursor_sub_offset > 0 { + self.cursor_sub_offset -= 1; + } else if self.cursor_line > 0 { + let previous_line = self.cursor_line - 1; + let previous_line_last_sub = + index.visual_height_of_line(previous_line).saturating_sub(1); + self.cursor_line = previous_line; + self.cursor_sub_offset = previous_line_last_sub; } + self.ensure_cursor_visible(); } 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 - }; + let width = self.get_content_width(); + if width > 0 { + let (new_line, new_sub) = + self.retreat_visual_pos(self.cursor_line, self.cursor_sub_offset, 1, width); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible_no_vhi(); } - self.cursor_line = self.v_offset; } } @@ -459,12 +531,27 @@ impl App { 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(); + if let Some(index) = self.get_visual_height_index() { + let total_vr = index.total_visual_rows(); + if total_vr == 0 { + return; + } + let cur_vr = self.cursor_visual_row(); + let target_vr = cur_vr.saturating_add(half as u64).min(total_vr - 1); + let (new_line, new_sub) = index.visual_row_to_logical_row_with_offset(target_vr); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible(); + } else { + let width = self.get_content_width(); + if width > 0 && half > 0 { + let (new_line, new_sub) = + self.advance_visual_pos(self.cursor_line, self.cursor_sub_offset, half, width); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible_no_vhi(); + } + } } pub fn scroll_up_half_page(&mut self) { @@ -472,12 +559,27 @@ impl App { 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(); + if let Some(index) = self.get_visual_height_index() { + let total_vr = index.total_visual_rows(); + if total_vr == 0 { + return; + } + let cur_vr = self.cursor_visual_row(); + let target_vr = cur_vr.saturating_sub(half as u64); + let (new_line, new_sub) = index.visual_row_to_logical_row_with_offset(target_vr); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible(); + } else { + let width = self.get_content_width(); + if width > 0 && half > 0 { + let (new_line, new_sub) = + self.retreat_visual_pos(self.cursor_line, self.cursor_sub_offset, half, width); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible_no_vhi(); + } + } } pub fn scroll_down_page(&mut self) { @@ -485,12 +587,27 @@ impl App { 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(); + if let Some(index) = self.get_visual_height_index() { + let total_vr = index.total_visual_rows(); + if total_vr == 0 { + return; + } + let cur_vr = self.cursor_visual_row(); + let target_vr = cur_vr.saturating_add(page as u64).min(total_vr - 1); + let (new_line, new_sub) = index.visual_row_to_logical_row_with_offset(target_vr); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible(); + } else { + let width = self.get_content_width(); + if width > 0 && page > 0 { + let (new_line, new_sub) = + self.advance_visual_pos(self.cursor_line, self.cursor_sub_offset, page, width); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible_no_vhi(); + } + } } pub fn scroll_up_page(&mut self) { @@ -498,12 +615,27 @@ impl App { 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(); + if let Some(index) = self.get_visual_height_index() { + let total_vr = index.total_visual_rows(); + if total_vr == 0 { + return; + } + let cur_vr = self.cursor_visual_row(); + let target_vr = cur_vr.saturating_sub(page as u64); + let (new_line, new_sub) = index.visual_row_to_logical_row_with_offset(target_vr); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible(); + } else { + let width = self.get_content_width(); + if width > 0 && page > 0 { + let (new_line, new_sub) = + self.retreat_visual_pos(self.cursor_line, self.cursor_sub_offset, page, width); + self.cursor_line = new_line; + self.cursor_sub_offset = new_sub; + self.ensure_cursor_visible_no_vhi(); + } + } } pub fn scroll_to_top(&mut self) { @@ -513,6 +645,7 @@ impl App { self.cursor_line = 0; self.v_offset = 0; self.v_sub_offset = 0; + self.cursor_sub_offset = 0; } pub fn scroll_to_bottom(&mut self) { @@ -520,34 +653,42 @@ impl App { return; } self.cursor_line = self.total_lines().saturating_sub(1); - self.v_sub_offset = 0; - self.ensure_cursor_visible(); - self.clamp_v_offset(); + if let Some(idx) = self.get_visual_height_index() { + self.cursor_sub_offset = idx + .visual_height_of_line(self.cursor_line) + .saturating_sub(1); + self.v_sub_offset = 0; + self.ensure_cursor_visible(); + self.clamp_v_offset(); + } else { + let width = self.get_content_width(); + self.cursor_sub_offset = if width > 0 { + self.line_visual_height(self.cursor_line, width) + .saturating_sub(1) + } else { + 0 + }; + self.ensure_cursor_visible_no_vhi(); + } } // ── Internal helpers ──────────────────────────────────────────── fn ensure_cursor_visible(&mut self) { - if !self.is_loaded() || self.total_lines() == 0 { + if !self.is_loaded() || self.total_lines() == 0 || self.content_height == 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 cursor_visual = self.cursor_visual_row() as usize; 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); + if cursor_visual < self.v_offset { + self.v_offset = cursor_visual; + } else if cursor_visual >= self.v_offset.saturating_add(content_h) { + self.v_offset = cursor_visual.saturating_sub(content_h).saturating_add(1); } self.clamp_v_offset(); } @@ -569,6 +710,18 @@ impl App { line } + fn cursor_visual_row(&self) -> u64 { + if let Some(index) = self.get_visual_height_index() { + let first = index.cursor_to_first_visual_row(self.cursor_line); + let height = index.visual_height_of_line(self.cursor_line); + let max_sub = height.saturating_sub(1); + let sub = self.cursor_sub_offset.min(max_sub) as u64; + first + sub + } else { + self.cursor_line as u64 + } + } + 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)); @@ -628,16 +781,18 @@ impl App { | KeyCode::Char('b') )) } - AppMode::Settings => plain - && matches!( - key.code, - KeyCode::Char('j') - | KeyCode::Down - | KeyCode::Char('k') - | KeyCode::Up - | KeyCode::Left - | KeyCode::Right - ), + AppMode::Settings => { + plain + && matches!( + key.code, + KeyCode::Char('j') + | KeyCode::Down + | KeyCode::Char('k') + | KeyCode::Up + | KeyCode::Left + | KeyCode::Right + ) + } } } @@ -712,6 +867,7 @@ impl App { } self.v_offset = new_offset; self.v_sub_offset = new_sub; + self.clamp_cursor_sub_offset_no_vhi(); self.last_g_press = None; } KeyCode::Char('s') | KeyCode::Char('S') @@ -743,8 +899,7 @@ impl App { self.mode = AppMode::Normal; } Err(e) => { - self.settings_error = - Some(format!("Failed to save settings: {e}")); + self.settings_error = Some(format!("Failed to save settings: {e}")); } } } @@ -778,8 +933,10 @@ impl App { Some(p) => { if forward { (p + 1) % colors.len() + } else if p == 0 { + colors.len() - 1 } else { - if p == 0 { colors.len() - 1 } else { p - 1 } + p - 1 } } None => { @@ -915,6 +1072,7 @@ impl App { } self.v_offset = new_offset; self.v_sub_offset = new_sub; + self.clamp_cursor_sub_offset_no_vhi(); } } @@ -951,22 +1109,24 @@ impl App { #[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()) - } + 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, } } + /// MUST match the renderer's `gutter_width` formula in `ui.rs::render_content`. + pub(crate) fn gutter_width(&self) -> usize { + gutter_width_for(self.total_lines(), self.is_loading()) + } + fn get_content_width(&self) -> usize { - if self.content_width > 0 { + let total = if self.content_width > 0 { self.content_width as usize } else { 80 - } + }; + total.saturating_sub(gutter_width_for(self.total_lines(), self.is_loading())) } pub fn poll_file_watcher(&mut self) { @@ -996,16 +1156,21 @@ impl App { } 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(); + let width = { + let total = if self.content_width > 0 { + self.content_width as usize + } else { + 80 + }; + total.saturating_sub(gutter_width_for(reader.line_count(), false)) + }; match status { - Ok( - log_viewer_core::io::file_reader::AppendStatus::Appended(_new_lines), - ) => { + Ok(log_viewer_core::io::file_reader::AppendStatus::Appended(_new_lines)) => { let _ = reader.save_cache(); let (old_line_count, can_extend) = { @@ -1035,8 +1200,9 @@ impl App { ); index.replace_last_line_height(new_h); } - let mut new_heights = - Vec::with_capacity(new_line_count.saturating_sub(old_line_count)); + 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( @@ -1051,6 +1217,7 @@ impl App { let (new_offset, new_sub) = rebased; self.v_offset = new_offset; self.v_sub_offset = new_sub; + self.cursor_sub_offset = 0; reader.invalidate_visual_height_index(); reader.start_visual_height_rebuild(width, self.json_format); } @@ -1062,9 +1229,11 @@ impl App { 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.cursor_line = + self.cursor_line.min(self.total_lines().saturating_sub(1)); self.v_offset = new_offset; self.v_sub_offset = 0; + self.cursor_sub_offset = 0; self.viewport_cache.invalidate(); self.clamp_v_offset(); } @@ -1079,16 +1248,24 @@ impl App { } 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 width = { + let total = if self.content_width > 0 { + self.content_width as usize + } else { + 80 + }; + total.saturating_sub(gutter_width_for(reader.line_count(), false)) + }; 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.clamp_cursor_sub_offset_no_vhi(); self.viewport_cache.invalidate(); self.clamp_v_offset(); } @@ -1110,21 +1287,19 @@ impl App { /// 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); - } + // Poll visual height rebuild (Ready state only). + // Recalibrate v_offset only when VHI actually changed. + if let AppLoadingState::Ready { reader } = &mut self.loading_state + && 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); @@ -1133,6 +1308,7 @@ impl App { .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_cursor_sub_offset_no_vhi(); self.clamp_v_offset(); self.viewport_cache.invalidate(); } @@ -1177,6 +1353,7 @@ impl App { self.v_offset = self.cursor_to_first_visual_row(self.cursor_line); self.clamp_v_offset(); self.v_sub_offset = 0; + self.cursor_sub_offset = 0; // Gutter width changes (~N → N) shift content_width, so any // VisualHeightIndex built with the old width is stale. @@ -1186,6 +1363,7 @@ impl App { } self.v_offset = new_offset; self.v_sub_offset = new_sub; + self.cursor_sub_offset = 0; if self.reload_after_loading { self.reload_after_loading = false; @@ -1212,6 +1390,15 @@ impl App { const TRUNCATE_TAB_WIDTH: usize = 4; +fn gutter_width_for(total_lines: usize, is_loading: bool) -> usize { + if total_lines == 0 { + return 0; + } + let line_num_width = total_lines.to_string().len(); + let loading_extra = if is_loading { 1 } else { 0 }; + line_num_width + loading_extra + 1 + 1 +} + fn truncate_to_columns(s: &str, max_cols: usize) -> String { if max_cols == 0 || s.is_empty() { return String::new(); @@ -1399,6 +1586,164 @@ mod tests { assert!(result.is_ok()); } + // ── no-VHI cursor_sub_offset sync (multi-line highlight bug) ──── + + #[test] + fn test_scroll_down_in_no_vhi_advances_cursor_only() { + let long_line = "a".repeat(200); + let path = make_temp_file(&format!("{long_line}\nb\n")); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + assert!(app.is_loading(), "must be in Loading (no-VHI) state"); + assert!(app.get_visual_height_index().is_none()); + app.content_width = 20; + + app.scroll_down_line(); + + assert_eq!(app.cursor_line, 0, "cursor stays on entry 0"); + assert_eq!( + app.cursor_sub_offset, 1, + "cursor_sub_offset advances one visual row" + ); + assert_eq!( + app.v_offset, 0, + "viewport must not move while cursor is visible" + ); + assert_eq!(app.v_sub_offset, 0); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_scroll_up_in_no_vhi_retreats_cursor_only() { + let long_line = "a".repeat(200); + let path = make_temp_file(&format!("{long_line}\nb\n")); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + assert!(app.is_loading()); + app.content_width = 20; + + app.scroll_down_line(); + app.scroll_down_line(); + app.scroll_up_line(); + + assert_eq!(app.cursor_line, 0); + assert_eq!( + app.cursor_sub_offset, 1, + "cursor_sub_offset retreats one visual row" + ); + assert_eq!(app.v_offset, 0); + assert_eq!(app.v_sub_offset, 0); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_half_page_scroll_in_no_vhi_advances_cursor_by_half_page() { + let long_line = "a".repeat(200); + let mut content = String::new(); + for _ in 0..20 { + content.push_str(&long_line); + content.push('\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.content_width = 20; + app.content_height = 10; + + app.cursor_sub_offset = 3; + + app.scroll_down_half_page(); + + assert_eq!( + app.cursor_line, 0, + "cursor stays on entry 0 (5-row half-page stays within line 0's 13 rows)" + ); + assert_eq!( + app.cursor_sub_offset, 8, + "cursor advances by half_page (5) visual rows: 3 + 5 = 8" + ); + assert_eq!(app.v_offset, 0); + assert_eq!(app.v_sub_offset, 0); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + // ── Effective content width (gutter subtraction) ──────────────── + + #[test] + fn test_get_content_width_subtracts_gutter() { + let path = make_temp_file("hello\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + load_file_ready(&mut app, &path); + app.content_width = 20; + + assert_eq!(app.gutter_width(), 3); + assert_eq!( + app.get_content_width(), + 17, + "get_content_width must return area width minus gutter" + ); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_compute_visual_height_uses_effective_width_after_gutter() { + let path = make_temp_file("012345678901234567\nb\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + load_file_ready(&mut app, &path); + app.content_width = 20; + + let effective = app.get_content_width(); + assert_eq!(effective, 17); + let height = app.compute_visual_height(0, effective); + assert_eq!( + height, 2, + "18-char line must wrap to 2 rows at effective width 17" + ); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_scroll_down_in_no_vhi_increments_via_effective_width() { + let path = make_temp_file("012345678901234567\nb\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + assert!(app.is_loading()); + app.content_width = 20; + + app.scroll_down_line(); + + assert_eq!( + app.cursor_sub_offset, 1, + "cursor_sub_offset must increment when the renderer wraps the line" + ); + assert_eq!( + app.cursor_line, 0, + "cursor must stay on entry 0, not jump to entry 1" + ); + assert_eq!(app.v_offset, 0); + assert_eq!(app.v_sub_offset, 0); + }); + cleanup(&path); + assert!(result.is_ok()); + } + #[test] fn test_viewport_cache_correctness() { let long_line = "a".repeat(200); @@ -1489,7 +1834,10 @@ mod tests { 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"); + 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"); @@ -1743,10 +2091,7 @@ plain text line app.ensure_viewport_cache(80); let height_0 = app.compute_visual_height(0, 80); - assert!( - height_0 > 1, - "JSON lines should expand when formatted" - ); + assert!(height_0 > 1, "JSON lines should expand when formatted"); let cursor_visual_after = app.cursor_to_first_visual_row(50); assert!( @@ -2160,10 +2505,7 @@ plain text line fn app_in_loading_state( file_path: &std::path::Path, generation: u64, - ) -> ( - App, - crossbeam_channel::Sender, - ) { + ) -> (App, crossbeam_channel::Sender) { if let Some(cp) = log_viewer_core::io::cache_util::cache_path(file_path) { let _ = std::fs::remove_file(cp); } @@ -2171,13 +2513,8 @@ plain text line 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 reader = + ProgressiveFileReader::with_channels(file_path, cancel_tx, rx, generation).unwrap(); let estimated = reader.line_count() as u64; let mut app = App::new(); @@ -2196,9 +2533,7 @@ plain text line #[test] fn test_seamless_transition_preserves_position() { - let content: String = (0..100) - .map(|i| format!("line {}\n", i)) - .collect(); + 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); @@ -2211,7 +2546,8 @@ plain text line generation: 42, reader: fr, visual_height_index: None, - }).unwrap(); + }) + .unwrap(); app.poll_background_indexer(); @@ -2225,12 +2561,8 @@ plain text line #[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_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(|| { @@ -2244,7 +2576,8 @@ plain text line generation: 42, reader: fr, visual_height_index: None, - }).unwrap(); + }) + .unwrap(); app.poll_background_indexer(); @@ -2260,9 +2593,7 @@ plain text line #[test] fn test_seamless_transition_v_offset_converted() { - let content: String = (0..50) - .map(|i| format!("line {}\n", i)) - .collect(); + 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); @@ -2275,13 +2606,17 @@ plain text line generation: 7, reader: fr, visual_height_index: None, - }).unwrap(); + }) + .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)"); + assert_eq!( + app.v_offset, 30, + "v_offset = cursor_to_first_visual_row(30)" + ); }); cleanup(&path); assert!(result.is_ok()); @@ -2289,9 +2624,7 @@ plain text line #[test] fn test_seamless_transition_invalidates_visual_height_index() { - let content: String = (0..30) - .map(|i| format!("line {}\n", i)) - .collect(); + 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); @@ -2303,7 +2636,8 @@ plain text line generation: 1, reader: fr, visual_height_index: Some(vhi), - }).unwrap(); + }) + .unwrap(); app.poll_background_indexer(); @@ -2411,7 +2745,8 @@ plain text line app.poll_file_watcher(); assert_eq!( - app.total_lines(), 4, + app.total_lines(), + 4, "total_lines should increase after append" ); assert_eq!(app.get_line(2), Some("line3".to_string())); @@ -2442,7 +2777,8 @@ plain text line app.poll_file_watcher(); assert_eq!( - app.total_lines(), 0, + app.total_lines(), + 0, "total_lines should be 0 after truncate" ); // Cursor should be clamped @@ -2467,7 +2803,8 @@ plain text line app.poll_file_watcher(); assert_eq!( - app.total_lines(), lines_before, + app.total_lines(), + lines_before, "no events should mean no change" ); }); @@ -2497,7 +2834,10 @@ plain text line 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!( + 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)" @@ -2509,7 +2849,8 @@ plain text line #[test] fn test_loading_json_expanded_visual_height() { - let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; + 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(|| { @@ -2530,7 +2871,8 @@ plain text line #[test] fn test_loading_json_viewport_contains_cursor() { - let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; + let json_line = + r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; let mut lines: Vec = (0..50).map(|i| format!("line{i}")).collect(); lines.push(json_line.to_string()); lines.push("end".to_string()); @@ -2553,7 +2895,9 @@ plain text line assert!( app.cursor_line >= first && app.cursor_line <= last_logical, "cursor_line {} should be within viewport range [{}, {}]", - app.cursor_line, first, last_logical + app.cursor_line, + first, + last_logical ); }); cleanup(&path); @@ -2604,7 +2948,8 @@ plain text line assert!( app.cursor_line < total, "cursor_line {} should be < total_lines {}", - app.cursor_line, total + app.cursor_line, + total ); }); cleanup(&path); @@ -2639,7 +2984,8 @@ plain text line #[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 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(|| { @@ -2666,7 +3012,11 @@ plain text line "json_format should remain true after Loading→Ready transition" ); - let has_expanded = app.viewport_cache.entries.iter().any(|e| e.visual_height > 1); + 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)" @@ -2752,7 +3102,12 @@ plain text line "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(); + 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 ({})", @@ -2791,7 +3146,8 @@ plain text line ); assert_eq!( - app.viewport_cache.logical_start, recomputed_offset.min(app.total_lines().saturating_sub(1)), + app.viewport_cache.logical_start, + recomputed_offset.min(app.total_lines().saturating_sub(1)), "logical_start should match the updated v_offset" ); }); @@ -2828,14 +3184,22 @@ plain text line assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); + assert_eq!(app.cursor_sub_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"); + assert_eq!(app.cursor_line, 1, "cursor should be on logical line 1"); + assert_eq!( + app.cursor_sub_offset, 2, + "cursor should be on the last visual row of logical line 1" + ); + assert!(app.v_offset <= 5, "v_offset should keep cursor visible"); + assert!( + app.v_offset + 10 > 5, + "cursor visual row 5 must be within viewport" + ); cleanup(&path); }); assert!(result.is_ok()); @@ -2855,14 +3219,16 @@ plain text line // Start at v_offset=5, cursor_line=3 (matching center) app.v_offset = 5; app.cursor_line = 3; + app.cursor_sub_offset = 0; 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); + assert_eq!(app.cursor_line, 1, "cursor should walk back to line 1"); + assert_eq!(app.cursor_sub_offset, 1, "cursor should land on sub-row 1"); + assert!(app.v_offset <= 4, "v_offset should keep cursor visible"); + assert!(app.v_offset + 10 > 4, "visual row 4 must be visible"); cleanup(&path); }); assert!(result.is_ok()); @@ -2882,10 +3248,15 @@ plain text line assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); + assert_eq!(app.cursor_sub_offset, 0); app.scroll_down_line(); assert_eq!(app.cursor_line, 1, "cursor should move to line 1 (logical)"); + assert_eq!( + app.cursor_sub_offset, 0, + "single-row entries should keep cursor sub-row at 0" + ); assert_eq!(app.v_offset, 0, "v_offset should stay 0 (content fits)"); cleanup(&path); }); @@ -2905,30 +3276,184 @@ plain text line install_vhi(&mut app, &[2usize; 20]); let initial_cursor = app.cursor_line; + let initial_cursor_sub = app.cursor_sub_offset; let initial_offset = app.v_offset; for _ in 0..15 { app.scroll_down_line(); } + assert_eq!(app.cursor_line, 7, "15 visual rows through 2-row entries"); + assert_eq!(app.cursor_sub_offset, 1, "15th row is line7 sub-row1"); 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 + assert_eq!( + app.v_offset, initial_offset, + "v_offset should roundtrip to {}", + initial_offset + ); + assert_eq!( + app.cursor_line, initial_cursor, + "cursor line should roundtrip" + ); + assert_eq!( + app.cursor_sub_offset, initial_cursor_sub, + "cursor sub-row should roundtrip" ); 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 { + #[test] + fn test_vhi_scroll_down_line_walks_cursor_sub_offset() { + 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 = 5; + install_vhi(&mut app, &[3usize; 3]); + + app.scroll_down_line(); + assert_eq!((app.cursor_line, app.cursor_sub_offset), (0, 1)); + + app.scroll_down_line(); + assert_eq!((app.cursor_line, app.cursor_sub_offset), (0, 2)); + + app.scroll_down_line(); + assert_eq!((app.cursor_line, app.cursor_sub_offset), (1, 0)); + + cleanup(&path); + }); + assert!(result.is_ok()); + } + + #[test] + fn test_vhi_scroll_up_line_walks_cursor_sub_offset() { + 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 = 5; + install_vhi(&mut app, &[3usize; 3]); + app.cursor_line = 1; + app.cursor_sub_offset = 0; + + app.scroll_up_line(); + assert_eq!((app.cursor_line, app.cursor_sub_offset), (0, 2)); + + app.scroll_up_line(); + assert_eq!((app.cursor_line, app.cursor_sub_offset), (0, 1)); + + app.scroll_up_line(); + assert_eq!((app.cursor_line, app.cursor_sub_offset), (0, 0)); + + cleanup(&path); + }); + assert!(result.is_ok()); + } + + #[test] + fn test_vhi_scroll_down_half_page_uses_visual_rows() { + let content = "line0\nline1\nline2\nline3\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 = 4; + install_vhi(&mut app, &[3usize; 4]); + + app.scroll_down_half_page(); + + assert_eq!(app.content_height as usize / 2, 2); + assert_eq!((app.cursor_line, app.cursor_sub_offset), (0, 2)); + cleanup(&path); + }); + assert!(result.is_ok()); + } + + #[test] + fn test_vhi_scroll_down_page_uses_visual_rows() { + let content = "line0\nline1\nline2\nline3\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 = 4; + install_vhi(&mut app, &[3usize; 4]); + + app.scroll_down_page(); + + assert_eq!((app.cursor_line, app.cursor_sub_offset), (1, 1)); + cleanup(&path); + }); + assert!(result.is_ok()); + } + + #[test] + fn test_tab_toggle_resets_cursor_sub_offset() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + 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 = 5; + app.content_width = 20; + install_vhi(&mut app, &[3usize; 3]); + app.cursor_line = 1; + app.cursor_sub_offset = 2; + + app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!( + app.cursor_line, 1, + "Tab should preserve logical cursor line" + ); + assert_eq!( + app.cursor_sub_offset, 0, + "Tab invalidates visual layout, so cursor sub-row must reset" + ); + cleanup(&path); + }); + assert!(result.is_ok()); + } + + #[test] + fn test_vhi_scroll_down_line_clamps_at_last_visual_row() { + let content = "line0\nline1\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 = 2; + install_vhi(&mut app, &[2usize; 2]); + + for _ in 0..10 { + app.scroll_down_line(); + } + + assert_eq!( + (app.cursor_line, app.cursor_sub_offset), + (1, 1), + "cursor should clamp on final visual row of final entry" + ); + 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::>() @@ -2950,20 +3475,42 @@ plain text line return; } + assert_eq!(app.cursor_line, 0); + assert_eq!(app.cursor_sub_offset, 0); + assert_eq!(app.v_offset, 0); + assert_eq!(app.v_sub_offset, 0); + 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"); + assert_eq!( + app.cursor_line, 0, + "cursor_line stays on entry 0 after first j" + ); + assert_eq!(app.cursor_sub_offset, 1, "cursor_sub_offset advances to 1"); + assert_eq!( + app.v_offset, 0, + "viewport must not move while cursor is visible" + ); + assert_eq!(app.v_sub_offset, 0); 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"); + assert_eq!(app.cursor_line, 0); + assert_eq!(app.cursor_sub_offset, 2); + assert_eq!(app.v_offset, 0); + assert_eq!(app.v_sub_offset, 0); 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"); + assert_eq!( + app.cursor_line, 1, + "cursor_line advances to 1 after wrapping past row 2" + ); + assert_eq!(app.cursor_sub_offset, 0); + assert_eq!( + app.v_offset, 0, + "viewport still must not move (cursor row 3 < 24)" + ); cleanup(&path); })); @@ -2982,19 +3529,23 @@ plain text line 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); + for _ in 0..3 { + app.scroll_down_line(); + } + assert_eq!(app.cursor_line, 1); + assert_eq!(app.cursor_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"); + assert_eq!(app.cursor_line, 0, "cursor_line should go back to 0"); + assert_eq!( + app.cursor_sub_offset, 2, + "cursor_sub_offset should be at end of line 0" + ); - let prev_sub = app.v_sub_offset; + let prev_sub = app.cursor_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); + assert_eq!(app.cursor_line, 0, "cursor_line should stay 0"); + assert_eq!(app.cursor_sub_offset, prev_sub - 1); cleanup(&path); })); @@ -3013,12 +3564,20 @@ plain text line return; } - for _ in 0..5 { app.scroll_down_line(); } - assert!(app.v_offset > 0 || app.v_sub_offset > 0); + for _ in 0..5 { + app.scroll_down_line(); + } + assert!(app.cursor_line > 0 || app.cursor_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"); + assert_eq!( + app.cursor_line, 0, + "cursor_line should be 0 after scroll_to_top" + ); + assert_eq!( + app.cursor_sub_offset, 0, + "cursor_sub_offset should be 0 after scroll_to_top" + ); cleanup(&path); })); @@ -3029,9 +3588,7 @@ plain text line #[test] fn test_append_during_loading_sets_reload_flag() { - let content: String = (0..50) - .map(|i| format!("line {}\n", i)) - .collect(); + 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); @@ -3040,8 +3597,10 @@ plain text line app.handle_file_appended(); - assert!(app.reload_after_loading, - "append during Loading should set reload_after_loading flag"); + assert!( + app.reload_after_loading, + "append during Loading should set reload_after_loading flag" + ); })); cleanup(&path); assert!(result.is_ok()); @@ -3049,9 +3608,7 @@ plain text line #[test] fn test_truncate_during_loading_sets_reload_flag() { - let content: String = (0..50) - .map(|i| format!("line {}\n", i)) - .collect(); + 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); @@ -3059,8 +3616,10 @@ plain text line app.handle_file_truncated(); - assert!(app.reload_after_loading, - "truncate during Loading should set reload_after_loading flag"); + assert!( + app.reload_after_loading, + "truncate during Loading should set reload_after_loading flag" + ); })); cleanup(&path); assert!(result.is_ok()); @@ -3068,9 +3627,7 @@ plain text line #[test] fn test_multiple_events_during_loading_collapse_to_single_reload() { - let content: String = (0..50) - .map(|i| format!("line {}\n", i)) - .collect(); + 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); @@ -3081,23 +3638,26 @@ plain text line assert!(app.reload_after_loading); - let updated_content: String = (0..60) - .map(|i| format!("updated line {}\n", i)) - .collect(); + 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(); + }) + .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"); + assert_eq!( + app.total_lines(), + 60, + "should show reloaded content, not stale indexer result" + ); })); cleanup(&path); assert!(result.is_ok()); @@ -3105,9 +3665,7 @@ plain text line #[test] fn test_indexer_error_clears_reload_flag() { - let content: String = (0..10) - .map(|i| format!("line {}\n", i)) - .collect(); + 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); @@ -3118,13 +3676,16 @@ plain text line tx.send(IndexerMessage::Error { generation: 1, message: "test error".into(), - }).unwrap(); + }) + .unwrap(); app.poll_background_indexer(); assert!(matches!(app.loading_state, AppLoadingState::Error(_))); - assert!(!app.reload_after_loading, - "flag should be cleared on indexer error"); + assert!( + !app.reload_after_loading, + "flag should be cleared on indexer error" + ); })); cleanup(&path); assert!(result.is_ok()); @@ -3138,7 +3699,7 @@ plain text line load_file_ready(&mut app, &path); assert_eq!(app.total_lines(), 1); - app.content_width = 5; + app.content_width = 8; install_vhi(&mut app, &[1usize]); { @@ -3164,12 +3725,20 @@ plain text line 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"); + 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"); + 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); @@ -3191,7 +3760,11 @@ plain text line 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); + 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"); } @@ -3204,7 +3777,11 @@ plain text line 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); + 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); @@ -3216,7 +3793,8 @@ plain text line 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); + 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"); } @@ -3229,7 +3807,8 @@ plain text line 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); + 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); @@ -3248,7 +3827,9 @@ plain text line install_vhi(&mut app, &[1usize; 10]); let repeat_ctrl_d = make_key_with_kind( - KeyCode::Char('d'), KeyModifiers::CONTROL, KeyEventKind::Repeat, + 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"); @@ -3265,9 +3846,8 @@ plain text line 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, - ); + 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); @@ -3283,10 +3863,14 @@ plain text line 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); + 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"); + assert!( + app.last_g_press.is_none(), + "Repeat+g must not set last_g_press" + ); cleanup(&path); }); assert!(result.is_ok()); @@ -3299,10 +3883,15 @@ plain text line enter_settings(&mut app); assert_eq!(app.mode, AppMode::Settings); - let repeat_right = make_key_with_kind(KeyCode::Right, KeyModifiers::NONE, KeyEventKind::Repeat); + 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"); + assert_eq!( + app.mode, + AppMode::Normal, + "Repeat Right in Settings should work then Enter closes" + ); } #[test] @@ -3311,9 +3900,14 @@ plain text line let mut app = App::new(); enter_settings(&mut app); - let repeat_enter = make_key_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat); + 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"); + assert_eq!( + app.mode, + AppMode::Settings, + "Repeat+Enter must NOT close settings" + ); } #[test] @@ -3324,7 +3918,7 @@ plain text line load_file_ready(&mut app, &path); assert_eq!(app.total_lines(), 1); - app.content_width = 5; + app.content_width = 8; install_vhi(&mut app, &[1usize]); // Append without adding any new line — just extends line 0 @@ -3341,12 +3935,16 @@ plain text line 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"); + 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"); + 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); }); @@ -3436,8 +4034,14 @@ plain text line 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"); + 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. @@ -3445,7 +4049,8 @@ plain text line assert!( app.v_offset < app.total_lines(), "v_offset ({}) must be a valid logical line index < {}", - app.v_offset, app.total_lines() + app.v_offset, + app.total_lines() ); cleanup(&path); @@ -3496,11 +4101,21 @@ plain text line // 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"); + 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"); + assert_eq!(app.cursor_line, 2, "cursor should remain on line 2"); + assert_eq!( + app.cursor_sub_offset, 1, + "cursor should advance one visual sub-row" + ); + assert_eq!( + app.v_offset, 7, + "v_offset moves to 7 so line2 sub-row1 is visible" + ); cleanup(&path); }); @@ -3549,7 +4164,10 @@ plain text line 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_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); @@ -3586,11 +4204,13 @@ plain text line 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 + // VHI is now None → scroll_down_line uses the no-VHI cursor-advance path. 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); + assert_eq!( + app.cursor_line, 2, + "cursor advances to line 2 (line 1 height=1, sub overflow)" + ); + assert_eq!(app.cursor_sub_offset, 0); cleanup(&path); }); @@ -3607,8 +4227,14 @@ plain text line 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.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, @@ -3633,7 +4259,11 @@ plain text line 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"); + assert_eq!( + app.compute_visual_height(1, 80), + 1, + "oversized line height=1" + ); }); cleanup(&path); assert!(result.is_ok()); @@ -3658,7 +4288,10 @@ plain text line 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.visual_height, 1, + "post-format oversized should have height 1" + ); assert_eq!(entry.wrapped_rows.len(), 1); }); cleanup(&path); @@ -3717,4 +4350,4 @@ plain text line assert_eq!(truncate_to_columns("\t", 4), " "); assert_eq!(truncate_to_columns("\t", 3), ""); } -} \ No newline at end of file +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 1f3ba7f..9d58f02 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -6,6 +6,7 @@ use crate::color::level_fg; use log_viewer_core::config::ColorConfig; use log_viewer_core::types::LogLevel; +#[allow(dead_code)] pub(crate) fn build_line_spans( gutter_text: String, content_text: String, @@ -28,6 +29,10 @@ pub(crate) fn build_line_spans( ]) } +fn is_cursor_visual_row(app: &App, logical_line: usize, visual_row: usize) -> bool { + logical_line == app.cursor_line && visual_row == app.cursor_sub_offset +} + pub fn render(frame: &mut ratatui::Frame, app: &mut App) { use ratatui::layout::{Constraint, Layout}; use ratatui::widgets::Paragraph; @@ -123,7 +128,10 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { let name = app.file_name().unwrap_or("unknown"); let total = app.total_lines(); let cursor_display = if total == 0 { 0 } else { app.cursor_line + 1 }; - let status = format!(" {} [{}/{}] | j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit", name, cursor_display, total); + let status = format!( + " {} [{}/{}] | j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit", + name, cursor_display, total + ); frame.render_widget(Paragraph::new(status), outer[2]); return; } else { @@ -142,8 +150,12 @@ pub fn render_settings(frame: &mut ratatui::Frame, app: &mut App, area: ratatui: let popup_w = ((area.width as u32 * 4 / 5).max(40)).min(area.width as u32) as u16; let popup_h = ((area.height as u32 * 4 / 5).max(14)).min(area.height as u32) as u16; - let popup_x = area.x.saturating_add(area.width.saturating_sub(popup_w) / 2); - let popup_y = area.y.saturating_add(area.height.saturating_sub(popup_h) / 2); + let popup_x = area + .x + .saturating_add(area.width.saturating_sub(popup_w) / 2); + let popup_y = area + .y + .saturating_add(area.height.saturating_sub(popup_h) / 2); let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h); let block = Block::new().borders(Borders::ALL).title(" Color Settings "); @@ -221,14 +233,8 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo } else { 0 }; - let is_loading = app.is_loading(); - let gutter_prefix_extra = if is_loading { 1 } else { 0 }; - let gutter_width = if total_lines > 0 { - line_num_width + gutter_prefix_extra + 1 + 1 - } else { - 0 - }; + let gutter_width = app.gutter_width(); let actual_content_width = content_width.saturating_sub(gutter_width); @@ -263,7 +269,7 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo break; } - let is_cursor = logical_line == app.cursor_line; + let is_cursor = is_cursor_visual_row(app, logical_line, visual_row); let level = entry.level.as_ref(); let bg_color = if is_cursor { @@ -375,6 +381,17 @@ mod tests { assert_eq!(line.spans[1].style.bg, Some(Color::Reset)); } + #[test] + fn test_cursor_highlight_predicate_uses_cursor_sub_offset() { + let mut app = App::new(); + app.cursor_line = 4; + app.cursor_sub_offset = 2; + + assert!(!is_cursor_visual_row(&app, 4, 0)); + assert!(!is_cursor_visual_row(&app, 3, 2)); + assert!(is_cursor_visual_row(&app, 4, 2)); + } + fn render_to_buffer(app: &mut App, width: u16, height: u16) -> ratatui::buffer::Buffer { let backend = ratatui::backend::TestBackend::new(width, height); let mut terminal = ratatui::Terminal::new(backend).unwrap(); @@ -481,7 +498,8 @@ mod tests { let result = std::panic::catch_unwind(|| { 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); + let _ = + log_viewer_core::io::index_cache::IndexCache::save_with_hash(&path, &index, &data); let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); @@ -509,14 +527,22 @@ mod tests { // ── Issue #31: Settings popup area offset tests ──────────────── /// Helper: enter settings mode and render to buffer. - fn render_settings_to_buffer(app: &mut App, width: u16, height: u16) -> ratatui::buffer::Buffer { + fn render_settings_to_buffer( + app: &mut App, + width: u16, + height: u16, + ) -> ratatui::buffer::Buffer { app.mode = crate::app::AppMode::Settings; app.settings_draft = app.color_config.clone(); render_to_buffer(app, width, height) } /// Find the top-left corner of the popup border by scanning for '┌'. - fn find_popup_top_left(buf: &ratatui::buffer::Buffer, width: u16, height: u16) -> Option<(u16, u16)> { + fn find_popup_top_left( + buf: &ratatui::buffer::Buffer, + width: u16, + height: u16, + ) -> Option<(u16, u16)> { for row in 0..height { for col in 0..width { if buf.cell((col, row)).unwrap().symbol() == "┌" { @@ -537,8 +563,8 @@ mod tests { let mut app = App::new(); let buf = render_settings_to_buffer(&mut app, 80, 24); - let (_px, py) = find_popup_top_left(&buf, 80, 24) - .expect("popup border '┌' should be rendered"); + let (_px, py) = + find_popup_top_left(&buf, 80, 24).expect("popup border '┌' should be rendered"); // outer[1].y == 1; the popup is centered inside a 22-row area, // so popup_y must be at least 1 (not 0). @@ -556,8 +582,8 @@ mod tests { let mut app = App::new(); let buf = render_settings_to_buffer(&mut app, 80, 24); - let (px, _py) = find_popup_top_left(&buf, 80, 24) - .expect("popup border '┌' should be rendered"); + let (px, _py) = + find_popup_top_left(&buf, 80, 24).expect("popup border '┌' should be rendered"); // popup_w = 80*4/5 = 64, centered: (80-64)/2 = 8 assert_eq!( @@ -573,6 +599,9 @@ mod tests { let mut app = App::new(); let _buf = render_settings_to_buffer(&mut app, 30, 10); })); - assert!(result.is_ok(), "rendering settings in a small frame should not panic"); + assert!( + result.is_ok(), + "rendering settings in a small frame should not panic" + ); } }