From 9854ef13b68c1e7616ea47ed8520d1f58777f1f3 Mon Sep 17 00:00:00 2001 From: dailz Date: Thu, 9 Jul 2026 13:53:36 +0800 Subject: [PATCH] refactor(tui): split remaining app.rs into per-concern submodules Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/tui/src/app/input.rs | 138 +++ crates/tui/src/app/loading.rs | 169 ++++ crates/tui/src/{app.rs => app/mod.rs} | 1138 +------------------------ crates/tui/src/app/scroll.rs | 359 ++++++++ crates/tui/src/app/settings.rs | 98 +++ crates/tui/src/app/viewport.rs | 222 +++++ crates/tui/src/app/watcher.rs | 162 ++++ 7 files changed, 1161 insertions(+), 1125 deletions(-) create mode 100644 crates/tui/src/app/input.rs create mode 100644 crates/tui/src/app/loading.rs rename crates/tui/src/{app.rs => app/mod.rs} (70%) create mode 100644 crates/tui/src/app/scroll.rs create mode 100644 crates/tui/src/app/settings.rs create mode 100644 crates/tui/src/app/viewport.rs create mode 100644 crates/tui/src/app/watcher.rs diff --git a/crates/tui/src/app/input.rs b/crates/tui/src/app/input.rs new file mode 100644 index 0000000..dfe704b --- /dev/null +++ b/crates/tui/src/app/input.rs @@ -0,0 +1,138 @@ +use std::time::Instant; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +use super::{App, AppMode}; + +impl App { + pub fn handle_key(&mut self, key: KeyEvent) { + 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: &KeyEvent) -> bool { + 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: KeyEvent) { + 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.toggle_json_format(); + self.last_g_press = None; + } + KeyCode::Char('s') | KeyCode::Char('S') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.settings_draft = self.color_config.clone(); + self.settings_error = None; + self.mode = AppMode::Settings; + } + _ => { + self.last_g_press = None; + } + } + } +} diff --git a/crates/tui/src/app/loading.rs b/crates/tui/src/app/loading.rs new file mode 100644 index 0000000..a6559d2 --- /dev/null +++ b/crates/tui/src/app/loading.rs @@ -0,0 +1,169 @@ +use std::path::Path; + +use log_viewer_core::io::progressive_reader::{ + IndexerMessage, ProgressiveFileReader, spawn_indexer, +}; +use log_viewer_core::watcher::file_watcher::FileWatcher; + +use super::{App, AppLoadingState}; + +impl App { + pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> { + // ── 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 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); + 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; + AppLoadingState::Loading { + reader: pfr, + estimated_lines: estimated, + progress_percent: 0.0, + } + } else { + // Cache hit: Ready state + AppLoadingState::Ready { reader: pfr } + }; + + let new_watcher = FileWatcher::watch(Path::new(path)).ok(); + + // ── Phase 2: Commit — swap self to new state ─────────────── + // SAFETY: Do NOT add any fallible operations (with ?) below this point. + // The old file_watcher and loading_state are dropped here, cancelling + // any background indexer for the previous file. + self.file_watcher = new_watcher; + self.loading_state = new_loading_state; + self.file_path = Some(path.to_string()); + + // Reset UI state for the new file + 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; + self.mode = super::AppMode::Normal; + self.reload_after_loading = false; + Ok(()) + } + + /// 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). + // 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); + } + + 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_cursor_sub_offset_no_vhi(); + 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; + self.cursor_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; + self.cursor_sub_offset = 0; + + 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; + } + } +} diff --git a/crates/tui/src/app.rs b/crates/tui/src/app/mod.rs similarity index 70% rename from crates/tui/src/app.rs rename to crates/tui/src/app/mod.rs index 1549878..6c37f5c 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app/mod.rs @@ -1,22 +1,24 @@ -use std::path::Path; use std::time::Instant; use log_viewer_core::config::ColorConfig; #[cfg(test)] +use log_viewer_core::io::progressive_reader::IndexerMessage; +use log_viewer_core::io::progressive_reader::ProgressiveFileReader; +#[cfg(test)] use log_viewer_core::io::progressive_reader::VisualHeightIndex; -use log_viewer_core::io::progressive_reader::{ - IndexerMessage, ProgressiveFileReader, compute_line_visual_height, spawn_indexer, -}; -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 crate::color::AVAILABLE_COLORS; +use log_viewer_core::watcher::file_watcher::FileWatcher; +mod input; +mod loading; mod query; +mod scroll; +mod settings; +mod viewport; mod viewport_cache; +mod watcher; -use viewport_cache::{ViewportCache, ViewportEntry, gutter_width_for, truncate_to_columns}; +use viewport_cache::ViewportCache; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AppMode { @@ -116,1127 +118,13 @@ impl App { reload_after_loading: false, } } - - pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> { - // ── 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 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); - 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; - AppLoadingState::Loading { - reader: pfr, - estimated_lines: estimated, - progress_percent: 0.0, - } - } else { - // Cache hit: Ready state - AppLoadingState::Ready { reader: pfr } - }; - - let new_watcher = FileWatcher::watch(Path::new(path)).ok(); - - // ── Phase 2: Commit — swap self to new state ─────────────── - // SAFETY: Do NOT add any fallible operations (with ?) below this point. - // The old file_watcher and loading_state are dropped here, cancelling - // any background indexer for the previous file. - self.file_watcher = new_watcher; - self.loading_state = new_loading_state; - self.file_path = Some(path.to_string()); - - // Reset UI state for the new file - 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; - 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: std::borrow::Cow<'_, str> = if self.json_format { - format_json_line(&raw) - } else { - std::borrow::Cow::Borrowed(raw.as_str()) - }; - - // 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: std::borrow::Cow<'_, str> = if self.json_format { - format_json_line(&raw) - } else { - std::borrow::Cow::Borrowed(raw.as_str()) - }; - - 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) - } - - 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) { - 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.set_json_format(self.json_format); - self.ensure_visual_height_index(width); - - 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, - ) - } 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); - } - - (start_logical, offset_in_line) - } - - /// 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; - } - - 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 { - let width = self.get_content_width(); - if width > 0 && self.total_lines() > 0 { - 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(); - } - } - } - - pub fn scroll_up_line(&mut self) { - if !self.is_loaded() || self.total_lines() == 0 { - return; - } - - 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 { - 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(); - } - } - } - - 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; - 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) { - if !self.is_loaded() || self.total_lines() == 0 { - return; - } - let half = self.content_height as usize / 2; - 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) { - if !self.is_loaded() || self.total_lines() == 0 { - return; - } - let page = self.content_height as usize; - 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) { - if !self.is_loaded() || self.total_lines() == 0 { - return; - } - let page = self.content_height as usize; - 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) { - if !self.is_loaded() || self.total_lines() == 0 { - return; - } - 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) { - if !self.is_loaded() || self.total_lines() == 0 { - return; - } - self.cursor_line = self.total_lines().saturating_sub(1); - 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 || self.content_height == 0 { - return; - } - if self.is_loading() { - return; - } - let cursor_visual = self.cursor_visual_row() as usize; - - let content_h = self.content_height as usize; - - 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(); - } - - 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 - } - - 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)); - } - 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.clamp_cursor_sub_offset_no_vhi(); - 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(), - _ => {} - } - } - - /// 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; - self.clamp_cursor_sub_offset_no_vhi(); - } - } - - pub fn poll_file_watcher(&mut self) { - let events: Vec = 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 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)) => { - 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; - self.cursor_sub_offset = 0; - 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.cursor_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 (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(); - } - } - - 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). - // 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); - } - - 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_cursor_sub_offset_no_vhi(); - 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; - self.cursor_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; - self.cursor_sub_offset = 0; - - 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; - } - } } #[cfg(test)] mod tests { + use super::viewport_cache::truncate_to_columns; use super::*; + use log_viewer_core::io::wrap::{MAX_WRAP_INPUT_LEN, format_json_line}; use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); diff --git a/crates/tui/src/app/scroll.rs b/crates/tui/src/app/scroll.rs new file mode 100644 index 0000000..65f3430 --- /dev/null +++ b/crates/tui/src/app/scroll.rs @@ -0,0 +1,359 @@ +use super::App; + +impl App { + 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) + } + + pub(super) 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); + } + } + + pub(super) 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; + } + + pub fn scroll_down_line(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + + 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 { + let width = self.get_content_width(); + if width > 0 && self.total_lines() > 0 { + 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(); + } + } + } + + pub fn scroll_up_line(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + + 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 { + 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(); + } + } + } + + 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; + 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) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let half = self.content_height as usize / 2; + 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) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let page = self.content_height as usize; + 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) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + let page = self.content_height as usize; + 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) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + 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) { + if !self.is_loaded() || self.total_lines() == 0 { + return; + } + self.cursor_line = self.total_lines().saturating_sub(1); + 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(); + } + } + + pub(super) fn ensure_cursor_visible(&mut self) { + if !self.is_loaded() || self.total_lines() == 0 || self.content_height == 0 { + return; + } + if self.is_loading() { + return; + } + let cursor_visual = self.cursor_visual_row() as usize; + + let content_h = self.content_height as usize; + + 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(); + } + + pub(super) 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); + } + + 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 + } + } +} diff --git a/crates/tui/src/app/settings.rs b/crates/tui/src/app/settings.rs new file mode 100644 index 0000000..36f9185 --- /dev/null +++ b/crates/tui/src/app/settings.rs @@ -0,0 +1,98 @@ +use crossterm::event::{KeyCode, KeyEvent}; + +use crate::color::AVAILABLE_COLORS; + +use super::{App, AppMode}; + +impl App { + pub(super) fn handle_settings_key(&mut self, key: KeyEvent) { + 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(), + _ => {} + } + } +} diff --git a/crates/tui/src/app/viewport.rs b/crates/tui/src/app/viewport.rs new file mode 100644 index 0000000..20ca031 --- /dev/null +++ b/crates/tui/src/app/viewport.rs @@ -0,0 +1,222 @@ +use std::borrow::Cow; + +use log_viewer_core::io::wrap::{MAX_WRAP_INPUT_LEN, format_json_line, wrap_line_chars}; + +use super::viewport_cache::{ViewportEntry, truncate_to_columns}; +use super::{App, AppLoadingState}; + +impl App { + /// Compute a single line's viewport entry (wrapped rows + level + height). + pub(super) 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: Cow<'_, str> = if self.json_format { + format_json_line(&raw) + } else { + Cow::Borrowed(raw.as_str()) + }; + + // 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. + pub(super) 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: Cow<'_, str> = if self.json_format { + format_json_line(&raw) + } else { + Cow::Borrowed(raw.as_str()) + }; + + 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.set_json_format(self.json_format); + self.ensure_visual_height_index(width); + + 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, + ) + } 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); + } + + (start_logical, offset_in_line) + } + + /// Compute total visual rows (cached, lazily evaluated). + /// Returns `total_lines` for sampling mode (1:1 mapping). + pub(super) 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() + } + + pub(super) 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(super) 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)) + } + + /// 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`. + pub(super) 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) + } + } + + pub(super) 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; + self.clamp_cursor_sub_offset_no_vhi(); + } + } + + pub(super) fn toggle_json_format(&mut self) { + 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.clamp_cursor_sub_offset_no_vhi(); + } +} diff --git a/crates/tui/src/app/watcher.rs b/crates/tui/src/app/watcher.rs new file mode 100644 index 0000000..d1c4fb4 --- /dev/null +++ b/crates/tui/src/app/watcher.rs @@ -0,0 +1,162 @@ +use log_viewer_core::io::file_reader::AppendStatus; +use log_viewer_core::io::progressive_reader::{ReaderState, compute_line_visual_height}; +use log_viewer_core::watcher::file_watcher::FileEvent; + +use super::viewport_cache::gutter_width_for; +use super::{App, AppLoadingState}; + +impl App { + pub fn poll_file_watcher(&mut self) { + let events: Vec = 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: _ } => {} + } + } + } + + pub(super) fn handle_file_appended(&mut self) { + 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(AppendStatus::Appended(_new_lines)) => { + let _ = reader.save_cache(); + + let (old_line_count, can_extend) = { + match &reader.state { + 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 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; + self.cursor_sub_offset = 0; + reader.invalidate_visual_height_index(); + reader.start_visual_height_rebuild(width, self.json_format); + } + + self.viewport_cache.invalidate(); + } + Ok(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.cursor_sub_offset = 0; + self.viewport_cache.invalidate(); + self.clamp_v_offset(); + } + Ok(AppendStatus::Unchanged) | Err(_) => {} + } + } + AppLoadingState::Loading { .. } => { + self.reload_after_loading = true; + } + _ => {} + } + } + + pub(super) fn reload_ready_reader(&mut self) { + 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(); + } + } + + pub(super) fn handle_file_truncated(&mut self) { + match &mut self.loading_state { + AppLoadingState::Ready { .. } => { + self.reload_ready_reader(); + } + AppLoadingState::Loading { .. } => { + self.reload_after_loading = true; + } + _ => {} + } + } +}