diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f65fc..0b47117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Formula display misaligned by one row with `--no-header` ([#50](https://github.com/bgreenwell/xleak/issues/50)) - Datetimes just before midnight rendered as `24:00:00` instead of rolling to the next day ([#54](https://github.com/bgreenwell/xleak/issues/54)) +- Sheets wider than 100 columns rendered every column at zero width in interactive mode without `-H` +- Search-match highlighting lagged on large sheets with many matches +- Jump-mode cell addresses with 14+ letters overflowed instead of being rejected +- Auto-sized column widths (`-H`) over-counted multibyte UTF-8 content - Terminal left in raw mode when TUI setup failed or a panic unwound ([#58](https://github.com/bgreenwell/xleak/issues/58)) - Control characters in cells could inject terminal escape sequences via the non-interactive table view ([#59](https://github.com/bgreenwell/xleak/issues/59)) - CSV export did not quote the header row ([#52](https://github.com/bgreenwell/xleak/issues/52)) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 974f810..fc4eb7b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -47,6 +47,13 @@ mod tests { assert_eq!(TuiState::parse_cell_address("ZZ1"), Some((701, 0))); } + #[test] + fn test_parse_cell_address_overflow_returns_none() { + // Enough letters to overflow the base-26 accumulator must be rejected, + // not wrap (release) or panic (debug). + assert_eq!(TuiState::parse_cell_address("AAAAAAAAAAAAAAAA1"), None); + } + #[test] fn test_first_data_sheet_row() { // With a header row (default), the header is sheet row 1 and the first diff --git a/src/tui/rendering.rs b/src/tui/rendering.rs index 594a256..5910288 100644 --- a/src/tui/rendering.rs +++ b/src/tui/rendering.rs @@ -203,7 +203,8 @@ impl TuiState { style = style.bg(alt_bg); } - let is_search_match = self.search_matches.contains(&(row_idx, col_idx)); + let is_search_match = + self.search_match_set.contains(&(row_idx, col_idx)); let is_current_match = self .current_match_index .and_then(|idx| self.search_matches.get(idx)) @@ -248,10 +249,12 @@ impl TuiState { ); } else { let sheet_width = self.sheet_data.width(); + // Ratio, not Percentage: 100/width truncates to 0% past 100 + // columns, collapsing every column to zero width. col_widths.extend( headers .iter() - .map(|_| Constraint::Percentage((100 / sheet_width.max(1)) as u16)), + .map(|_| Constraint::Ratio(1, sheet_width.max(1) as u32)), ); } @@ -343,9 +346,7 @@ impl TuiState { // searching); right segment shows the theme and key hints. let cell_addr = self.current_cell_address(); - let (info_left, info_right) = if let Some(ref progress) = self.progress { - (format!(" ⏳ {} ", progress.format()), String::new()) - } else if self.jump_mode { + let (info_left, info_right) = if self.jump_mode { (format!(" Jump: {} ", self.jump_input), String::new()) } else if self.search_mode { ( diff --git a/src/tui/state.rs b/src/tui/state.rs index 1812fc8..ec88b53 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -1,7 +1,7 @@ use crate::utils; use crate::workbook::{CellValue, LazySheetData, SheetData, Workbook}; use anyhow::Result; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Instant; use super::clipboard::{self, CopyOutcome}; @@ -129,43 +129,6 @@ impl SheetDataSource { } } -/// Progress information for long-running operations -#[derive(Debug, Clone)] -pub(crate) struct ProgressInfo { - message: String, - current: usize, - total: usize, - started_at: Instant, -} - -impl ProgressInfo { - pub fn new(message: impl Into, total: usize) -> Self { - Self { - message: message.into(), - current: 0, - total, - started_at: Instant::now(), - } - } - - pub fn update(&mut self, current: usize) { - self.current = current; - } - - pub fn percentage(&self) -> usize { - (self.current * 100).checked_div(self.total).unwrap_or(100) - } - - pub fn format(&self) -> String { - let pct = self.percentage(); - let _elapsed = self.started_at.elapsed().as_secs_f64(); - format!( - "{} {}% ({}/{})", - self.message, pct, self.current, self.total - ) - } -} - /// TUI application state pub struct TuiState { pub workbook: Workbook, @@ -186,15 +149,14 @@ pub struct TuiState { // Search state pub search_mode: bool, // Whether we're in search input mode pub search_query: String, // Current search query - pub search_matches: Vec<(usize, usize)>, // List of (row, col) matches + pub search_matches: Vec<(usize, usize)>, // List of (row, col) matches in scan order + pub search_match_set: HashSet<(usize, usize)>, // Same matches, for O(1) render lookups pub current_match_index: Option, // Index in search_matches // Jump mode state pub jump_mode: bool, // Whether we're in jump input mode pub jump_input: String, // Current jump input (row number or cell address) // Clipboard state pub copy_feedback: Option<(String, Instant)>, // Message and timestamp for copy feedback - // Progress state - pub progress: Option, // Current operation progress // Theme state pub current_theme: Theme, // Current color theme // Config state @@ -266,11 +228,11 @@ impl TuiState { search_mode: false, search_query: String::new(), search_matches: Vec::new(), + search_match_set: HashSet::new(), current_match_index: None, jump_mode: false, jump_input: String::new(), copy_feedback: None, - progress: None, current_theme: Self::parse_theme_name(&config.theme.default), config: config.clone(), no_header, @@ -399,20 +361,16 @@ impl TuiState { /// Perform case-insensitive search across all cells pub fn perform_search(&mut self) { self.search_matches.clear(); + self.search_match_set.clear(); self.current_match_index = None; if self.search_query.is_empty() { - self.progress = None; return; } let query_lower = self.search_query.to_lowercase(); let total_height = self.sheet_data.height(); - if total_height > 1000 { - self.progress = Some(ProgressInfo::new("Searching", total_height)); - } - const SEARCH_CHUNK_SIZE: usize = 500; for chunk_start in (0..total_height).step_by(SEARCH_CHUNK_SIZE) { let chunk_size = SEARCH_CHUNK_SIZE.min(total_height - chunk_start); @@ -424,17 +382,12 @@ impl TuiState { let cell_str = cell.to_string().to_lowercase(); if cell_str.contains(&query_lower) { self.search_matches.push((row_idx, col_idx)); + self.search_match_set.insert((row_idx, col_idx)); } } } - - if let Some(ref mut progress) = self.progress { - progress.update(chunk_start + chunk_size); - } } - self.progress = None; - if !self.search_matches.is_empty() { self.current_match_index = Some(0); self.jump_to_current_match(); @@ -485,6 +438,7 @@ impl TuiState { pub fn clear_search(&mut self) { self.search_query.clear(); self.search_matches.clear(); + self.search_match_set.clear(); self.current_match_index = None; } @@ -583,7 +537,10 @@ impl TuiState { for ch in addr.chars() { if ch.is_ascii_alphabetic() { - col = col * 26 + (ch as usize - 'A' as usize + 1); + // Checked: enough letters (14+) would overflow usize. + col = col + .checked_mul(26)? + .checked_add(ch as usize - 'A' as usize + 1)?; } else if ch.is_ascii_digit() { row_str.push(ch); } else { @@ -673,7 +630,7 @@ impl TuiState { let headers = self.sheet_data.headers(); for (i, header) in headers.iter().enumerate() { - widths[i] = header.len(); + widths[i] = header.chars().count(); } let sample_size = 100.min(self.sheet_data.height()); @@ -681,7 +638,8 @@ impl TuiState { for row in sample_rows.iter() { for (col_idx, cell) in row.iter().enumerate() { - let len = cell.to_string().len(); + // Chars, not bytes: byte length over-sizes multibyte UTF-8. + let len = cell.to_string().chars().count(); widths[col_idx] = widths[col_idx].max(len); } }