Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions src/tui/rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)),
);
}

Expand Down Expand Up @@ -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 {
(
Expand Down
70 changes: 14 additions & 56 deletions src/tui/state.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<String>, 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,
Expand All @@ -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<usize>, // 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<ProgressInfo>, // Current operation progress
// Theme state
pub current_theme: Theme, // Current color theme
// Config state
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -673,15 +630,16 @@ 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());
let (sample_rows, _) = self.sheet_data.get_rows(0, sample_size);

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);
}
}
Expand Down
Loading