Compare commits
8 Commits
463c53148b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10323ce814 | ||
|
|
c1a931551b | ||
|
|
dfc016c348 | ||
|
|
19a3b877f9 | ||
|
|
5cb56dafd8 | ||
|
|
e99861c76d | ||
|
|
a43ef673b0 | ||
|
|
70f930eef7 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2358,6 +2358,7 @@ dependencies = [
|
|||||||
"ratatui",
|
"ratatui",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ pub fn compute_line_visual_height(
|
|||||||
}
|
}
|
||||||
if json_format {
|
if json_format {
|
||||||
let formatted = format_json_line(line_text);
|
let formatted = format_json_line(line_text);
|
||||||
|
if formatted.len() > MAX_WRAP_INPUT_LEN {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
compute_text_visual_height(&formatted, terminal_width)
|
compute_text_visual_height(&formatted, terminal_width)
|
||||||
} else {
|
} else {
|
||||||
compute_text_visual_height(line_text, terminal_width)
|
compute_text_visual_height(line_text, terminal_width)
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
/// Maximum input length for wrap/format operations (10 MB).
|
/// Maximum input length for wrap/format operations (10 MB).
|
||||||
/// Lines exceeding this are returned as-is to avoid pathological cases.
|
/// Callers should check against this constant before invoking `wrap_line_chars`
|
||||||
|
/// to avoid pathological cases on oversized lines.
|
||||||
pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024;
|
pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Column spacing for tab stop alignment.
|
||||||
|
const TAB_WIDTH: usize = 4;
|
||||||
|
|
||||||
/// Split a line into chunks of exactly `width` display columns.
|
/// Split a line into chunks of exactly `width` display columns.
|
||||||
/// For a log viewer, we want character-level wrapping, not word-level.
|
/// For a log viewer, we want character-level wrapping, not word-level.
|
||||||
/// Uses `unicode-width` for correct CJK/emoji/zero-width handling.
|
/// Uses `unicode-width` for correct CJK/emoji/zero-width handling.
|
||||||
|
/// Tab characters expand to the next tab-stop boundary and split across
|
||||||
|
/// rows when the expansion exceeds the remaining width.
|
||||||
pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
||||||
use unicode_width::UnicodeWidthChar;
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
@@ -18,29 +24,40 @@ pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
|||||||
let mut row = String::new();
|
let mut row = String::new();
|
||||||
let mut col = 0;
|
let mut col = 0;
|
||||||
for ch in line.chars() {
|
for ch in line.chars() {
|
||||||
let w = if ch == '\t' {
|
|
||||||
4
|
|
||||||
} else if ch.is_control() {
|
|
||||||
// Control characters (except tab): width 0, still pushed to preserve content.
|
|
||||||
// Visible rendering is the caller's responsibility.
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
ch.width().unwrap_or(0)
|
|
||||||
};
|
|
||||||
if col + w > width && !row.is_empty() {
|
|
||||||
result.push(std::mem::take(&mut row));
|
|
||||||
col = 0;
|
|
||||||
}
|
|
||||||
if ch == '\t' {
|
if ch == '\t' {
|
||||||
row.push_str(" ");
|
let tab_stop = TAB_WIDTH - (col % TAB_WIDTH);
|
||||||
col += 4;
|
let mut remaining = tab_stop;
|
||||||
|
while remaining > 0 {
|
||||||
|
let avail = width.saturating_sub(col);
|
||||||
|
let fill = remaining.min(avail);
|
||||||
|
for _ in 0..fill {
|
||||||
|
row.push(' ');
|
||||||
|
}
|
||||||
|
col += fill;
|
||||||
|
remaining -= fill;
|
||||||
|
if col >= width {
|
||||||
|
result.push(std::mem::take(&mut row));
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let w = if ch.is_control() {
|
||||||
|
// Control characters (except tab): width 0, still pushed to preserve content.
|
||||||
|
// Visible rendering is the caller's responsibility.
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
ch.width().unwrap_or(0)
|
||||||
|
};
|
||||||
|
if col + w > width && !row.is_empty() {
|
||||||
|
result.push(std::mem::take(&mut row));
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
row.push(ch);
|
row.push(ch);
|
||||||
col += w;
|
col += w;
|
||||||
}
|
if col >= width {
|
||||||
if col >= width {
|
result.push(std::mem::take(&mut row));
|
||||||
result.push(std::mem::take(&mut row));
|
col = 0;
|
||||||
col = 0;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !row.is_empty() {
|
if !row.is_empty() {
|
||||||
@@ -107,7 +124,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_wrap_with_tab() {
|
fn test_wrap_with_tab() {
|
||||||
let result = wrap_line_chars("a\tb", 4);
|
let result = wrap_line_chars("a\tb", 4);
|
||||||
assert_eq!(result, vec!["a", " ", "b"]);
|
assert_eq!(result, vec!["a ", "b"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -187,4 +204,31 @@ mod tests {
|
|||||||
let result = wrap_line_chars("你好", 1);
|
let result = wrap_line_chars("你好", 1);
|
||||||
assert_eq!(result, vec!["你", "好"]);
|
assert_eq!(result, vec!["你", "好"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_narrow_width() {
|
||||||
|
let result = wrap_line_chars("\t", 2);
|
||||||
|
assert_eq!(result, vec![" ", " "]);
|
||||||
|
let result = wrap_line_chars("\t", 1);
|
||||||
|
assert_eq!(result, vec![" ", " ", " ", " "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_stop_alignment() {
|
||||||
|
assert_eq!(wrap_line_chars("a\tb", 8), vec!["a b"]);
|
||||||
|
assert_eq!(wrap_line_chars("ab\t", 4), vec!["ab "]);
|
||||||
|
assert_eq!(wrap_line_chars("abc\tb", 8), vec!["abc b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_at_line_boundary() {
|
||||||
|
let result = wrap_line_chars("a\tb", 4);
|
||||||
|
assert_eq!(result, vec!["a ", "b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_regression_ab_tab() {
|
||||||
|
let result = wrap_line_chars("ab\t", 4);
|
||||||
|
assert_eq!(result, vec!["ab "]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,13 +100,21 @@ fn detect_level_from_text(line: &str) -> Option<LogLevel> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── is_ident_char ─────────────────────────────────────────────────────────
|
||||||
|
/// Whether a byte looks like an ASCII identifier continuation character
|
||||||
|
/// (letter / digit / underscore). Log-level keywords must NOT be adjacent to
|
||||||
|
/// such characters to count as a valid word boundary.
|
||||||
|
fn is_ident_char(b: u8) -> bool {
|
||||||
|
b.is_ascii_alphanumeric() || b == b'_'
|
||||||
|
}
|
||||||
|
|
||||||
// ─── is_word_boundary ───────────────────────────────────────────────────────
|
// ─── is_word_boundary ───────────────────────────────────────────────────────
|
||||||
/// Check that the match at `start..start+len` is surrounded by non-alphabetic
|
/// Check that the match at `start..start+len` is surrounded by non-identifier
|
||||||
/// characters (or the string edge).
|
/// characters (or the string edge).
|
||||||
fn is_word_boundary(text: &str, start: usize, len: usize) -> bool {
|
fn is_word_boundary(text: &str, start: usize, len: usize) -> bool {
|
||||||
let before_ok = start == 0 || !text.as_bytes()[start - 1].is_ascii_alphabetic();
|
let before_ok = start == 0 || !is_ident_char(text.as_bytes()[start - 1]);
|
||||||
let after_idx = start + len;
|
let after_idx = start + len;
|
||||||
let after_ok = after_idx >= text.len() || !text.as_bytes()[after_idx].is_ascii_alphabetic();
|
let after_ok = after_idx >= text.len() || !is_ident_char(text.as_bytes()[after_idx]);
|
||||||
before_ok && after_ok
|
before_ok && after_ok
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,4 +217,36 @@ mod tests {
|
|||||||
let line = format!("{prefix} ERROR something");
|
let line = format!("{prefix} ERROR something");
|
||||||
assert_eq!(detect_level(&line), Some(LogLevel::Error));
|
assert_eq!(detect_level(&line), Some(LogLevel::Error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_boundary_rejects_trailing_digits() {
|
||||||
|
assert_eq!(detect_level("ERROR123"), None);
|
||||||
|
assert_eq!(detect_level("WARN2: bad"), None);
|
||||||
|
assert_eq!(detect_level("ERR2"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_boundary_rejects_underscore() {
|
||||||
|
assert_eq!(detect_level("INFO_foo"), None);
|
||||||
|
assert_eq!(detect_level("DBG_value=5"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_boundary_rejects_leading_digits_and_underscore() {
|
||||||
|
assert_eq!(detect_level("123ERROR: fail"), None);
|
||||||
|
assert_eq!(detect_level("foo_ERROR: fail"), None);
|
||||||
|
assert_eq!(detect_level("1WRN"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_boundary_accepts_valid_suffixes() {
|
||||||
|
assert_eq!(detect_level("ERROR: fail"), Some(LogLevel::Error));
|
||||||
|
assert_eq!(detect_level("[ERROR] fail"), Some(LogLevel::Error));
|
||||||
|
assert_eq!(detect_level("ERROR fail"), Some(LogLevel::Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_boundary_camel_case_regression() {
|
||||||
|
assert_eq!(detect_level("errorLevel"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ anyhow.workspace = true
|
|||||||
log-viewer-core.workspace = true
|
log-viewer-core.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
crossbeam-channel.workspace = true
|
crossbeam-channel.workspace = true
|
||||||
|
unicode-width.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use log_viewer_core::io::progressive_reader::{
|
|||||||
IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height,
|
IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height,
|
||||||
spawn_indexer,
|
spawn_indexer,
|
||||||
};
|
};
|
||||||
use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars};
|
use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars, MAX_WRAP_INPUT_LEN};
|
||||||
use log_viewer_core::types::LogLevel;
|
use log_viewer_core::types::LogLevel;
|
||||||
use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher};
|
use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher};
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
use crate::color::AVAILABLE_COLORS;
|
use crate::color::AVAILABLE_COLORS;
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ pub struct App {
|
|||||||
// Settings panel state
|
// Settings panel state
|
||||||
pub(crate) settings_cursor: usize,
|
pub(crate) settings_cursor: usize,
|
||||||
pub(crate) settings_draft: ColorConfig,
|
pub(crate) settings_draft: ColorConfig,
|
||||||
|
pub(crate) settings_error: Option<String>,
|
||||||
|
|
||||||
// File watcher
|
// File watcher
|
||||||
file_watcher: Option<FileWatcher>,
|
file_watcher: Option<FileWatcher>,
|
||||||
@@ -141,6 +143,7 @@ impl App {
|
|||||||
color_config: ColorConfig::default(),
|
color_config: ColorConfig::default(),
|
||||||
settings_cursor: 0,
|
settings_cursor: 0,
|
||||||
settings_draft: ColorConfig::default(),
|
settings_draft: ColorConfig::default(),
|
||||||
|
settings_error: None,
|
||||||
file_watcher: None,
|
file_watcher: None,
|
||||||
reload_after_loading: false,
|
reload_after_loading: false,
|
||||||
}
|
}
|
||||||
@@ -201,12 +204,33 @@ impl App {
|
|||||||
/// Compute a single line's viewport entry (wrapped rows + level + height).
|
/// Compute a single line's viewport entry (wrapped rows + level + height).
|
||||||
fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry {
|
fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry {
|
||||||
let raw = self.get_line(line).unwrap_or_default();
|
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 level = log_viewer_core::parser::level::detect_level(&raw);
|
||||||
let display_text = if self.json_format {
|
let display_text = if self.json_format {
|
||||||
format_json_line(&raw)
|
format_json_line(&raw)
|
||||||
} else {
|
} else {
|
||||||
raw
|
raw
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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();
|
let mut wrapped = Vec::new();
|
||||||
for sub_line in display_text.split('\n') {
|
for sub_line in display_text.split('\n') {
|
||||||
wrapped.extend(wrap_line_chars(sub_line, width));
|
wrapped.extend(wrap_line_chars(sub_line, width));
|
||||||
@@ -222,11 +246,23 @@ impl App {
|
|||||||
/// Compute visual height for a single line without storing it.
|
/// Compute visual height for a single line without storing it.
|
||||||
fn compute_visual_height(&self, line: usize, width: usize) -> usize {
|
fn compute_visual_height(&self, line: usize, width: usize) -> usize {
|
||||||
let raw = self.get_line(line).unwrap_or_default();
|
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 = if self.json_format {
|
let display_text = if self.json_format {
|
||||||
format_json_line(&raw)
|
format_json_line(&raw)
|
||||||
} else {
|
} else {
|
||||||
raw
|
raw
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Guard 2: post-format expansion.
|
||||||
|
if display_text.len() > MAX_WRAP_INPUT_LEN {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
let mut height = 0;
|
let mut height = 0;
|
||||||
for sub_line in display_text.split('\n') {
|
for sub_line in display_text.split('\n') {
|
||||||
height += wrap_line_chars(sub_line, width).len();
|
height += wrap_line_chars(sub_line, width).len();
|
||||||
@@ -246,7 +282,6 @@ impl App {
|
|||||||
/// Returns (start_logical, offset_in_line) for rendering.
|
/// Returns (start_logical, offset_in_line) for rendering.
|
||||||
pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) {
|
pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) {
|
||||||
let viewport_height = self.content_height as usize;
|
let viewport_height = self.content_height as usize;
|
||||||
let v_offset = self.v_offset;
|
|
||||||
|
|
||||||
if !self.is_loaded() || width == 0 || viewport_height == 0 {
|
if !self.is_loaded() || width == 0 || viewport_height == 0 {
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
@@ -268,7 +303,7 @@ impl App {
|
|||||||
|
|
||||||
// Find start logical line from v_offset
|
// Find start logical line from v_offset
|
||||||
let (start_logical, offset_in_line) = if self.is_loading() {
|
let (start_logical, offset_in_line) = if self.is_loading() {
|
||||||
(v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset)
|
(self.v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset)
|
||||||
} else {
|
} else {
|
||||||
self.find_logical_line_at_visual_row(self.v_offset, width)
|
self.find_logical_line_at_visual_row(self.v_offset, width)
|
||||||
};
|
};
|
||||||
@@ -660,18 +695,22 @@ impl App {
|
|||||||
self.json_format = !self.json_format;
|
self.json_format = !self.json_format;
|
||||||
self.viewport_cache.invalidate();
|
self.viewport_cache.invalidate();
|
||||||
let width = self.viewport_cache.width;
|
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 {
|
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||||||
reader.invalidate_visual_height_index();
|
reader.invalidate_visual_height_index();
|
||||||
if width > 0 {
|
if width > 0 {
|
||||||
reader.start_visual_height_rebuild(width, self.json_format);
|
reader.start_visual_height_rebuild(width, self.json_format);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.v_offset = new_offset;
|
||||||
|
self.v_sub_offset = new_sub;
|
||||||
self.last_g_press = None;
|
self.last_g_press = None;
|
||||||
}
|
}
|
||||||
KeyCode::Char('s') | KeyCode::Char('S')
|
KeyCode::Char('s') | KeyCode::Char('S')
|
||||||
if !key.modifiers.contains(KeyModifiers::CONTROL) =>
|
if !key.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||||
{
|
{
|
||||||
self.settings_draft = self.color_config.clone();
|
self.settings_draft = self.color_config.clone();
|
||||||
|
self.settings_error = None;
|
||||||
self.mode = AppMode::Settings;
|
self.mode = AppMode::Settings;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -684,12 +723,22 @@ impl App {
|
|||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
|
self.settings_error = None;
|
||||||
self.mode = AppMode::Normal;
|
self.mode = AppMode::Normal;
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
self.color_config = self.settings_draft.clone();
|
let draft = self.settings_draft.clone();
|
||||||
let _ = self.color_config.save();
|
match draft.save() {
|
||||||
self.mode = AppMode::Normal;
|
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 => {
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
if self.settings_cursor < 5 {
|
if self.settings_cursor < 5 {
|
||||||
@@ -722,7 +771,7 @@ impl App {
|
|||||||
if forward {
|
if forward {
|
||||||
(p + 1) % colors.len()
|
(p + 1) % colors.len()
|
||||||
} else {
|
} else {
|
||||||
p.saturating_sub(1).min(colors.len() - 1)
|
if p == 0 { colors.len() - 1 } else { p - 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -749,6 +798,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_color(&mut self, level_idx: usize, color_name: &str) {
|
fn set_color(&mut self, level_idx: usize, color_name: &str) {
|
||||||
|
self.settings_error = None;
|
||||||
match level_idx {
|
match level_idx {
|
||||||
0 => self.settings_draft.error = color_name.to_string(),
|
0 => self.settings_draft.error = color_name.to_string(),
|
||||||
1 => self.settings_draft.warn = color_name.to_string(),
|
1 => self.settings_draft.warn = color_name.to_string(),
|
||||||
@@ -824,6 +874,25 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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`.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_visual_height_index(&mut self, width: usize) {
|
fn ensure_visual_height_index(&mut self, width: usize) {
|
||||||
let needs_rebuild = match self.get_visual_height_index() {
|
let needs_rebuild = match self.get_visual_height_index() {
|
||||||
Some(idx) => !idx.is_valid_for(self.json_format, width),
|
Some(idx) => !idx.is_valid_for(self.json_format, width),
|
||||||
@@ -831,10 +900,13 @@ impl App {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if needs_rebuild {
|
if needs_rebuild {
|
||||||
|
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
|
||||||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||||||
reader.invalidate_visual_height_index();
|
reader.invalidate_visual_height_index();
|
||||||
reader.start_visual_height_rebuild(width, self.json_format);
|
reader.start_visual_height_rebuild(width, self.json_format);
|
||||||
}
|
}
|
||||||
|
self.v_offset = new_offset;
|
||||||
|
self.v_sub_offset = new_sub;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,6 +989,7 @@ impl App {
|
|||||||
|
|
||||||
fn handle_file_appended(&mut self) {
|
fn handle_file_appended(&mut self) {
|
||||||
let width = self.get_content_width();
|
let width = self.get_content_width();
|
||||||
|
let rebased = self.rebase_offset_for_invalidate();
|
||||||
match &mut self.loading_state {
|
match &mut self.loading_state {
|
||||||
AppLoadingState::Ready { reader } => {
|
AppLoadingState::Ready { reader } => {
|
||||||
let old_reader_line_count = reader.line_count();
|
let old_reader_line_count = reader.line_count();
|
||||||
@@ -967,6 +1040,9 @@ impl App {
|
|||||||
index.extend_from_heights(&new_heights);
|
index.extend_from_heights(&new_heights);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let (new_offset, new_sub) = rebased;
|
||||||
|
self.v_offset = new_offset;
|
||||||
|
self.v_sub_offset = new_sub;
|
||||||
reader.invalidate_visual_height_index();
|
reader.invalidate_visual_height_index();
|
||||||
reader.start_visual_height_rebuild(width, self.json_format);
|
reader.start_visual_height_rebuild(width, self.json_format);
|
||||||
}
|
}
|
||||||
@@ -975,9 +1051,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => {
|
Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => {
|
||||||
let _ = reader.save_cache();
|
let _ = reader.save_cache();
|
||||||
|
let (new_offset, _new_sub) = rebased;
|
||||||
reader.invalidate_visual_height_index();
|
reader.invalidate_visual_height_index();
|
||||||
reader.start_visual_height_rebuild(width, self.json_format);
|
reader.start_visual_height_rebuild(width, self.json_format);
|
||||||
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
|
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
|
||||||
|
self.v_offset = new_offset;
|
||||||
self.v_sub_offset = 0;
|
self.v_sub_offset = 0;
|
||||||
self.viewport_cache.invalidate();
|
self.viewport_cache.invalidate();
|
||||||
self.clamp_v_offset();
|
self.clamp_v_offset();
|
||||||
@@ -994,12 +1072,14 @@ impl App {
|
|||||||
|
|
||||||
fn reload_ready_reader(&mut self) {
|
fn reload_ready_reader(&mut self) {
|
||||||
let width = self.get_content_width();
|
let width = self.get_content_width();
|
||||||
|
let (new_offset, _new_sub) = self.rebase_offset_for_invalidate();
|
||||||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||||||
let _ = reader.reload();
|
let _ = reader.reload();
|
||||||
let _ = reader.save_cache();
|
let _ = reader.save_cache();
|
||||||
reader.invalidate_visual_height_index();
|
reader.invalidate_visual_height_index();
|
||||||
reader.start_visual_height_rebuild(width, self.json_format);
|
reader.start_visual_height_rebuild(width, self.json_format);
|
||||||
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
|
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
|
||||||
|
self.v_offset = new_offset;
|
||||||
self.v_sub_offset = 0;
|
self.v_sub_offset = 0;
|
||||||
self.viewport_cache.invalidate();
|
self.viewport_cache.invalidate();
|
||||||
self.clamp_v_offset();
|
self.clamp_v_offset();
|
||||||
@@ -1031,10 +1111,23 @@ impl App {
|
|||||||
} = &mut reader.state
|
} = &mut reader.state
|
||||||
{
|
{
|
||||||
*visual_height_index = Some(index);
|
*visual_height_index = Some(index);
|
||||||
self.viewport_cache.invalidate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Recalibrate v_offset from logical → visual-row now that VHI is available.
|
||||||
|
// Must be outside the `reader` borrow above.
|
||||||
|
if self.get_visual_height_index().is_some() {
|
||||||
|
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_v_offset();
|
||||||
|
self.viewport_cache.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
// Poll main indexer (Loading state)
|
// Poll main indexer (Loading state)
|
||||||
let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty);
|
let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty);
|
||||||
@@ -1079,9 +1172,12 @@ impl App {
|
|||||||
|
|
||||||
// Gutter width changes (~N → N) shift content_width, so any
|
// Gutter width changes (~N → N) shift content_width, so any
|
||||||
// VisualHeightIndex built with the old width is stale.
|
// 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 {
|
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||||||
reader.invalidate_visual_height_index();
|
reader.invalidate_visual_height_index();
|
||||||
}
|
}
|
||||||
|
self.v_offset = new_offset;
|
||||||
|
self.v_sub_offset = new_sub;
|
||||||
|
|
||||||
if self.reload_after_loading {
|
if self.reload_after_loading {
|
||||||
self.reload_after_loading = false;
|
self.reload_after_loading = false;
|
||||||
@@ -1106,6 +1202,43 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRUNCATE_TAB_WIDTH: usize = 4;
|
||||||
|
|
||||||
|
fn truncate_to_columns(s: &str, max_cols: usize) -> String {
|
||||||
|
if max_cols == 0 || s.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut col = 0;
|
||||||
|
|
||||||
|
for ch in s.chars() {
|
||||||
|
if ch == '\t' {
|
||||||
|
let tab_stop = TRUNCATE_TAB_WIDTH - (col % TRUNCATE_TAB_WIDTH);
|
||||||
|
if col + tab_stop > max_cols {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for _ in 0..tab_stop {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
col += tab_stop;
|
||||||
|
} else {
|
||||||
|
let w = if ch.is_control() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
ch.width().unwrap_or(0)
|
||||||
|
};
|
||||||
|
if col + w > max_cols {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(ch);
|
||||||
|
col += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1848,6 +1981,14 @@ plain text line
|
|||||||
|
|
||||||
app.handle_key(make_key(crossterm::event::KeyCode::Left));
|
app.handle_key(make_key(crossterm::event::KeyCode::Left));
|
||||||
assert_eq!(app.settings_draft.error, "red");
|
assert_eq!(app.settings_draft.error, "red");
|
||||||
|
|
||||||
|
// backward wrap: red (index 0) → white (index 7)
|
||||||
|
app.handle_key(make_key(crossterm::event::KeyCode::Left));
|
||||||
|
assert_eq!(app.settings_draft.error, "white");
|
||||||
|
|
||||||
|
// forward wrap: white (index 7) → red (index 0)
|
||||||
|
app.handle_key(make_key(crossterm::event::KeyCode::Right));
|
||||||
|
assert_eq!(app.settings_draft.error, "red");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1924,6 +2065,71 @@ plain text line
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_save_failure_stays_in_settings() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let original = app.color_config.clone();
|
||||||
|
|
||||||
|
let impossible_path = std::path::PathBuf::from("/nonexistent/deep/nested/config.toml");
|
||||||
|
enter_settings(&mut app);
|
||||||
|
app.handle_key(make_key(crossterm::event::KeyCode::Right));
|
||||||
|
assert_ne!(app.settings_draft.error, original.error);
|
||||||
|
|
||||||
|
let draft = app.settings_draft.clone();
|
||||||
|
let result = draft.save_to(&impossible_path);
|
||||||
|
assert!(result.is_err(), "Save to impossible path should fail");
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("config") || err.to_string().contains("creating"),
|
||||||
|
"Error should mention config or creating directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_error_cleared_on_edit() {
|
||||||
|
let mut app = App::new();
|
||||||
|
enter_settings(&mut app);
|
||||||
|
|
||||||
|
app.settings_error = Some("test error".to_string());
|
||||||
|
|
||||||
|
app.handle_key(make_key(crossterm::event::KeyCode::Right));
|
||||||
|
assert!(
|
||||||
|
app.settings_error.is_none(),
|
||||||
|
"Editing a setting should clear settings_error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_error_cleared_on_esc() {
|
||||||
|
let mut app = App::new();
|
||||||
|
enter_settings(&mut app);
|
||||||
|
|
||||||
|
app.settings_error = Some("test error".to_string());
|
||||||
|
app.handle_key(make_key(crossterm::event::KeyCode::Esc));
|
||||||
|
|
||||||
|
assert_eq!(app.mode, AppMode::Normal);
|
||||||
|
assert!(
|
||||||
|
app.settings_error.is_none(),
|
||||||
|
"Esc should clear settings_error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_error_cleared_on_enter_settings() {
|
||||||
|
let mut app = App::new();
|
||||||
|
enter_settings(&mut app);
|
||||||
|
app.settings_error = Some("stale error".to_string());
|
||||||
|
app.handle_key(make_key(crossterm::event::KeyCode::Esc));
|
||||||
|
assert!(app.settings_error.is_none());
|
||||||
|
|
||||||
|
enter_settings(&mut app);
|
||||||
|
assert!(
|
||||||
|
app.settings_error.is_none(),
|
||||||
|
"Entering settings should clear stale errors"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_color_change_affects_build_line_spans() {
|
fn test_color_change_affects_build_line_spans() {
|
||||||
use crate::ui::build_line_spans;
|
use crate::ui::build_line_spans;
|
||||||
@@ -2538,6 +2744,42 @@ plain text line
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for issue #24:
|
||||||
|
/// ensure_viewport_cache must use the updated self.v_offset after params_changed
|
||||||
|
/// recalculates it, not a stale local captured before the change block.
|
||||||
|
#[test]
|
||||||
|
fn test_loading_viewport_cache_uses_updated_v_offset_on_params_changed() {
|
||||||
|
let content: String = (0..200).map(|i| format!("line{i}\n")).collect();
|
||||||
|
let path = make_temp_file(&content);
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.load_file(path.to_str().unwrap()).unwrap();
|
||||||
|
assert!(app.is_loading(), "should be in Loading state");
|
||||||
|
|
||||||
|
app.content_height = 10;
|
||||||
|
app.ensure_viewport_cache(80);
|
||||||
|
|
||||||
|
app.v_offset = 90;
|
||||||
|
app.cursor_line = 100;
|
||||||
|
|
||||||
|
let new_width = 40;
|
||||||
|
app.ensure_viewport_cache(new_width);
|
||||||
|
|
||||||
|
let recomputed_offset = app.v_offset;
|
||||||
|
assert_ne!(
|
||||||
|
recomputed_offset, 90,
|
||||||
|
"v_offset should have been recalculated by params_changed block, still 90"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.viewport_cache.logical_start, recomputed_offset.min(app.total_lines().saturating_sub(1)),
|
||||||
|
"logical_start should match the updated v_offset"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
cleanup(&path);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
fn install_vhi(app: &mut App, heights: &[usize]) {
|
fn install_vhi(app: &mut App, heights: &[usize]) {
|
||||||
let width = app.get_content_width();
|
let width = app.get_content_width();
|
||||||
let json_format = app.json_format;
|
let json_format = app.json_format;
|
||||||
@@ -3091,4 +3333,369 @@ plain text line
|
|||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Issue #25 regression tests ──────────────────────────────────
|
||||||
|
// Ready state without VHI must keep v_offset self-consistent.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rebase_offset_converts_visual_to_logical_with_sub() {
|
||||||
|
let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
|
||||||
|
let path = make_temp_file(content);
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
app.content_height = 10;
|
||||||
|
|
||||||
|
// 10 lines × 3 visual rows each = 30 visual rows
|
||||||
|
install_vhi(&mut app, &[3usize; 10]);
|
||||||
|
|
||||||
|
// v_offset=7 → visual row 7 is inside line2 (rows 6-8), sub=1
|
||||||
|
app.v_offset = 7;
|
||||||
|
app.v_sub_offset = 0;
|
||||||
|
|
||||||
|
let (logical, sub) = app.rebase_offset_for_invalidate();
|
||||||
|
assert_eq!(logical, 2, "visual row 7 → logical line 2 (rows 6-8)");
|
||||||
|
assert_eq!(sub, 1, "visual row 7 is sub-row 1 within line 2");
|
||||||
|
|
||||||
|
// v_offset=0 → line 0, sub=0
|
||||||
|
app.v_offset = 0;
|
||||||
|
let (logical, sub) = app.rebase_offset_for_invalidate();
|
||||||
|
assert_eq!(logical, 0);
|
||||||
|
assert_eq!(sub, 0);
|
||||||
|
|
||||||
|
// v_offset=5 → line1 (rows 3-5), sub=2
|
||||||
|
app.v_offset = 5;
|
||||||
|
let (logical, sub) = app.rebase_offset_for_invalidate();
|
||||||
|
assert_eq!(logical, 1, "visual row 5 → logical line 1 (rows 3-5)");
|
||||||
|
assert_eq!(sub, 2);
|
||||||
|
|
||||||
|
cleanup(&path);
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rebase_offset_passthrough_when_no_vhi() {
|
||||||
|
let content = "line0\nline1\nline2\n";
|
||||||
|
let path = make_temp_file(content);
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
|
||||||
|
// No VHI installed — rebase should return current values unchanged
|
||||||
|
app.v_offset = 42;
|
||||||
|
app.v_sub_offset = 3;
|
||||||
|
let (logical, sub) = app.rebase_offset_for_invalidate();
|
||||||
|
assert_eq!(logical, 42);
|
||||||
|
assert_eq!(sub, 3);
|
||||||
|
|
||||||
|
cleanup(&path);
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_toggle_rebases_offset_before_invalidate() {
|
||||||
|
let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
|
||||||
|
let path = make_temp_file(content);
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
app.content_height = 10;
|
||||||
|
app.content_width = 20;
|
||||||
|
|
||||||
|
install_vhi(&mut app, &[3usize; 10]);
|
||||||
|
|
||||||
|
// Scroll to visual row 7 → line2 sub-row 1
|
||||||
|
app.v_offset = 7;
|
||||||
|
app.cursor_line = 2;
|
||||||
|
app.v_sub_offset = 0;
|
||||||
|
|
||||||
|
// Simulate Tab toggle: rebase, then invalidate
|
||||||
|
let rebased = app.rebase_offset_for_invalidate();
|
||||||
|
let (new_offset, new_sub) = rebased;
|
||||||
|
app.v_offset = new_offset;
|
||||||
|
app.v_sub_offset = new_sub;
|
||||||
|
|
||||||
|
assert_eq!(app.v_offset, 2, "v_offset should be logical line 2 after rebase");
|
||||||
|
assert_eq!(app.v_sub_offset, 1, "v_sub_offset should preserve sub-row 1");
|
||||||
|
|
||||||
|
// Key invariant: v_offset is now a valid logical line number,
|
||||||
|
// not a stale visual-row offset that would cause a jump.
|
||||||
|
// Before the fix, v_offset would still be 7 (visual) treated as logical → jump.
|
||||||
|
assert!(
|
||||||
|
app.v_offset < app.total_lines(),
|
||||||
|
"v_offset ({}) must be a valid logical line index < {}",
|
||||||
|
app.v_offset, app.total_lines()
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup(&path);
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vhi_rebuild_recalibrates_from_viewport_top() {
|
||||||
|
let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
|
||||||
|
let path = make_temp_file(content);
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
app.content_height = 10;
|
||||||
|
app.content_width = 20;
|
||||||
|
|
||||||
|
// Install VHI with varying heights
|
||||||
|
install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]);
|
||||||
|
|
||||||
|
// Scroll to visual row 8 → line2 (rows 6-8), sub=2
|
||||||
|
app.v_offset = 8;
|
||||||
|
app.cursor_line = 2;
|
||||||
|
app.v_sub_offset = 0;
|
||||||
|
|
||||||
|
// Rebase (simulates invalidate)
|
||||||
|
let (logical_top, sub) = app.rebase_offset_for_invalidate();
|
||||||
|
assert_eq!(logical_top, 2);
|
||||||
|
assert_eq!(sub, 2);
|
||||||
|
|
||||||
|
// Simulate what happens: v_offset becomes logical, VHI cleared
|
||||||
|
app.v_offset = logical_top;
|
||||||
|
app.v_sub_offset = sub;
|
||||||
|
|
||||||
|
// Now simulate VHI rebuild completion — the recalibration block
|
||||||
|
// Install a fresh VHI (same heights, simulates rebuild result)
|
||||||
|
install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]);
|
||||||
|
|
||||||
|
// The recalibration logic from poll_background_indexer
|
||||||
|
let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1));
|
||||||
|
let sub = app.v_sub_offset;
|
||||||
|
let first_visual = app.cursor_to_first_visual_row(logical_top);
|
||||||
|
let line_height = app
|
||||||
|
.get_visual_height_index()
|
||||||
|
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
|
||||||
|
app.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1)));
|
||||||
|
app.v_sub_offset = 0;
|
||||||
|
|
||||||
|
// line2 starts at visual row 6, sub was 2 → visual row 8
|
||||||
|
assert_eq!(app.v_offset, 8, "v_offset should map back to visual row 8");
|
||||||
|
assert_eq!(app.v_sub_offset, 0, "v_sub_offset should be 0 after recalibration");
|
||||||
|
|
||||||
|
// Scrolling should work normally with VHI
|
||||||
|
app.scroll_down_line();
|
||||||
|
assert_eq!(app.v_offset, 9, "scroll down should advance visual row");
|
||||||
|
|
||||||
|
cleanup(&path);
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vhi_rebuild_clamps_sub_to_new_line_height() {
|
||||||
|
let content = "line0\nline1\nline2\n";
|
||||||
|
let path = make_temp_file(content);
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
app.content_height = 24;
|
||||||
|
app.content_width = 20;
|
||||||
|
|
||||||
|
// Line heights: line0=1, line1=5 (wraps), line2=1
|
||||||
|
install_vhi(&mut app, &[1, 5, 1]);
|
||||||
|
|
||||||
|
// Position at line1 sub-row 4 (last sub-row of 5-row line)
|
||||||
|
app.v_offset = 5;
|
||||||
|
app.cursor_line = 1;
|
||||||
|
app.v_sub_offset = 0;
|
||||||
|
|
||||||
|
// Rebase
|
||||||
|
let (logical_top, sub) = app.rebase_offset_for_invalidate();
|
||||||
|
assert_eq!(logical_top, 1, "visual row 5 → line 1 (rows 1-5)");
|
||||||
|
assert_eq!(sub, 4, "sub-row 4 within line 1");
|
||||||
|
|
||||||
|
app.v_offset = logical_top;
|
||||||
|
app.v_sub_offset = sub;
|
||||||
|
|
||||||
|
// Simulate VHI rebuild with SHRUNK line height (e.g., JSON toggle)
|
||||||
|
// line1 now only 2 visual rows instead of 5
|
||||||
|
install_vhi(&mut app, &[1, 2, 1]);
|
||||||
|
|
||||||
|
// Recalibration should clamp sub=4 to new height-1=1
|
||||||
|
let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1));
|
||||||
|
let sub = app.v_sub_offset;
|
||||||
|
let first_visual = app.cursor_to_first_visual_row(logical_top);
|
||||||
|
let line_height = app
|
||||||
|
.get_visual_height_index()
|
||||||
|
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
|
||||||
|
let clamped_sub = sub.min(line_height.saturating_sub(1));
|
||||||
|
app.v_offset = first_visual.saturating_add(clamped_sub);
|
||||||
|
app.v_sub_offset = 0;
|
||||||
|
|
||||||
|
// line1 starts at visual row 1, clamped sub=1 → visual row 2
|
||||||
|
assert_eq!(app.v_offset, 2, "v_offset should be clamped to visual row 2 (line1 row 1 of 2)");
|
||||||
|
assert_eq!(app.v_sub_offset, 0);
|
||||||
|
|
||||||
|
cleanup(&path);
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_loading_to_ready_rebases_visual_offset() {
|
||||||
|
let content = "line0\nline1\nline2\nline3\nline4\n";
|
||||||
|
let path = make_temp_file(content);
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
app.content_height = 10;
|
||||||
|
|
||||||
|
// Install VHI: 2 rows per line
|
||||||
|
install_vhi(&mut app, &[2usize; 5]);
|
||||||
|
|
||||||
|
// Scroll to visual row 3 → line1 sub-row 1
|
||||||
|
app.v_offset = 3;
|
||||||
|
app.cursor_line = 1;
|
||||||
|
app.v_sub_offset = 0;
|
||||||
|
|
||||||
|
// Simulate Loading→Ready invalidation rebase
|
||||||
|
let (new_offset, new_sub) = app.rebase_offset_for_invalidate();
|
||||||
|
assert_eq!(new_offset, 1, "visual row 3 → line 1");
|
||||||
|
assert_eq!(new_sub, 1, "sub-row 1 within line 1");
|
||||||
|
|
||||||
|
// Apply rebased values and clear VHI (simulate invalidate)
|
||||||
|
app.v_offset = new_offset;
|
||||||
|
app.v_sub_offset = new_sub;
|
||||||
|
if let AppLoadingState::Ready { reader } = &mut app.loading_state {
|
||||||
|
reader.invalidate_visual_height_index();
|
||||||
|
}
|
||||||
|
|
||||||
|
// VHI is now None → scroll_down_line uses else branch (v_sub_offset path)
|
||||||
|
// "line1" at width=80 → compute_visual_height=1, sub=1+1 >= 1 → advance
|
||||||
|
app.scroll_down_line();
|
||||||
|
assert_eq!(app.v_offset, 2, "should advance to line 2 (line1 height=1, sub overflow)");
|
||||||
|
assert_eq!(app.v_sub_offset, 0);
|
||||||
|
|
||||||
|
cleanup(&path);
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_line_entry_oversized_raw_guard() {
|
||||||
|
let long_line = "x".repeat(MAX_WRAP_INPUT_LEN + 1);
|
||||||
|
let path = make_temp_file(&format!("short\n{}\n", long_line));
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
assert!(app.is_loaded());
|
||||||
|
|
||||||
|
let entry = app.compute_line_entry(1, 80);
|
||||||
|
assert_eq!(entry.visual_height, 1, "oversized line should have height 1");
|
||||||
|
assert!(entry.level.is_none(), "oversized raw line should skip detect_level");
|
||||||
|
assert_eq!(entry.wrapped_rows.len(), 1);
|
||||||
|
assert!(
|
||||||
|
entry.wrapped_rows[0].len() <= 80,
|
||||||
|
"preview should be bounded to width"
|
||||||
|
);
|
||||||
|
|
||||||
|
let short_entry = app.compute_line_entry(0, 80);
|
||||||
|
assert_eq!(short_entry.visual_height, 1);
|
||||||
|
assert!(short_entry.level.is_none(), "'short' has no log level");
|
||||||
|
});
|
||||||
|
cleanup(&path);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_visual_height_oversized_raw_guard() {
|
||||||
|
let long_line = "a".repeat(MAX_WRAP_INPUT_LEN + 100);
|
||||||
|
let path = make_temp_file(&format!("hi\n{}\n", long_line));
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
assert!(app.is_loaded());
|
||||||
|
|
||||||
|
assert_eq!(app.compute_visual_height(0, 80), 1, "normal line height=1");
|
||||||
|
assert_eq!(app.compute_visual_height(1, 80), 1, "oversized line height=1");
|
||||||
|
});
|
||||||
|
cleanup(&path);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_line_entry_post_format_guard() {
|
||||||
|
// Build a JSON object whose raw form is just under MAX_WRAP_INPUT_LEN
|
||||||
|
// but whose pretty-printed form (with added spaces around ':') exceeds it.
|
||||||
|
let inner = "x".repeat(MAX_WRAP_INPUT_LEN - 11);
|
||||||
|
let json_line = format!(r#"{{"msg":"{}"}}"#, inner);
|
||||||
|
assert!(
|
||||||
|
json_line.len() < MAX_WRAP_INPUT_LEN,
|
||||||
|
"raw JSON should be under limit: got {}",
|
||||||
|
json_line.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let path = make_temp_file(&format!("{}\n", json_line));
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
app.json_format = true;
|
||||||
|
|
||||||
|
let entry = app.compute_line_entry(0, 80);
|
||||||
|
assert_eq!(entry.visual_height, 1, "post-format oversized should have height 1");
|
||||||
|
assert_eq!(entry.wrapped_rows.len(), 1);
|
||||||
|
});
|
||||||
|
cleanup(&path);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_to_columns_basic() {
|
||||||
|
assert_eq!(truncate_to_columns("hello world", 5), "hello");
|
||||||
|
assert_eq!(truncate_to_columns("hi", 80), "hi");
|
||||||
|
assert_eq!(truncate_to_columns("", 80), "");
|
||||||
|
assert_eq!(truncate_to_columns("abc", 0), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_to_columns_cjk() {
|
||||||
|
// Each CJK char is 2 columns wide
|
||||||
|
assert_eq!(truncate_to_columns("你好世界", 3), "你");
|
||||||
|
assert_eq!(truncate_to_columns("你好世界", 4), "你好");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_to_columns_tab() {
|
||||||
|
assert_eq!(truncate_to_columns("a\tb", 3), "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_tab_fits_fully() {
|
||||||
|
assert_eq!(truncate_to_columns("a\tb", 5), "a b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_tab_exact_boundary() {
|
||||||
|
assert_eq!(truncate_to_columns("a\tb", 4), "a ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_cjk_at_boundary() {
|
||||||
|
assert_eq!(truncate_to_columns("你好", 3), "你");
|
||||||
|
assert_eq!(truncate_to_columns("你好", 4), "你好");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_control_char() {
|
||||||
|
assert_eq!(truncate_to_columns("a\x07b", 5), "a\x07b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_exact_width() {
|
||||||
|
assert_eq!(truncate_to_columns("abcde", 5), "abcde");
|
||||||
|
assert_eq!(truncate_to_columns("abcdef", 5), "abcde");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_only_tab() {
|
||||||
|
assert_eq!(truncate_to_columns("\t", 4), " ");
|
||||||
|
assert_eq!(truncate_to_columns("\t", 3), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -88,9 +88,21 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Status bar ─────────────────────────────────────────────────
|
// ── Status bar ─────────────────────────────────────────────────
|
||||||
let status_text = if app.mode == AppMode::Settings {
|
if app.mode == AppMode::Settings {
|
||||||
" j/k:navigate ←/→:change 1-8:jump Enter:save Esc:cancel"
|
if let Some(ref err) = app.settings_error {
|
||||||
} else if app.is_error() {
|
frame.render_widget(
|
||||||
|
Paragraph::new(err.as_str()).style(Style::default().fg(Color::Red)),
|
||||||
|
outer[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(" j/k:navigate ←/→:change 1-8:jump Enter:save Esc:cancel"),
|
||||||
|
outer[2],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let status_text = if app.is_error() {
|
||||||
" Press q to quit"
|
" Press q to quit"
|
||||||
} else if app.is_loading() {
|
} else if app.is_loading() {
|
||||||
let pct = app.loading_progress().map_or(0, |p| p as usize);
|
let pct = app.loading_progress().map_or(0, |p| p as usize);
|
||||||
@@ -130,8 +142,8 @@ pub fn render_settings(frame: &mut ratatui::Frame, app: &mut App, area: ratatui:
|
|||||||
|
|
||||||
let popup_w = ((area.width as u32 * 4 / 5).max(40)).min(area.width as u32) as u16;
|
let popup_w = ((area.width as u32 * 4 / 5).max(40)).min(area.width as u32) as u16;
|
||||||
let popup_h = ((area.height as u32 * 4 / 5).max(14)).min(area.height as u32) as u16;
|
let popup_h = ((area.height as u32 * 4 / 5).max(14)).min(area.height as u32) as u16;
|
||||||
let popup_x = area.width.saturating_sub(popup_w) / 2;
|
let popup_x = area.x.saturating_add(area.width.saturating_sub(popup_w) / 2);
|
||||||
let popup_y = area.height.saturating_sub(popup_h) / 2;
|
let popup_y = area.y.saturating_add(area.height.saturating_sub(popup_h) / 2);
|
||||||
let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h);
|
let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h);
|
||||||
|
|
||||||
let block = Block::new().borders(Borders::ALL).title(" Color Settings ");
|
let block = Block::new().borders(Borders::ALL).title(" Color Settings ");
|
||||||
@@ -493,4 +505,74 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Issue #31: Settings popup area offset tests ────────────────
|
||||||
|
|
||||||
|
/// Helper: enter settings mode and render to buffer.
|
||||||
|
fn render_settings_to_buffer(app: &mut App, width: u16, height: u16) -> ratatui::buffer::Buffer {
|
||||||
|
app.mode = crate::app::AppMode::Settings;
|
||||||
|
app.settings_draft = app.color_config.clone();
|
||||||
|
render_to_buffer(app, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the top-left corner of the popup border by scanning for '┌'.
|
||||||
|
fn find_popup_top_left(buf: &ratatui::buffer::Buffer, width: u16, height: u16) -> Option<(u16, u16)> {
|
||||||
|
for row in 0..height {
|
||||||
|
for col in 0..width {
|
||||||
|
if buf.cell((col, row)).unwrap().symbol() == "┌" {
|
||||||
|
return Some((col, row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_popup_includes_area_offset() {
|
||||||
|
// In an 80x24 frame with Layout [Length(1), Min(1), Length(1)]:
|
||||||
|
// outer[0] = title bar -> y=0
|
||||||
|
// outer[1] = content -> y=1, height=22
|
||||||
|
// outer[2] = status bar -> y=23
|
||||||
|
// The popup is centered within outer[1], so its y must be >= outer[1].y (which is 1).
|
||||||
|
let mut app = App::new();
|
||||||
|
let buf = render_settings_to_buffer(&mut app, 80, 24);
|
||||||
|
|
||||||
|
let (_px, py) = find_popup_top_left(&buf, 80, 24)
|
||||||
|
.expect("popup border '┌' should be rendered");
|
||||||
|
|
||||||
|
// outer[1].y == 1; the popup is centered inside a 22-row area,
|
||||||
|
// so popup_y must be at least 1 (not 0).
|
||||||
|
assert!(
|
||||||
|
py >= 1,
|
||||||
|
"popup top row should account for area.y offset, got y={py} (expected >= 1)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_popup_horizontal_centering_uses_area_x() {
|
||||||
|
// outer[1].x is 0 for this layout, so this mainly verifies the popup
|
||||||
|
// is centered and not shifted left. A non-zero area.x layout would
|
||||||
|
// need a different layout to trigger, but the formula is the same.
|
||||||
|
let mut app = App::new();
|
||||||
|
let buf = render_settings_to_buffer(&mut app, 80, 24);
|
||||||
|
|
||||||
|
let (px, _py) = find_popup_top_left(&buf, 80, 24)
|
||||||
|
.expect("popup border '┌' should be rendered");
|
||||||
|
|
||||||
|
// popup_w = 80*4/5 = 64, centered: (80-64)/2 = 8
|
||||||
|
assert_eq!(
|
||||||
|
px, 8,
|
||||||
|
"popup should start at x=8 (centered 64-wide popup in 80-col area)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_popup_small_frame_no_panic() {
|
||||||
|
// Frame smaller than the min popup size (40x14) should not panic.
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
let _buf = render_settings_to_buffer(&mut app, 30, 10);
|
||||||
|
}));
|
||||||
|
assert!(result.is_ok(), "rendering settings in a small frame should not panic");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user