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:
+16
-216
@@ -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::*;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user