refactor(tui): split app query and viewport cache helpers

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-06-30 16:55:34 +08:00
co-authored by Sisyphus
parent 0910d4e85f
commit 58ac4a65c7
4 changed files with 383 additions and 294 deletions
+16 -216
View File
@@ -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<String>,
pub(crate) level: Option<LogLevel>,
pub(crate) visual_height: usize,
}
pub(crate) struct ViewportCache {
pub(crate) entries: Vec<ViewportEntry>,
pub(crate) logical_start: usize,
pub(crate) width: usize,
json_format: bool,
cached_total_visual_rows: Option<usize>,
}
impl ViewportCache {
pub(crate) fn new() -> Self {
Self {
entries: Vec::new(),
logical_start: 0,
width: 0,
json_format: false,
cached_total_visual_rows: None,
}
}
pub(crate) fn invalidate(&mut self) {
self.entries.clear();
self.logical_start = 0;
self.width = 0;
self.cached_total_visual_rows = None;
}
pub(crate) fn needs_recompute(&self, width: usize, json_format: bool) -> bool {
self.width != width || self.json_format != json_format
}
#[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<String> {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.get_line(idx),
AppLoadingState::Loading { reader, .. } => reader.get_line(idx),
_ => None,
}
}
#[allow(dead_code)]
pub fn file_name(&self) -> Option<&str> {
self.file_path
.as_ref()
.and_then(|p| std::path::Path::new(p).file_name().and_then(|n| n.to_str()))
}
pub fn total_lines(&self) -> usize {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.line_count(),
AppLoadingState::Loading {
reader,
estimated_lines,
..
} => {
// Use estimated total lines (not sampled_line_count) so the user can
// scroll freely during indexing. get_line() incrementally scans
// forward on demand, so lines beyond the initial 64KB are still
// accessible. The .max() guards against under-estimates.
(*estimated_lines as usize).max(reader.sampled_line_count())
}
_ => 0,
}
}
pub fn is_loaded(&self) -> bool {
matches!(
self.loading_state,
AppLoadingState::Ready { .. } | AppLoadingState::Loading { .. }
)
}
pub fn is_loading(&self) -> bool {
matches!(self.loading_state, AppLoadingState::Loading { .. })
}
pub fn is_error(&self) -> bool {
matches!(self.loading_state, AppLoadingState::Error(_))
}
fn get_visual_height_index(&self) -> Option<&VisualHeightIndex> {
match &self.loading_state {
AppLoadingState::Ready { reader } => match &reader.state {
log_viewer_core::io::progressive_reader::ReaderState::Ready {
visual_height_index,
..
} => visual_height_index.as_ref(),
_ => None,
},
_ => None,
}
}
/// Compute the rebased offset pair (logical_line, sub_row) from the
/// current visual-row `v_offset`. Returns `(v_offset, v_sub_offset)`
/// suitable for the no-VHI fallback scrolling path.
@@ -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<f64> {
match &self.loading_state {
AppLoadingState::Loading {
progress_percent, ..
} => Some(*progress_percent),
_ => None,
}
}
pub fn estimated_lines(&self) -> Option<u64> {
match &self.loading_state {
AppLoadingState::Loading {
estimated_lines, ..
} => Some(*estimated_lines),
_ => None,
}
}
#[cfg(test)]
pub fn set_error_state(&mut self, msg: impl Into<String>) {
self.loading_state = AppLoadingState::Error(msg.into());
}
#[allow(dead_code)]
pub fn file_size(&self) -> u64 {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.reader().map_or(0, |r| r.file_size()),
AppLoadingState::Loading { reader, .. } => reader.reader().map_or(0, |r| r.file_size()),
_ => 0,
}
}
/// 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<FileEvent> = 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::*;
+209
View File
@@ -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<String> {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.get_line(idx),
AppLoadingState::Loading { reader, .. } => reader.get_line(idx),
_ => None,
}
}
#[allow(dead_code)]
pub fn file_name(&self) -> Option<&str> {
self.file_path
.as_ref()
.and_then(|p| std::path::Path::new(p).file_name().and_then(|n| n.to_str()))
}
pub fn total_lines(&self) -> usize {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.line_count(),
AppLoadingState::Loading {
reader,
estimated_lines,
..
} => {
// Use estimated total lines (not sampled_line_count) so the user can
// scroll freely during indexing. get_line() incrementally scans
// forward on demand, so lines beyond the initial 64KB are still
// accessible. The .max() guards against under-estimates.
(*estimated_lines as usize).max(reader.sampled_line_count())
}
_ => 0,
}
}
pub fn is_loaded(&self) -> bool {
matches!(
self.loading_state,
AppLoadingState::Ready { .. } | AppLoadingState::Loading { .. }
)
}
pub fn is_loading(&self) -> bool {
matches!(self.loading_state, AppLoadingState::Loading { .. })
}
pub fn is_error(&self) -> bool {
matches!(self.loading_state, AppLoadingState::Error(_))
}
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<f64> {
match &self.loading_state {
AppLoadingState::Loading {
progress_percent, ..
} => Some(*progress_percent),
_ => None,
}
}
pub fn estimated_lines(&self) -> Option<u64> {
match &self.loading_state {
AppLoadingState::Loading {
estimated_lines, ..
} => Some(*estimated_lines),
_ => None,
}
}
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<ViewportRenderRow<'_>> {
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<String>) {
self.loading_state = AppLoadingState::Error(msg.into());
}
#[allow(dead_code)]
pub fn file_size(&self) -> u64 {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.reader().map_or(0, |r| r.file_size()),
AppLoadingState::Loading { reader, .. } => reader.reader().map_or(0, |r| r.file_size()),
_ => 0,
}
}
/// 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()))
}
}
+99
View File
@@ -0,0 +1,99 @@
use log_viewer_core::types::LogLevel;
use unicode_width::UnicodeWidthChar;
pub(super) struct ViewportEntry {
pub(super) wrapped_rows: Vec<String>,
pub(super) level: Option<LogLevel>,
pub(super) visual_height: usize,
}
pub(super) struct ViewportCache {
pub(super) entries: Vec<ViewportEntry>,
pub(super) logical_start: usize,
pub(super) width: usize,
json_format: bool,
pub(super) cached_total_visual_rows: Option<usize>,
}
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
}
+59 -78
View File
@@ -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::<Color>().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<Line> = 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)
}