Compare commits
4
Commits
ef26481976
...
c9aa50f932
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9aa50f932 | ||
|
|
9854ef13b6 | ||
|
|
58ac4a65c7 | ||
|
|
0910d4e85f |
@@ -76,7 +76,15 @@ All ten modules are declared `pub` in `lib.rs` and re-exported as `log_viewer_co
|
||||
## TUI Architecture
|
||||
|
||||
- **`main.rs`** — clap CLI (`files: Vec<String>`), `TerminalGuard` RAII for raw mode + alternate screen. **CRITICAL**: `Drop` does not run on `std::process::exit` or `panic = "abort"` — never call `process::exit` inside the guarded scope; return `Err` and use `?` instead. Event loop: poll indexer → poll watcher → draw → poll keys, 100ms timeout.
|
||||
- **`app.rs`** — `App` struct (~4000 lines including ~135 inline tests, test mod starts at `#[cfg(test)] mod tests`). `AppLoadingState: Empty | Loading { reader, estimated_lines, progress_percent } | Ready { reader } | Error(String)`. `AppMode: Normal | Settings`. Viewport cache for scroll. All key handling.
|
||||
- **`app/`** — module directory (split from the former monolithic `app.rs`). `mod.rs` (~3000 lines, inline `tests` mod at `#[cfg(test)] mod tests`) holds the `App` struct, `AppLoadingState: Empty | Loading { reader, estimated_lines, progress_percent } | Ready { reader } | Error(String)`, `AppMode: Normal | Settings`, `ViewportRenderRow`, and the main impl blocks. Per-concern helpers are split into submodules:
|
||||
- `input.rs` — `handle_key` + Normal-mode key dispatch
|
||||
- `settings.rs` — Settings-mode key dispatch (←/→ color cycle, j/k level select, Enter/Esc)
|
||||
- `scroll.rs` — line/half/full-page scroll + VHI-aware sub-offset walk
|
||||
- `viewport.rs` — viewport computation helpers
|
||||
- `viewport_cache.rs` — on-demand viewport-sized render cache
|
||||
- `loading.rs` — loading-state polling/progress helpers
|
||||
- `watcher.rs` — file-watcher event polling + reload/reindex glue
|
||||
- `query.rs` — query helpers
|
||||
- **`ui.rs`** — ratatui rendering: title bar, content area, status bar, settings popup
|
||||
- **`color.rs`** — `LogLevel` → ratatui `Color` via `ColorConfig`
|
||||
|
||||
@@ -88,9 +96,9 @@ j/k scroll, Ctrl+d/u half-page, Ctrl+f/b full-page, G/gg jump end/top, Tab toggl
|
||||
|
||||
All tests are inline (`#[cfg(test)] mod tests` blocks). No `tests/` directories, no integration tests, no async tests.
|
||||
|
||||
- **~450 tests total**: core 251, tui 136, bench 63, gui 0
|
||||
- **~470 tests total**: core 262, tui 142, bench 63, gui 0
|
||||
- Temp-file helpers vary per module — check the local `tests` mod before assuming an API:
|
||||
- `make_temp_file(content) -> PathBuf` — `tui/app.rs`, `tui/ui.rs`
|
||||
- `make_temp_file(content) -> PathBuf` — `tui/app/mod.rs`, `tui/ui.rs`
|
||||
- `make_test_file(lines) -> NamedTempFile` — `core/io/index_cache.rs`
|
||||
- `make_file(data) -> NamedTempFile` — `core/io/read_cache.rs`
|
||||
- `struct TempFile { ... }` — `core/io/line_sampler.rs`
|
||||
|
||||
+5
-1
@@ -66,7 +66,9 @@ cargo test --workspace
|
||||
cargo fmt --check --all
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# 运行基准测试(首次运行会生成约 5GB 测试文件)
|
||||
# 运行基准测试(需要先生成约 5GB 测试文件)
|
||||
mkdir -p /tmp/test-logviewer
|
||||
dd if=/dev/urandom of=/tmp/test-logviewer/extreme.log bs=1M count=5000
|
||||
cargo run -p log-viewer-bench
|
||||
cargo run -p log-viewer-bench -- --quick --suites startup,render --output results.md
|
||||
|
||||
@@ -102,4 +104,6 @@ CI 在 ubuntu-latest 和 windows-latest 上运行以下检查:
|
||||
|
||||
自研基准测试框架,对比 mmap 与 pread 两种后端在 7 个测试场景(启动、渲染、跳转、内存、增长、轮转、并发)下的表现。使用挂钟计时和 `/proc/self/` 的 RSS 及页错误指标。结果以 Markdown 表格形式输出到 `benchmark-report.md`。
|
||||
|
||||
基准测试框架要求 `/tmp/test-logviewer/extreme.log` 已经存在。它不会自动生成主用的 5GB 测试文件;请先使用上方的 `dd` 命令创建。
|
||||
|
||||
基准测试二进制包含约 75 个单元测试,覆盖读取器后端和指标采集逻辑。
|
||||
|
||||
@@ -66,7 +66,9 @@ cargo test --workspace
|
||||
cargo fmt --check --all
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# Run benchmarks (generates ~5GB test file on first run)
|
||||
# Run benchmarks (requires a pre-generated ~5GB test file)
|
||||
mkdir -p /tmp/test-logviewer
|
||||
dd if=/dev/urandom of=/tmp/test-logviewer/extreme.log bs=1M count=5000
|
||||
cargo run -p log-viewer-bench
|
||||
cargo run -p log-viewer-bench -- --quick --suites startup,render --output results.md
|
||||
|
||||
@@ -101,4 +103,6 @@ Planned but not yet built: filtering, bookmarks, sessions, search engine (stub m
|
||||
|
||||
Custom harness comparing mmap vs pread backends across 7 suites (startup, render, jump, memory, growth, rotation, concurrent). Uses wall-clock timing with `/proc/self/` RSS and page fault metrics. Results are written as markdown tables to `benchmark-report.md`.
|
||||
|
||||
The harness expects `/tmp/test-logviewer/extreme.log` to already exist. It does not generate the primary 5GB test file automatically; create it first with the `dd` command shown above.
|
||||
|
||||
The benchmark binary includes ~75 unit tests for the reader backends and metrics collection.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<FileEvent> = 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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-49
@@ -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,40 +255,28 @@ 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
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
for (visual_row, text) in entry.wrapped_rows.iter().enumerate().skip(start_row) {
|
||||
if current_visual_offset >= available_rows {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_cursor = is_cursor_visual_row(app, logical_line, visual_row);
|
||||
let level = entry.level.as_ref();
|
||||
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 {
|
||||
Color::Reset
|
||||
};
|
||||
let level_fg = level_fg(level, &app.color_config).unwrap_or(Color::White);
|
||||
let level_fg = level_fg(level, app.color_config()).unwrap_or(Color::White);
|
||||
|
||||
let gutter_text = if visual_row == 0 {
|
||||
let gutter_text = if row.visual_row == 0 {
|
||||
if is_loading {
|
||||
format!(
|
||||
"~{:>width$} \u{2502}",
|
||||
logical_line + 1,
|
||||
row.logical_line + 1,
|
||||
width = line_num_width
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{:>width$} \u{2502}",
|
||||
logical_line + 1,
|
||||
row.logical_line + 1,
|
||||
width = line_num_width
|
||||
)
|
||||
}
|
||||
@@ -307,15 +294,11 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(gutter_text, effective_gutter_style),
|
||||
Span::styled(text.clone(), Style::default().fg(level_fg).bg(bg_color)),
|
||||
Span::styled(
|
||||
row.text.to_string(),
|
||||
Style::default().fg(level_fg).bg(bg_color),
|
||||
),
|
||||
]));
|
||||
|
||||
current_visual_offset += 1;
|
||||
}
|
||||
|
||||
if current_visual_offset >= available_rows {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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