diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index c1dcc5f..1549878 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2,18 +2,23 @@ use std::path::Path; use std::time::Instant; use log_viewer_core::config::ColorConfig; +#[cfg(test)] +use log_viewer_core::io::progressive_reader::VisualHeightIndex; use log_viewer_core::io::progressive_reader::{ - IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height, - spawn_indexer, + 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 unicode_width::UnicodeWidthChar; use crate::color::AVAILABLE_COLORS; -#[derive(Debug, Clone, PartialEq, Eq)] +mod query; +mod viewport_cache; + +use viewport_cache::{ViewportCache, ViewportEntry, gutter_width_for, truncate_to_columns}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AppMode { Normal, Settings, @@ -32,53 +37,11 @@ pub(crate) enum AppLoadingState { Error(String), } -// ── Viewport cache (on-demand, viewport-sized) ─────────────────── - -pub(crate) struct ViewportEntry { - pub(crate) wrapped_rows: Vec, - pub(crate) level: Option, - pub(crate) visual_height: usize, -} - -pub(crate) struct ViewportCache { - pub(crate) entries: Vec, - pub(crate) logical_start: usize, - pub(crate) width: usize, - json_format: bool, - cached_total_visual_rows: Option, -} - -impl ViewportCache { - pub(crate) fn new() -> Self { - Self { - entries: Vec::new(), - logical_start: 0, - width: 0, - json_format: false, - cached_total_visual_rows: None, - } - } - - pub(crate) fn invalidate(&mut self) { - self.entries.clear(); - self.logical_start = 0; - self.width = 0; - self.cached_total_visual_rows = None; - } - - pub(crate) fn needs_recompute(&self, width: usize, json_format: bool) -> bool { - self.width != width || self.json_format != json_format - } - - #[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; - self.entries.get(idx) - } else { - None - } - } +pub(crate) struct ViewportRenderRow<'a> { + pub(crate) logical_line: usize, + pub(crate) visual_row: usize, + pub(crate) text: &'a str, + pub(crate) level: Option<&'a LogLevel>, } // ── App ────────────────────────────────────────────────────────── @@ -99,7 +62,7 @@ pub struct App { pub(crate) cursor_sub_offset: usize, // Viewport cache (on-demand, viewport-sized) - pub(crate) viewport_cache: ViewportCache, + viewport_cache: ViewportCache, // Viewport #[allow(dead_code)] @@ -418,7 +381,7 @@ impl App { if params_changed { self.viewport_cache.invalidate(); self.viewport_cache.width = width; - self.viewport_cache.json_format = self.json_format; + self.viewport_cache.set_json_format(self.json_format); self.ensure_visual_height_index(width); if self.get_visual_height_index().is_some() { @@ -975,70 +938,6 @@ impl App { } } - // ── Utility methods ───────────────────────────────────────────── - - #[allow(dead_code)] - pub fn get_line(&self, idx: usize) -> Option { - match &self.loading_state { - AppLoadingState::Ready { reader } => reader.get_line(idx), - AppLoadingState::Loading { reader, .. } => reader.get_line(idx), - _ => None, - } - } - - #[allow(dead_code)] - pub fn file_name(&self) -> Option<&str> { - self.file_path - .as_ref() - .and_then(|p| std::path::Path::new(p).file_name().and_then(|n| n.to_str())) - } - - pub fn total_lines(&self) -> usize { - match &self.loading_state { - AppLoadingState::Ready { reader } => reader.line_count(), - AppLoadingState::Loading { - reader, - estimated_lines, - .. - } => { - // Use estimated total lines (not sampled_line_count) so the user can - // scroll freely during indexing. get_line() incrementally scans - // forward on demand, so lines beyond the initial 64KB are still - // accessible. The .max() guards against under-estimates. - (*estimated_lines as usize).max(reader.sampled_line_count()) - } - _ => 0, - } - } - - pub fn is_loaded(&self) -> bool { - matches!( - self.loading_state, - AppLoadingState::Ready { .. } | AppLoadingState::Loading { .. } - ) - } - - pub fn is_loading(&self) -> bool { - matches!(self.loading_state, AppLoadingState::Loading { .. }) - } - - pub fn is_error(&self) -> bool { - matches!(self.loading_state, AppLoadingState::Error(_)) - } - - fn get_visual_height_index(&self) -> Option<&VisualHeightIndex> { - match &self.loading_state { - AppLoadingState::Ready { reader } => match &reader.state { - log_viewer_core::io::progressive_reader::ReaderState::Ready { - visual_height_index, - .. - } => visual_height_index.as_ref(), - _ => None, - }, - _ => None, - } - } - /// Compute the rebased offset pair (logical_line, sub_row) from the /// current visual-row `v_offset`. Returns `(v_offset, v_sub_offset)` /// suitable for the no-VHI fallback scrolling path. @@ -1076,59 +975,6 @@ impl App { } } - pub fn error_message(&self) -> Option<&str> { - match &self.loading_state { - AppLoadingState::Error(msg) => Some(msg), - _ => None, - } - } - - pub fn loading_progress(&self) -> Option { - match &self.loading_state { - AppLoadingState::Loading { - progress_percent, .. - } => Some(*progress_percent), - _ => None, - } - } - - pub fn estimated_lines(&self) -> Option { - match &self.loading_state { - AppLoadingState::Loading { - estimated_lines, .. - } => Some(*estimated_lines), - _ => None, - } - } - - #[cfg(test)] - pub fn set_error_state(&mut self, msg: impl Into) { - self.loading_state = AppLoadingState::Error(msg.into()); - } - - #[allow(dead_code)] - pub fn file_size(&self) -> u64 { - match &self.loading_state { - AppLoadingState::Ready { reader } => reader.reader().map_or(0, |r| r.file_size()), - AppLoadingState::Loading { reader, .. } => reader.reader().map_or(0, |r| r.file_size()), - _ => 0, - } - } - - /// 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 { - 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) { let events: Vec = match &mut self.file_watcher { Some(w) => std::iter::from_fn(|| w.try_recv()).collect(), @@ -1388,52 +1234,6 @@ 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(); - } - - let mut out = String::new(); - let mut col = 0; - - for ch in s.chars() { - if ch == '\t' { - let tab_stop = TRUNCATE_TAB_WIDTH - (col % TRUNCATE_TAB_WIDTH); - if col + tab_stop > max_cols { - break; - } - for _ in 0..tab_stop { - out.push(' '); - } - col += tab_stop; - } else { - let w = if ch.is_control() { - 0 - } else { - ch.width().unwrap_or(0) - }; - if col + w > max_cols { - break; - } - out.push(ch); - col += w; - } - } - - out -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/app/query.rs b/crates/tui/src/app/query.rs new file mode 100644 index 0000000..40cd8c2 --- /dev/null +++ b/crates/tui/src/app/query.rs @@ -0,0 +1,209 @@ +use log_viewer_core::config::ColorConfig; +use log_viewer_core::io::progressive_reader::VisualHeightIndex; + +use super::viewport_cache::gutter_width_for; +use super::{App, AppLoadingState, AppMode, ViewportRenderRow}; + +impl App { + #[allow(dead_code)] + pub fn get_line(&self, idx: usize) -> Option { + match &self.loading_state { + AppLoadingState::Ready { reader } => reader.get_line(idx), + AppLoadingState::Loading { reader, .. } => reader.get_line(idx), + _ => None, + } + } + + #[allow(dead_code)] + pub fn file_name(&self) -> Option<&str> { + self.file_path + .as_ref() + .and_then(|p| std::path::Path::new(p).file_name().and_then(|n| n.to_str())) + } + + pub fn total_lines(&self) -> usize { + match &self.loading_state { + AppLoadingState::Ready { reader } => reader.line_count(), + AppLoadingState::Loading { + reader, + estimated_lines, + .. + } => { + // Use estimated total lines (not sampled_line_count) so the user can + // scroll freely during indexing. get_line() incrementally scans + // forward on demand, so lines beyond the initial 64KB are still + // accessible. The .max() guards against under-estimates. + (*estimated_lines as usize).max(reader.sampled_line_count()) + } + _ => 0, + } + } + + pub fn is_loaded(&self) -> bool { + matches!( + self.loading_state, + AppLoadingState::Ready { .. } | AppLoadingState::Loading { .. } + ) + } + + pub fn is_loading(&self) -> bool { + matches!(self.loading_state, AppLoadingState::Loading { .. }) + } + + pub fn is_error(&self) -> bool { + matches!(self.loading_state, AppLoadingState::Error(_)) + } + + pub(super) fn get_visual_height_index(&self) -> Option<&VisualHeightIndex> { + match &self.loading_state { + AppLoadingState::Ready { reader } => match &reader.state { + log_viewer_core::io::progressive_reader::ReaderState::Ready { + visual_height_index, + .. + } => visual_height_index.as_ref(), + _ => None, + }, + _ => None, + } + } + + pub fn error_message(&self) -> Option<&str> { + match &self.loading_state { + AppLoadingState::Error(msg) => Some(msg), + _ => None, + } + } + + pub fn loading_progress(&self) -> Option { + match &self.loading_state { + AppLoadingState::Loading { + progress_percent, .. + } => Some(*progress_percent), + _ => None, + } + } + + pub fn estimated_lines(&self) -> Option { + match &self.loading_state { + AppLoadingState::Loading { + estimated_lines, .. + } => Some(*estimated_lines), + _ => None, + } + } + + pub(crate) fn mode(&self) -> AppMode { + self.mode + } + + pub(crate) fn settings_error(&self) -> Option<&str> { + self.settings_error.as_deref() + } + + pub(crate) fn settings_draft(&self) -> &ColorConfig { + &self.settings_draft + } + + pub(crate) fn settings_cursor(&self) -> usize { + self.settings_cursor + } + + pub(crate) fn color_config(&self) -> &ColorConfig { + &self.color_config + } + + pub(crate) fn cursor_line(&self) -> usize { + self.cursor_line + } + + pub(crate) fn cursor_sub_offset(&self) -> usize { + self.cursor_sub_offset + } + + #[allow(dead_code)] + pub(crate) fn content_width(&self) -> u16 { + self.content_width + } + + #[allow(dead_code)] + pub(crate) fn content_height(&self) -> u16 { + self.content_height + } + + pub(crate) fn set_content_area(&mut self, width: u16, height: u16) { + self.content_width = width; + self.content_height = height; + } + + pub(crate) fn viewport_rows( + &self, + start_logical: usize, + offset_in_line: usize, + available_rows: usize, + ) -> Vec> { + let mut rows = Vec::new(); + + for (entry_idx, entry) in self.viewport_cache.entries.iter().enumerate() { + let logical_line = self.viewport_cache.logical_start + entry_idx; + let start_row = if logical_line == start_logical { + offset_in_line + } else { + 0 + }; + + for (visual_row, text) in entry.wrapped_rows.iter().enumerate().skip(start_row) { + if rows.len() >= available_rows { + return rows; + } + rows.push(ViewportRenderRow { + logical_line, + visual_row, + text, + level: entry.level.as_ref(), + }); + } + } + + rows + } + + #[cfg(test)] + pub(crate) fn enter_settings_mode_for_test(&mut self) { + self.mode = AppMode::Settings; + self.settings_draft = self.color_config.clone(); + } + + #[cfg(test)] + pub(crate) fn set_cursor_for_test(&mut self, line: usize, sub_offset: usize) { + self.cursor_line = line; + self.cursor_sub_offset = sub_offset; + } + + #[cfg(test)] + pub fn set_error_state(&mut self, msg: impl Into) { + self.loading_state = AppLoadingState::Error(msg.into()); + } + + #[allow(dead_code)] + pub fn file_size(&self) -> u64 { + match &self.loading_state { + AppLoadingState::Ready { reader } => reader.reader().map_or(0, |r| r.file_size()), + AppLoadingState::Loading { reader, .. } => reader.reader().map_or(0, |r| r.file_size()), + _ => 0, + } + } + + /// 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()) + } + + pub(super) fn get_content_width(&self) -> usize { + 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())) + } +} diff --git a/crates/tui/src/app/viewport_cache.rs b/crates/tui/src/app/viewport_cache.rs new file mode 100644 index 0000000..eeff70e --- /dev/null +++ b/crates/tui/src/app/viewport_cache.rs @@ -0,0 +1,99 @@ +use log_viewer_core::types::LogLevel; +use unicode_width::UnicodeWidthChar; + +pub(super) struct ViewportEntry { + pub(super) wrapped_rows: Vec, + pub(super) level: Option, + pub(super) visual_height: usize, +} + +pub(super) struct ViewportCache { + pub(super) entries: Vec, + pub(super) logical_start: usize, + pub(super) width: usize, + json_format: bool, + pub(super) cached_total_visual_rows: Option, +} + +impl ViewportCache { + pub(super) fn new() -> Self { + Self { + entries: Vec::new(), + logical_start: 0, + width: 0, + json_format: false, + cached_total_visual_rows: None, + } + } + + pub(super) fn invalidate(&mut self) { + self.entries.clear(); + self.logical_start = 0; + self.width = 0; + self.cached_total_visual_rows = None; + } + + pub(super) fn needs_recompute(&self, width: usize, json_format: bool) -> bool { + self.width != width || self.json_format != json_format + } + + pub(super) fn set_json_format(&mut self, json_format: bool) { + self.json_format = json_format; + } + + #[allow(dead_code)] + pub(super) fn get_entry(&self, logical_line: usize) -> Option<&ViewportEntry> { + if logical_line >= self.logical_start { + let idx = logical_line - self.logical_start; + self.entries.get(idx) + } else { + None + } + } +} + +pub(super) const TRUNCATE_TAB_WIDTH: usize = 4; + +pub(super) 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 +} + +pub(super) fn truncate_to_columns(s: &str, max_cols: usize) -> String { + if max_cols == 0 || s.is_empty() { + return String::new(); + } + + let mut out = String::new(); + let mut col = 0; + + for ch in s.chars() { + if ch == '\t' { + let tab_stop = TRUNCATE_TAB_WIDTH - (col % TRUNCATE_TAB_WIDTH); + if col + tab_stop > max_cols { + break; + } + for _ in 0..tab_stop { + out.push(' '); + } + col += tab_stop; + } else { + let w = if ch.is_control() { + 0 + } else { + ch.width().unwrap_or(0) + }; + if col + w > max_cols { + break; + } + out.push(ch); + col += w; + } + } + + out +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 9d58f02..573883a 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -30,7 +30,7 @@ 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 + logical_line == app.cursor_line() && visual_row == app.cursor_sub_offset() } pub fn render(frame: &mut ratatui::Frame, app: &mut App) { @@ -45,7 +45,7 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { .split(frame.area()); // ── Title bar ────────────────────────────────────────────────── - let title_text = if app.mode == AppMode::Settings { + let title_text = if app.mode() == AppMode::Settings { " Color Settings".to_string() } else if app.is_loading() { let name = app.file_name().unwrap_or("unknown"); @@ -56,7 +56,7 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { let cursor_display = if app.total_lines() == 0 { 0 } else { - app.cursor_line + 1 + app.cursor_line() + 1 }; format!(" {} [{}/{}]", name, cursor_display, app.total_lines()) } else { @@ -68,7 +68,7 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { ); // ── Content area ─────────────────────────────────────────────── - if app.mode == AppMode::Settings { + if app.mode() == AppMode::Settings { render_settings(frame, app, outer[1]); } else if app.is_error() { let msg = app.error_message().unwrap_or_default(); @@ -93,10 +93,10 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { } // ── Status bar ───────────────────────────────────────────────── - if app.mode == AppMode::Settings { - if let Some(ref err) = app.settings_error { + if app.mode() == AppMode::Settings { + if let Some(err) = app.settings_error() { frame.render_widget( - Paragraph::new(err.as_str()).style(Style::default().fg(Color::Red)), + Paragraph::new(err).style(Style::default().fg(Color::Red)), outer[2], ); } else { @@ -127,7 +127,7 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { } else if app.is_loaded() { 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 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 @@ -162,18 +162,19 @@ pub fn render_settings(frame: &mut ratatui::Frame, app: &mut App, area: ratatui: let inner = block.inner(popup); frame.render_widget(block, popup); + let settings_draft = app.settings_draft(); let levels = [ - ("ERROR", &app.settings_draft.error), - ("WARN", &app.settings_draft.warn), - ("INFO", &app.settings_draft.info), - ("DEBUG", &app.settings_draft.debug), - ("TRACE", &app.settings_draft.trace), - ("UNKNOWN", &app.settings_draft.unknown), + ("ERROR", &settings_draft.error), + ("WARN", &settings_draft.warn), + ("INFO", &settings_draft.info), + ("DEBUG", &settings_draft.debug), + ("TRACE", &settings_draft.trace), + ("UNKNOWN", &settings_draft.unknown), ]; let mut lines = Vec::new(); for (i, (level_name, color_name)) in levels.iter().enumerate() { - let is_selected = i == app.settings_cursor; + let is_selected = i == app.settings_cursor(); let cursor_marker = if is_selected { "▶ " } else { " " }; let preview_color = color_name.parse::().unwrap_or(Color::White); @@ -224,8 +225,7 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo let content_width = area.width as usize; let content_height = area.height as usize; - app.content_height = area.height; - app.content_width = area.width; + app.set_content_area(area.width, area.height); let total_lines = app.total_lines(); let line_num_width = if total_lines > 0 { @@ -245,7 +245,6 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo let (start_logical, offset_in_line) = app.ensure_viewport_cache(actual_content_width); let mut lines: Vec = Vec::new(); - let mut current_visual_offset: usize = 0; let available_rows = content_height; let gutter_style = if is_loading { @@ -256,66 +255,50 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo Style::default().fg(Color::DarkGray) }; - for (entry_idx, entry) in app.viewport_cache.entries.iter().enumerate() { - let logical_line = app.viewport_cache.logical_start + entry_idx; - let start_row = if logical_line == start_logical { - offset_in_line + for row in app.viewport_rows(start_logical, offset_in_line, available_rows) { + let is_cursor = is_cursor_visual_row(app, row.logical_line, row.visual_row); + let level = row.level; + + let bg_color = if is_cursor { + Color::DarkGray } else { - 0 + Color::Reset + }; + let level_fg = level_fg(level, app.color_config()).unwrap_or(Color::White); + + let gutter_text = if row.visual_row == 0 { + if is_loading { + format!( + "~{:>width$} \u{2502}", + row.logical_line + 1, + width = line_num_width + ) + } else { + format!( + "{:>width$} \u{2502}", + row.logical_line + 1, + width = line_num_width + ) + } + } else if is_loading { + format!(" {:width$} \u{2502}", "", width = line_num_width) + } else { + format!("{:width$} \u{2502}", "", width = line_num_width) }; - for (visual_row, text) in entry.wrapped_rows.iter().enumerate().skip(start_row) { - if current_visual_offset >= available_rows { - break; - } + let effective_gutter_style = if is_cursor { + gutter_style.bg(bg_color) + } else { + gutter_style + }; - let is_cursor = is_cursor_visual_row(app, logical_line, visual_row); - let level = entry.level.as_ref(); - - let bg_color = if is_cursor { - Color::DarkGray - } else { - Color::Reset - }; - let level_fg = level_fg(level, &app.color_config).unwrap_or(Color::White); - - let gutter_text = if visual_row == 0 { - if is_loading { - format!( - "~{:>width$} \u{2502}", - logical_line + 1, - width = line_num_width - ) - } else { - format!( - "{:>width$} \u{2502}", - logical_line + 1, - width = line_num_width - ) - } - } else if is_loading { - format!(" {:width$} \u{2502}", "", width = line_num_width) - } else { - format!("{:width$} \u{2502}", "", width = line_num_width) - }; - - let effective_gutter_style = if is_cursor { - gutter_style.bg(bg_color) - } else { - gutter_style - }; - - lines.push(Line::from(vec![ - Span::styled(gutter_text, effective_gutter_style), - Span::styled(text.clone(), Style::default().fg(level_fg).bg(bg_color)), - ])); - - current_visual_offset += 1; - } - - if current_visual_offset >= available_rows { - break; - } + lines.push(Line::from(vec![ + Span::styled(gutter_text, effective_gutter_style), + Span::styled( + row.text.to_string(), + Style::default().fg(level_fg).bg(bg_color), + ), + ])); } while lines.len() < available_rows { @@ -384,8 +367,7 @@ mod tests { #[test] fn test_cursor_highlight_predicate_uses_cursor_sub_offset() { let mut app = App::new(); - app.cursor_line = 4; - app.cursor_sub_offset = 2; + app.set_cursor_for_test(4, 2); assert!(!is_cursor_visual_row(&app, 4, 0)); assert!(!is_cursor_visual_row(&app, 3, 2)); @@ -532,8 +514,7 @@ mod tests { width: u16, height: u16, ) -> ratatui::buffer::Buffer { - app.mode = crate::app::AppMode::Settings; - app.settings_draft = app.color_config.clone(); + app.enter_settings_mode_for_test(); render_to_buffer(app, width, height) }