refactor(tui): split remaining app.rs into per-concern submodules
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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,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,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;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user