Compare commits
7
Commits
95f259a2bb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9aa50f932 | ||
|
|
9854ef13b6 | ||
|
|
58ac4a65c7 | ||
|
|
0910d4e85f | ||
|
|
ef26481976 | ||
|
|
c4ba196016 | ||
|
|
ec5a163f05 |
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/target/
|
||||
crates/*/target/
|
||||
*.swp
|
||||
*.swo
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Agent tooling state
|
||||
.omo/
|
||||
.sisyphus/
|
||||
|
||||
# Benchmark output
|
||||
benchmark-report.md
|
||||
|
||||
@@ -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`
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# logViewer
|
||||
|
||||
**[English](README.md)**
|
||||
|
||||
用 Rust 构建的高性能终端日志查看器,专为处理超大日志文件(GB 级别)而设计,内存占用极低。
|
||||
|
||||
基于 mmap 内存映射、稀疏行索引和后台渐进式加载,打开文件几乎瞬时完成——即使是 5GB 以上的日志文件,也能在索引完成前就开始滚动浏览。
|
||||
|
||||
## 特性
|
||||
|
||||
- **瞬时打开** — mmap 读取 + 后台渐进式索引,索引未完成即可开始浏览
|
||||
- **超大文件支持** — 稀疏行索引(每 256 行采样一次),5GB 文件仅占用约 8MB 内存
|
||||
- **实时文件追踪** — 通过 `notify` 监控文件追加、截断和日志轮转
|
||||
- **JSON 日志解析** — 支持 NDJSON 格式,自动处理 BOM、检测重复键,可切换格式化显示
|
||||
- **Unicode 感知的换行** — 正确处理中文、emoji 和制表符的行宽计算
|
||||
- **持久化索引缓存** — 行索引保存到磁盘,使用 xxh3 哈希校验内容变更,再次打开几乎无需等待
|
||||
- **Vim 风格快捷键** — 终端用户无学习成本
|
||||
- **可自定义配色** — 每个日志级别独立配色,通过 TOML 配置文件管理,TUI 内直接调整
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 直接编译运行
|
||||
cargo run -p log-viewer-tui -- path/to/logfile.log
|
||||
|
||||
# 或先编译再运行
|
||||
cargo build --release -p log-viewer-tui
|
||||
./target/release/log-viewer-tui path/to/logfile.log
|
||||
```
|
||||
|
||||
需要 Rust 1.92 及以上版本(见 `rust-toolchain.toml`)。
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 按键 | 操作 |
|
||||
|------|------|
|
||||
| `j` / `↓` | 向下滚动一行 |
|
||||
| `k` / `↑` | 向上滚动一行 |
|
||||
| `Ctrl+d` | 向下滚动半页 |
|
||||
| `Ctrl+u` | 向上滚动半页 |
|
||||
| `Ctrl+f` / `PgDn` | 向下滚动一页 |
|
||||
| `Ctrl+b` / `PgUp` | 向上滚动一页 |
|
||||
| `G` / `End` | 跳转到文件末尾 |
|
||||
| `gg` / `Home` | 跳转到文件开头 |
|
||||
| `Tab` | 切换 JSON 格式化显示 |
|
||||
| `S` | 打开配色设置 |
|
||||
| `q` / `Esc` | 退出 |
|
||||
|
||||
设置面板:`j`/`k` 选择日志级别,`←`/`→` 切换颜色,`Enter` 保存,`Esc` 取消。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
crates/core — 核心库:I/O、解析、数据类型、配置、文件监控
|
||||
crates/tui — 终端界面(ratatui + crossterm)
|
||||
crates/gui — 图形界面(egui + eframe)— 占位模块,尚未实现
|
||||
crates/bench — mmap 与 pread 性能对比基准测试
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 检查、测试、代码规范
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo fmt --check --all
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# 运行基准测试(需要先生成约 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
|
||||
|
||||
# 单独测试某个 crate
|
||||
cargo test -p log-viewer-core
|
||||
cargo test -p log-viewer-tui
|
||||
```
|
||||
|
||||
### CI
|
||||
|
||||
CI 在 ubuntu-latest 和 windows-latest 上运行以下检查:
|
||||
|
||||
1. `cargo fmt --check --all`
|
||||
2. `cargo check --workspace`
|
||||
3. `cargo test --workspace`
|
||||
4. `cargo clippy --workspace -- -D warnings`
|
||||
|
||||
无自定义 rustfmt 或 clippy 配置——使用工具链默认值。
|
||||
|
||||
## 架构
|
||||
|
||||
核心库 `log-viewer-core` 负责所有 I/O、解析和数据类型。关键设计决策:
|
||||
|
||||
- **稀疏行索引** — 每 256 行采样一次,使用 memchr SIMD 加速。100 万行的文件仅产生约 32KB 的索引,而非 8MB。
|
||||
- **渐进式加载** — `ProgressiveFileReader` 先读取文件头尾进行快速行数估算,再通过 crossbeam-channel 在后台线程构建完整索引。
|
||||
- **mmap 与 TOCTOU 防护** — mmap 映射后进行 stat 校验以检测文件变更。`read_cache` 模块为未来基于 pread 的实现预留,可彻底消除 SIGBUS 风险。
|
||||
- **持久化缓存** — 行索引序列化到磁盘,使用 xxh3 内容哈希进行失效校验,通过临时文件实现原子写入。
|
||||
- **视觉高度索引** — 基于换行后行高的前缀和数组,支持 O(log n) 的滚动定位,适用于长行换行场景。
|
||||
|
||||
计划中但尚未实现的功能:过滤、书签、会话管理、搜索引擎(stub 模块已创建)。
|
||||
|
||||
## 基准测试
|
||||
|
||||
自研基准测试框架,对比 mmap 与 pread 两种后端在 7 个测试场景(启动、渲染、跳转、内存、增长、轮转、并发)下的表现。使用挂钟计时和 `/proc/self/` 的 RSS 及页错误指标。结果以 Markdown 表格形式输出到 `benchmark-report.md`。
|
||||
|
||||
基准测试框架要求 `/tmp/test-logviewer/extreme.log` 已经存在。它不会自动生成主用的 5GB 测试文件;请先使用上方的 `dd` 命令创建。
|
||||
|
||||
基准测试二进制包含约 75 个单元测试,覆盖读取器后端和指标采集逻辑。
|
||||
@@ -0,0 +1,108 @@
|
||||
# logViewer
|
||||
|
||||
**[中文文档](README-zh.md)**
|
||||
|
||||
A high-performance terminal log file viewer built in Rust, designed to handle multi-gigabyte files with minimal memory overhead.
|
||||
|
||||
Uses memory-mapped I/O with a sparse line index and progressive background loading to open files instantly — even 5GB+ logs scroll smoothly from the first keystroke.
|
||||
|
||||
## Features
|
||||
|
||||
- **Instant file open** — mmap-backed reader with background progressive indexing; start scrolling before indexing finishes
|
||||
- **Handles huge files** — sparse line index (1 entry per 256 lines) keeps memory usage at ~8MB even for 5GB files
|
||||
- **Live file tracking** — watches for appends, truncations, and log rotation via `notify`
|
||||
- **JSON log support** — NDJSON parsing with BOM handling, duplicate key detection, and toggleable pretty-printing
|
||||
- **Unicode-aware wrapping** — correct line wrapping for CJK, emoji, and tabs
|
||||
- **Persistent index cache** — line indexes saved to disk with xxh3 content hashing; re-opens are near-instant
|
||||
- **Vim-like keybindings** — familiar navigation for terminal users
|
||||
- **Customizable colors** — per-log-level colors via TOML config, adjustable from within the TUI
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
cargo run -p log-viewer-tui -- path/to/logfile.log
|
||||
|
||||
# Or build first
|
||||
cargo build --release -p log-viewer-tui
|
||||
./target/release/log-viewer-tui path/to/logfile.log
|
||||
```
|
||||
|
||||
Requires Rust 1.92+ (see `rust-toolchain.toml`).
|
||||
|
||||
## Keybindings
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `j` / `↓` | Scroll down one line |
|
||||
| `k` / `↑` | Scroll up one line |
|
||||
| `Ctrl+d` | Scroll down half page |
|
||||
| `Ctrl+u` | Scroll up half page |
|
||||
| `Ctrl+f` / `PgDn` | Scroll down full page |
|
||||
| `Ctrl+b` / `PgUp` | Scroll up full page |
|
||||
| `G` / `End` | Jump to end of file |
|
||||
| `gg` / `Home` | Jump to start of file |
|
||||
| `Tab` | Toggle JSON pretty-printing |
|
||||
| `S` | Open color settings |
|
||||
| `q` / `Esc` | Quit |
|
||||
|
||||
Settings panel: use `j`/`k` to select a log level, `←`/`→` to cycle colors, `Enter` to save, `Esc` to cancel.
|
||||
|
||||
## Workspace Structure
|
||||
|
||||
```
|
||||
crates/core — Shared library: I/O, parsing, types, config, file watching
|
||||
crates/tui — Terminal UI (ratatui + crossterm)
|
||||
crates/gui — GUI (egui + eframe) — placeholder, not yet functional
|
||||
crates/bench — mmap vs pread benchmark harness
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Check, test, lint
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo fmt --check --all
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# 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
|
||||
|
||||
# Test a single crate
|
||||
cargo test -p log-viewer-core
|
||||
cargo test -p log-viewer-tui
|
||||
```
|
||||
|
||||
### CI
|
||||
|
||||
The CI gate runs on ubuntu-latest and windows-latest:
|
||||
1. `cargo fmt --check --all`
|
||||
2. `cargo check --workspace`
|
||||
3. `cargo test --workspace`
|
||||
4. `cargo clippy --workspace -- -D warnings`
|
||||
|
||||
No custom rustfmt or clippy config — uses toolchain defaults.
|
||||
|
||||
## Architecture
|
||||
|
||||
The core library (`log-viewer-core`) owns all I/O, parsing, and data types. Key design decisions:
|
||||
|
||||
- **Sparse line index** — samples every 256 lines using memchr SIMD acceleration. A 1M-line file produces a ~32KB index instead of ~8MB.
|
||||
- **Progressive loading** — `ProgressiveFileReader` starts with a quick head+tail sample for instant line estimates, then builds the full index in a background thread via crossbeam-channel.
|
||||
- **mmap with TOCTOU mitigation** — post-mmap stat check to detect file changes. `read_cache` module exists as a future pread-based alternative to eliminate SIGBUS risk entirely.
|
||||
- **Persistent cache** — line indexes are serialized to disk with xxh3 content hashing for invalidation. Atomic writes via temp files.
|
||||
- **Visual height index** — prefix-sum over wrapped-line heights, enabling O(log n) scroll-to-line mapping for long wrapped lines.
|
||||
|
||||
Planned but not yet built: filtering, bookmarks, sessions, search engine (stub modules exist).
|
||||
|
||||
## Benchmarks
|
||||
|
||||
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.
|
||||
@@ -84,10 +84,8 @@ pub fn generate_growable_file(dir: &Path) -> std::io::Result<PathBuf> {
|
||||
/// Append `count` lines to the file
|
||||
pub fn append_lines(path: &Path, count: usize) -> std::io::Result<()> {
|
||||
let existing_lines = count_existing_lines(path)?;
|
||||
let mut file = BufWriter::with_capacity(
|
||||
64 * 1024,
|
||||
fs::OpenOptions::new().append(true).open(path)?,
|
||||
);
|
||||
let mut file =
|
||||
BufWriter::with_capacity(64 * 1024, fs::OpenOptions::new().append(true).open(path)?);
|
||||
for i in 0..count {
|
||||
writeln!(
|
||||
file,
|
||||
|
||||
@@ -27,8 +27,10 @@ fn main() {
|
||||
|
||||
let suites = match args.suites {
|
||||
Some(names) => {
|
||||
let parsed: Result<Vec<_>, _> =
|
||||
names.iter().map(|s| s.parse::<log_viewer_bench::runner::Suite>()).collect();
|
||||
let parsed: Result<Vec<_>, _> = names
|
||||
.iter()
|
||||
.map(|s| s.parse::<log_viewer_bench::runner::Suite>())
|
||||
.collect();
|
||||
match parsed {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering};
|
||||
use std::sync::Once;
|
||||
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering};
|
||||
|
||||
use memmap2::{Advice, Mmap, MmapOptions, RemapOptions};
|
||||
use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal};
|
||||
use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction};
|
||||
|
||||
use crate::line_index::LineIndex;
|
||||
use crate::FileReaderBackend;
|
||||
use crate::line_index::LineIndex;
|
||||
|
||||
// ─── SIGBUS Handler ──────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -35,7 +35,10 @@ const HANDLER_NONE: u8 = 0;
|
||||
const HANDLER_DEFAULT: u8 = 1;
|
||||
const HANDLER_IGNORE: u8 = 2;
|
||||
const HANDLER_PLAIN: u8 = 3; // extern "C" fn(c_int)
|
||||
#[allow(clippy::unseparated_literal_suffix, reason = "clarity: this is the SA_SIGACTION variant")]
|
||||
#[allow(
|
||||
clippy::unseparated_literal_suffix,
|
||||
reason = "clarity: this is the SA_SIGACTION variant"
|
||||
)]
|
||||
const HANDLER_SIGACTION: u8 = 4; // extern "C" fn(c_int, *mut siginfo_t, *mut c_void)
|
||||
|
||||
/// Old SIGBUS handler type — raw atomic, async-signal-safe to read.
|
||||
@@ -107,11 +110,8 @@ extern "C" fn sigbus_handler(
|
||||
HANDLER_SIGACTION => {
|
||||
let ptr = OLD_HANDLER_PTR.load(Ordering::Acquire);
|
||||
if !ptr.is_null() {
|
||||
let f: extern "C" fn(
|
||||
libc::c_int,
|
||||
*mut libc::siginfo_t,
|
||||
*mut std::ffi::c_void,
|
||||
) = unsafe { std::mem::transmute(ptr) };
|
||||
let f: extern "C" fn(libc::c_int, *mut libc::siginfo_t, *mut std::ffi::c_void) =
|
||||
unsafe { std::mem::transmute(ptr) };
|
||||
f(sig, info, ctx);
|
||||
} else {
|
||||
unsafe { libc::_exit(128 + sig) };
|
||||
|
||||
@@ -15,8 +15,8 @@ use std::os::unix::fs::FileExt;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::line_index::LineIndex;
|
||||
use crate::FileReaderBackend;
|
||||
use crate::line_index::LineIndex;
|
||||
|
||||
const BLOCK_SIZE: usize = 256;
|
||||
const CACHE_CHUNK: usize = 4096;
|
||||
@@ -478,12 +478,19 @@ mod tests {
|
||||
let mut reader = PreadReaderPlain::open(&path).unwrap();
|
||||
assert_eq!(reader.total_lines(), 3);
|
||||
assert_eq!(reader.get_line(0), Some("alpha".to_owned()));
|
||||
assert_eq!(reader.get_line(3), None, "should be out of bounds before append");
|
||||
assert_eq!(
|
||||
reader.get_line(3),
|
||||
None,
|
||||
"should be out of bounds before append"
|
||||
);
|
||||
|
||||
// Phase 2: append 2 more lines
|
||||
{
|
||||
use std::io::Write as _;
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
f.write_all(b"delta\nepsilon\n").unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -145,8 +145,7 @@ pub fn format_report(results: &[BenchmarkResult]) -> String {
|
||||
|
||||
let mut mem_rows: Vec<&BenchmarkResult> = category_results.to_vec();
|
||||
mem_rows.sort_by(|a, b| {
|
||||
(&a.test_name, &a.backend, &a.variant)
|
||||
.cmp(&(&b.test_name, &b.backend, &b.variant))
|
||||
(&a.test_name, &a.backend, &a.variant).cmp(&(&b.test_name, &b.backend, &b.variant))
|
||||
});
|
||||
for r in mem_rows {
|
||||
let variant_label = format!("{} ({})", r.backend, r.variant);
|
||||
@@ -243,21 +242,60 @@ mod tests {
|
||||
#[test]
|
||||
fn report_ordering_independent_of_input_order() {
|
||||
let set_a = vec![
|
||||
make_result("sequential", "read_1mb", "pread", "default", vec![100, 110, 105]),
|
||||
make_result("sequential", "read_1mb", "mmap", "default", vec![80, 85, 90]),
|
||||
make_result("sequential", "read_4kb", "pread", "default", vec![10, 12, 11]),
|
||||
make_result(
|
||||
"sequential",
|
||||
"read_1mb",
|
||||
"pread",
|
||||
"default",
|
||||
vec![100, 110, 105],
|
||||
),
|
||||
make_result(
|
||||
"sequential",
|
||||
"read_1mb",
|
||||
"mmap",
|
||||
"default",
|
||||
vec![80, 85, 90],
|
||||
),
|
||||
make_result(
|
||||
"sequential",
|
||||
"read_4kb",
|
||||
"pread",
|
||||
"default",
|
||||
vec![10, 12, 11],
|
||||
),
|
||||
make_result("sequential", "read_4kb", "mmap", "default", vec![8, 9, 7]),
|
||||
];
|
||||
|
||||
let set_b = vec![
|
||||
make_result("sequential", "read_4kb", "mmap", "default", vec![8, 9, 7]),
|
||||
make_result("sequential", "read_1mb", "mmap", "default", vec![80, 85, 90]),
|
||||
make_result("sequential", "read_4kb", "pread", "default", vec![10, 12, 11]),
|
||||
make_result("sequential", "read_1mb", "pread", "default", vec![100, 110, 105]),
|
||||
make_result(
|
||||
"sequential",
|
||||
"read_1mb",
|
||||
"mmap",
|
||||
"default",
|
||||
vec![80, 85, 90],
|
||||
),
|
||||
make_result(
|
||||
"sequential",
|
||||
"read_4kb",
|
||||
"pread",
|
||||
"default",
|
||||
vec![10, 12, 11],
|
||||
),
|
||||
make_result(
|
||||
"sequential",
|
||||
"read_1mb",
|
||||
"pread",
|
||||
"default",
|
||||
vec![100, 110, 105],
|
||||
),
|
||||
];
|
||||
|
||||
let report_a = format_report(&set_a);
|
||||
let report_b = format_report(&set_b);
|
||||
assert_eq!(report_a, report_b, "Reports must be identical regardless of input order");
|
||||
assert_eq!(
|
||||
report_a, report_b,
|
||||
"Reports must be identical regardless of input order"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,11 @@ mod tests {
|
||||
("concurrent", Suite::Concurrent),
|
||||
];
|
||||
for (s, expected_suite) in expected {
|
||||
assert_eq!(Suite::from_str(s).unwrap(), expected_suite, "failed to parse '{s}'");
|
||||
assert_eq!(
|
||||
Suite::from_str(s).unwrap(),
|
||||
expected_suite,
|
||||
"failed to parse '{s}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::FileReaderBackend;
|
||||
use crate::metrics::MetricsCollector;
|
||||
use crate::mmap_reader::{
|
||||
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
|
||||
@@ -8,7 +9,6 @@ use crate::mmap_reader::{
|
||||
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
|
||||
use crate::runner::BenchConfig;
|
||||
use crate::types::BenchmarkResult;
|
||||
use crate::FileReaderBackend;
|
||||
|
||||
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::FileReaderBackend;
|
||||
use crate::data_gen;
|
||||
use crate::metrics::MetricsCollector;
|
||||
use crate::mmap_reader::MmapReaderPlain;
|
||||
use crate::pread_reader::PreadReaderPlain;
|
||||
use crate::runner::BenchConfig;
|
||||
use crate::types::BenchmarkResult;
|
||||
use crate::FileReaderBackend;
|
||||
|
||||
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::FRAME_LINES;
|
||||
use crate::FileReaderBackend;
|
||||
use crate::metrics::MetricsCollector;
|
||||
use crate::mmap_reader::{
|
||||
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
|
||||
@@ -9,7 +10,6 @@ use crate::mmap_reader::{
|
||||
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
|
||||
use crate::runner::BenchConfig;
|
||||
use crate::types::BenchmarkResult;
|
||||
use crate::FileReaderBackend;
|
||||
|
||||
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::FileReaderBackend;
|
||||
use crate::metrics::MetricsCollector;
|
||||
use crate::mmap_reader::{
|
||||
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
|
||||
@@ -8,7 +9,6 @@ use crate::mmap_reader::{
|
||||
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
|
||||
use crate::runner::BenchConfig;
|
||||
use crate::types::BenchmarkResult;
|
||||
use crate::FileReaderBackend;
|
||||
|
||||
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::FRAME_LINES;
|
||||
use crate::FileReaderBackend;
|
||||
use crate::metrics::MetricsCollector;
|
||||
use crate::mmap_reader::{
|
||||
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
|
||||
@@ -9,7 +10,6 @@ use crate::mmap_reader::{
|
||||
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
|
||||
use crate::runner::BenchConfig;
|
||||
use crate::types::BenchmarkResult;
|
||||
use crate::FileReaderBackend;
|
||||
|
||||
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
|
||||
let mut results = Vec::new();
|
||||
@@ -248,7 +248,11 @@ mod tests {
|
||||
assert!(
|
||||
!ranges_overlap(pos[i].1, pos[j].1),
|
||||
"overlap: {:?} @ {} vs {:?} @ {} (total={})",
|
||||
pos[i].0, pos[i].1, pos[j].0, pos[j].1, total
|
||||
pos[i].0,
|
||||
pos[i].1,
|
||||
pos[j].0,
|
||||
pos[j].1,
|
||||
total
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -269,7 +273,10 @@ mod tests {
|
||||
assert!(
|
||||
!ranges_overlap(pos[i].1, pos[j].1),
|
||||
"overlap at total=104: {:?} @ {} vs {:?} @ {}",
|
||||
pos[i].0, pos[i].1, pos[j].0, pos[j].1
|
||||
pos[i].0,
|
||||
pos[i].1,
|
||||
pos[j].0,
|
||||
pos[j].1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -283,7 +290,10 @@ mod tests {
|
||||
assert!(
|
||||
!ranges_overlap(pos[i].1, pos[j].1),
|
||||
"overlap at total=105: {:?} @ {} vs {:?} @ {}",
|
||||
pos[i].0, pos[i].1, pos[j].0, pos[j].1
|
||||
pos[i].0,
|
||||
pos[i].1,
|
||||
pos[j].0,
|
||||
pos[j].1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::FileReaderBackend;
|
||||
use crate::data_gen;
|
||||
use crate::metrics::MetricsCollector;
|
||||
use crate::mmap_reader::{self, MmapReaderPlain};
|
||||
use crate::pread_reader::PreadReaderPlain;
|
||||
use crate::runner::BenchConfig;
|
||||
use crate::types::BenchmarkResult;
|
||||
use crate::FileReaderBackend;
|
||||
|
||||
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::FileReaderBackend;
|
||||
use crate::metrics::MetricsCollector;
|
||||
use crate::mmap_reader::{
|
||||
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
|
||||
@@ -9,7 +10,6 @@ use crate::mmap_reader::{
|
||||
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
|
||||
use crate::runner::BenchConfig;
|
||||
use crate::types::BenchmarkResult;
|
||||
use crate::FileReaderBackend;
|
||||
|
||||
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -33,8 +33,8 @@ impl FileReader {
|
||||
} else {
|
||||
// SAFETY: 使用只读 Mmap(非 MmapMut),文件以只读方式打开。
|
||||
// memmap2 内部持有文件描述符,确保 mmap 期间文件不会被关闭。
|
||||
let m = unsafe { memmap2::Mmap::map(&file) }
|
||||
.map_err(|e| CoreError::Mmap(e.to_string()))?;
|
||||
let m =
|
||||
unsafe { memmap2::Mmap::map(&file) }.map_err(|e| CoreError::Mmap(e.to_string()))?;
|
||||
|
||||
// Layer 3: mmap 后立即 stat 同一 fd,检测截断(TOCTOU 缓解,非安全证明)
|
||||
let current_size = file.metadata()?.len();
|
||||
@@ -507,7 +507,11 @@ mod tests {
|
||||
reader.reload().unwrap();
|
||||
assert_eq!(reader.line_count(), 1);
|
||||
assert_eq!(reader.get_line(0), Some("new"));
|
||||
assert_eq!(reader.get_line(1), None, "old line should not be accessible");
|
||||
assert_eq!(
|
||||
reader.get_line(1),
|
||||
None,
|
||||
"old line should not be accessible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::io::cache_util::{cache_path, CACHE_VERSION};
|
||||
use crate::io::cache_util::{CACHE_VERSION, cache_path};
|
||||
use crate::io::line_index::LineIndex;
|
||||
|
||||
pub struct IndexCache;
|
||||
@@ -47,11 +47,7 @@ impl IndexCache {
|
||||
///
|
||||
/// The file hash is derived from `data` (the same byte slice used to build the index),
|
||||
/// avoiding TOCTOU issues from re-reading the file from disk.
|
||||
pub fn save_with_hash(
|
||||
file_path: &Path,
|
||||
index: &LineIndex,
|
||||
data: &[u8],
|
||||
) -> std::io::Result<()> {
|
||||
pub fn save_with_hash(file_path: &Path, index: &LineIndex, data: &[u8]) -> std::io::Result<()> {
|
||||
let dest = cache_path(file_path).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "cannot determine cache path")
|
||||
})?;
|
||||
@@ -382,6 +378,10 @@ mod tests {
|
||||
}
|
||||
|
||||
let final_data = std::fs::read(&dest).expect("dest file should exist");
|
||||
assert_eq!(final_data.len(), 64 * 1024, "final file must be exactly one payload");
|
||||
assert_eq!(
|
||||
final_data.len(),
|
||||
64 * 1024,
|
||||
"final file must be exactly one payload"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -324,10 +324,7 @@ mod tests {
|
||||
Ok(LogLevel::Unknown("CUSTOM".into()))
|
||||
);
|
||||
// Pure whitespace becomes Unknown("").
|
||||
assert_eq!(
|
||||
" ".parse::<LogLevel>(),
|
||||
Ok(LogLevel::Unknown("".into()))
|
||||
);
|
||||
assert_eq!(" ".parse::<LogLevel>(), Ok(LogLevel::Unknown("".into())));
|
||||
// Internal whitespace is NOT collapsed.
|
||||
assert_eq!(
|
||||
"W ARN".parse::<LogLevel>(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crossbeam_channel::{bounded, Receiver, Sender};
|
||||
use crossbeam_channel::{Receiver, Sender, bounded};
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
@@ -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