use crate::workbook::{CellValue, LazySheetData, SheetData, Workbook};
use anyhow::Result;
use arboard::Clipboard;
use crossterm::{
event::{self, Event, KeyCode, KeyEvent},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap},
};
use std::io;
use std::time::{Duration, Instant};
struct RowCache {
start_row: usize,
rows: Vec<Vec<CellValue>>,
formulas: Vec<Vec<Option<String>>>,
}
enum SheetDataSource {
Eager(SheetData),
Lazy {
data: LazySheetData,
cache: Option<RowCache>,
cache_size: usize, },
}
impl SheetDataSource {
fn headers(&self) -> &[String] {
match self {
SheetDataSource::Eager(data) => &data.headers,
SheetDataSource::Lazy { data, .. } => &data.headers,
}
}
fn width(&self) -> usize {
match self {
SheetDataSource::Eager(data) => data.width,
SheetDataSource::Lazy { data, .. } => data.width,
}
}
fn height(&self) -> usize {
match self {
SheetDataSource::Eager(data) => data.height,
SheetDataSource::Lazy { data, .. } => data.height,
}
}
fn get_rows(
&mut self,
start: usize,
count: usize,
) -> (&[Vec<CellValue>], &[Vec<Option<String>>]) {
match self {
SheetDataSource::Eager(data) => {
let end = (start + count).min(data.rows.len());
(&data.rows[start..end], &data.formulas[start..end])
}
SheetDataSource::Lazy {
data,
cache,
cache_size,
} => {
let needs_reload = match cache {
None => true,
Some(c) => start < c.start_row || start >= c.start_row + c.rows.len(),
};
if needs_reload {
let cache_start = start.saturating_sub(*cache_size / 4); let (rows, formulas) = data.get_rows(cache_start, *cache_size);
*cache = Some(RowCache {
start_row: cache_start,
rows,
formulas,
});
}
if let Some(c) = cache {
let offset = start.saturating_sub(c.start_row);
let end = (offset + count).min(c.rows.len());
(&c.rows[offset..end], &c.formulas[offset..end])
} else {
(&[], &[])
}
}
}
}
fn get_cell(&mut self, row: usize, col: usize) -> (Option<CellValue>, Option<String>) {
match self {
SheetDataSource::Eager(data) => {
let cell = data.rows.get(row).and_then(|r| r.get(col)).cloned();
let formula = data
.formulas
.get(row)
.and_then(|r| r.get(col))
.and_then(|f| f.clone());
(cell, formula)
}
SheetDataSource::Lazy { .. } => {
let (rows, formulas) = self.get_rows(row, 1);
let cell = rows.first().and_then(|r| r.get(col)).cloned();
let formula = formulas
.first()
.and_then(|r| r.get(col))
.and_then(|f| f.clone());
(cell, formula)
}
}
}
}
#[derive(Debug, Clone)]
struct ProgressInfo {
message: String,
current: usize,
total: usize,
started_at: Instant,
}
impl ProgressInfo {
fn new(message: impl Into<String>, total: usize) -> Self {
Self {
message: message.into(),
current: 0,
total,
started_at: Instant::now(),
}
}
fn update(&mut self, current: usize) {
self.current = current;
}
fn percentage(&self) -> usize {
if self.total == 0 {
100
} else {
(self.current * 100) / self.total
}
}
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
)
}
}
pub struct TuiState {
workbook: Workbook,
sheet_names: Vec<String>,
current_sheet_index: usize,
sheet_data: SheetDataSource,
should_quit: bool,
cursor_row: usize, cursor_col: usize, scroll_offset: usize, show_help: bool, show_cell_detail: bool, search_mode: bool, search_query: String, search_matches: Vec<(usize, usize)>, current_match_index: Option<usize>, jump_mode: bool, jump_input: String, copy_feedback: Option<(String, Instant)>, progress: Option<ProgressInfo>, }
impl TuiState {
const LAZY_LOADING_THRESHOLD: usize = 1000; const ROW_CACHE_SIZE: usize = 200;
pub fn new(mut workbook: Workbook, initial_sheet_name: &str) -> Result<Self> {
let sheet_names = workbook.sheet_names();
let current_sheet_index = sheet_names
.iter()
.position(|name| name == initial_sheet_name)
.unwrap_or(0);
let lazy_data = workbook.load_sheet_lazy(&sheet_names[current_sheet_index])?;
let sheet_height = lazy_data.height;
let sheet_data = if sheet_height > Self::LAZY_LOADING_THRESHOLD {
eprintln!(
"📊 Large file detected ({} rows) - using lazy loading",
sheet_height
);
SheetDataSource::Lazy {
data: lazy_data,
cache: None,
cache_size: Self::ROW_CACHE_SIZE,
}
} else {
SheetDataSource::Eager(lazy_data.to_sheet_data())
};
Ok(Self {
workbook,
sheet_names,
current_sheet_index,
sheet_data,
should_quit: false,
cursor_row: 0,
cursor_col: 0,
scroll_offset: 0,
show_help: false,
show_cell_detail: false,
search_mode: false,
search_query: String::new(),
search_matches: Vec::new(),
current_match_index: None,
jump_mode: false,
jump_input: String::new(),
copy_feedback: None,
progress: None,
})
}
fn current_sheet_name(&self) -> &str {
&self.sheet_names[self.current_sheet_index]
}
fn switch_to_next_sheet(&mut self) -> Result<()> {
if self.sheet_names.len() <= 1 {
return Ok(()); }
self.current_sheet_index = (self.current_sheet_index + 1) % self.sheet_names.len();
self.load_current_sheet()?;
self.reset_cursor();
self.clear_search(); Ok(())
}
fn switch_to_prev_sheet(&mut self) -> Result<()> {
if self.sheet_names.len() <= 1 {
return Ok(()); }
self.current_sheet_index = if self.current_sheet_index == 0 {
self.sheet_names.len() - 1
} else {
self.current_sheet_index - 1
};
self.load_current_sheet()?;
self.reset_cursor();
self.clear_search(); Ok(())
}
fn load_current_sheet(&mut self) -> Result<()> {
let sheet_name = self.sheet_names[self.current_sheet_index].clone();
let lazy_data = self.workbook.load_sheet_lazy(&sheet_name)?;
let sheet_height = lazy_data.height;
self.sheet_data = if sheet_height > Self::LAZY_LOADING_THRESHOLD {
eprintln!(
"📊 Large file detected ({} rows) - using lazy loading",
sheet_height
);
SheetDataSource::Lazy {
data: lazy_data,
cache: None,
cache_size: Self::ROW_CACHE_SIZE,
}
} else {
SheetDataSource::Eager(lazy_data.to_sheet_data())
};
Ok(())
}
fn reset_cursor(&mut self) {
self.cursor_row = 0;
self.cursor_col = 0;
self.scroll_offset = 0;
}
fn perform_search(&mut self) {
self.search_matches.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);
let (rows, _formulas) = self.sheet_data.get_rows(chunk_start, chunk_size);
for (chunk_idx, row) in rows.iter().enumerate() {
let row_idx = chunk_start + chunk_idx;
for (col_idx, cell) in row.iter().enumerate() {
let cell_str = cell.to_string().to_lowercase();
if cell_str.contains(&query_lower) {
self.search_matches.push((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();
}
}
fn jump_to_next_match(&mut self) {
if self.search_matches.is_empty() {
return;
}
self.current_match_index = Some(match self.current_match_index {
Some(idx) => (idx + 1) % self.search_matches.len(),
None => 0,
});
self.jump_to_current_match();
}
fn jump_to_prev_match(&mut self) {
if self.search_matches.is_empty() {
return;
}
self.current_match_index = Some(match self.current_match_index {
Some(idx) => {
if idx == 0 {
self.search_matches.len() - 1
} else {
idx - 1
}
}
None => self.search_matches.len() - 1,
});
self.jump_to_current_match();
}
fn jump_to_current_match(&mut self) {
if let Some(idx) = self.current_match_index
&& let Some(&(row, col)) = self.search_matches.get(idx)
{
self.cursor_row = row;
self.cursor_col = col;
}
}
fn clear_search(&mut self) {
self.search_query.clear();
self.search_matches.clear();
self.current_match_index = None;
}
fn enter_jump_mode(&mut self) {
self.jump_mode = true;
self.jump_input.clear();
}
fn perform_jump(&mut self) {
if self.jump_input.is_empty() {
self.jump_mode = false;
return;
}
let input = self.jump_input.trim();
if let Ok(row_num) = input.parse::<usize>() {
if row_num > 0 && row_num <= self.sheet_data.height() {
self.cursor_row = row_num - 1; self.copy_feedback = Some((format!("Jumped to row {}", row_num), Instant::now()));
} else {
self.copy_feedback = Some((
format!(
"Invalid row: {} (max: {})",
row_num,
self.sheet_data.height()
),
Instant::now(),
));
}
}
else if let Some((col, row)) = Self::parse_cell_address(input) {
if row < self.sheet_data.height() && col < self.sheet_data.width() {
self.cursor_row = row;
self.cursor_col = col;
self.copy_feedback = Some((
format!("Jumped to {}", input.to_uppercase()),
Instant::now(),
));
} else {
self.copy_feedback = Some((
format!("Cell address out of bounds: {}", input),
Instant::now(),
));
}
}
else if let Some((row, col)) = input.split_once(',') {
if let (Ok(row_num), Ok(col_num)) =
(row.trim().parse::<usize>(), col.trim().parse::<usize>())
{
if row_num > 0
&& row_num <= self.sheet_data.height()
&& col_num > 0
&& col_num <= self.sheet_data.width()
{
self.cursor_row = row_num - 1;
self.cursor_col = col_num - 1;
self.copy_feedback = Some((
format!("Jumped to row {}, col {}", row_num, col_num),
Instant::now(),
));
} else {
self.copy_feedback =
Some(("Invalid row/column number".to_string(), Instant::now()));
}
} else {
self.copy_feedback = Some((
"Invalid format. Use: row number, cell (A5), or row,col".to_string(),
Instant::now(),
));
}
} else {
self.copy_feedback = Some((
"Invalid format. Use: row number, cell (A5), or row,col".to_string(),
Instant::now(),
));
}
self.jump_mode = false;
self.jump_input.clear();
}
fn parse_cell_address(addr: &str) -> Option<(usize, usize)> {
let addr = addr.to_uppercase();
let mut col = 0usize;
let mut row_str = String::new();
for ch in addr.chars() {
if ch.is_ascii_alphabetic() {
col = col * 26 + (ch as usize - 'A' as usize + 1);
} else if ch.is_ascii_digit() {
row_str.push(ch);
} else {
return None;
}
}
if row_str.is_empty() || col == 0 {
return None;
}
let row = row_str.parse::<usize>().ok()?;
Some((col - 1, row - 1)) }
fn copy_current_cell(&mut self) {
let (cell, _formula) = self.sheet_data.get_cell(self.cursor_row, self.cursor_col);
let cell_value = cell.map(|v| v.to_raw_string()).unwrap_or_default();
match Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&cell_value) {
self.copy_feedback = Some((format!("Copy failed: {}", e), Instant::now()));
} else {
let cell_addr = self.current_cell_address();
self.copy_feedback =
Some((format!("Copied cell {}", cell_addr), Instant::now()));
}
}
Err(e) => {
self.copy_feedback = Some((format!("Clipboard error: {}", e), Instant::now()));
}
}
}
fn copy_current_row(&mut self) {
let (rows, _formulas) = self.sheet_data.get_rows(self.cursor_row, 1);
let row_values = rows
.first()
.map(|row| {
row.iter()
.map(|cell| {
let value = cell.to_raw_string();
if value.contains('\t') || value.contains('\n') || value.contains('"') {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value
}
})
.collect::<Vec<_>>()
.join("\t")
})
.unwrap_or_default();
match Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&row_values) {
self.copy_feedback = Some((format!("Copy failed: {}", e), Instant::now()));
} else {
self.copy_feedback = Some((
format!(
"Copied row {} ({} cells)",
self.cursor_row + 1,
self.sheet_data.width()
),
Instant::now(),
));
}
}
Err(e) => {
self.copy_feedback = Some((format!("Clipboard error: {}", e), Instant::now()));
}
}
}
fn move_up(&mut self) {
if self.cursor_row > 0 {
self.cursor_row -= 1;
if self.cursor_row < self.scroll_offset {
self.scroll_offset = self.cursor_row;
}
}
}
fn move_down(&mut self) {
if self.cursor_row < self.sheet_data.height().saturating_sub(1) {
self.cursor_row += 1;
}
}
fn update_scroll(&mut self, viewport_height: usize) {
if self.cursor_row >= self.scroll_offset + viewport_height {
self.scroll_offset = self.cursor_row.saturating_sub(viewport_height - 1);
}
if self.cursor_row < self.scroll_offset {
self.scroll_offset = self.cursor_row;
}
}
fn move_left(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
}
}
fn move_right(&mut self) {
if self.cursor_col < self.sheet_data.width().saturating_sub(1) {
self.cursor_col += 1;
}
}
fn move_to_start_of_row(&mut self) {
self.cursor_col = 0;
}
fn move_to_end_of_row(&mut self) {
self.cursor_col = self.sheet_data.width().saturating_sub(1);
}
fn page_up(&mut self, page_size: usize) {
self.cursor_row = self.cursor_row.saturating_sub(page_size);
}
fn page_down(&mut self, page_size: usize) {
self.cursor_row =
(self.cursor_row + page_size).min(self.sheet_data.height().saturating_sub(1));
}
fn move_to_top(&mut self) {
self.cursor_row = 0;
}
fn move_to_bottom(&mut self) {
self.cursor_row = self.sheet_data.height().saturating_sub(1);
}
fn col_to_letter(&self, col: usize) -> String {
let mut result = String::new();
let mut n = col + 1;
while n > 0 {
n -= 1;
result.push((b'A' + (n % 26) as u8) as char);
n /= 26;
}
result.chars().rev().collect()
}
fn current_cell_address(&self) -> String {
format!(
"{}{}",
self.col_to_letter(self.cursor_col),
self.cursor_row + 1
)
}
fn handle_event(&mut self, event: Event) {
if let Event::Key(KeyEvent {
code, modifiers, ..
}) = event
{
use crossterm::event::KeyModifiers;
if self.show_help {
self.show_help = false;
return;
}
if self.show_cell_detail {
self.show_cell_detail = false;
return;
}
if self.search_mode {
match code {
KeyCode::Char(c) => {
self.search_query.push(c);
self.perform_search();
}
KeyCode::Backspace => {
self.search_query.pop();
self.perform_search();
}
KeyCode::Enter => {
self.search_mode = false;
}
KeyCode::Esc => {
self.search_mode = false;
self.clear_search();
}
_ => {}
}
return;
}
if self.jump_mode {
match code {
KeyCode::Char(c) => {
self.jump_input.push(c);
}
KeyCode::Backspace => {
self.jump_input.pop();
}
KeyCode::Enter => {
self.perform_jump();
}
KeyCode::Esc => {
self.jump_mode = false;
self.jump_input.clear();
}
_ => {}
}
return;
}
match code {
KeyCode::Char('q') => {
self.should_quit = true;
}
KeyCode::Char('?') => {
self.show_help = true;
}
KeyCode::Char('/') => {
self.search_mode = true;
self.clear_search();
}
KeyCode::Char('n') => {
self.jump_to_next_match();
}
KeyCode::Char('N') => {
self.jump_to_prev_match();
}
KeyCode::Char('c') => {
self.copy_current_cell();
}
KeyCode::Char('C') => {
self.copy_current_row();
}
KeyCode::Char('g') if modifiers.contains(KeyModifiers::CONTROL) => {
self.enter_jump_mode();
}
KeyCode::Enter => {
self.show_cell_detail = true;
}
KeyCode::Esc => {
if !self.search_matches.is_empty() {
self.clear_search();
} else {
self.should_quit = true;
}
}
KeyCode::Tab => {
if modifiers.contains(KeyModifiers::SHIFT) {
let _ = self.switch_to_prev_sheet();
} else {
let _ = self.switch_to_next_sheet();
}
}
KeyCode::BackTab => {
let _ = self.switch_to_prev_sheet();
}
KeyCode::Up => self.move_up(),
KeyCode::Down => self.move_down(),
KeyCode::Left => self.move_left(),
KeyCode::Right => self.move_right(),
KeyCode::Home => {
if modifiers.contains(KeyModifiers::CONTROL) {
self.move_to_top();
} else {
self.move_to_start_of_row();
}
}
KeyCode::End => {
if modifiers.contains(KeyModifiers::CONTROL) {
self.move_to_bottom();
} else {
self.move_to_end_of_row();
}
}
KeyCode::PageUp => self.page_up(10),
KeyCode::PageDown => self.page_down(10),
_ => {}
}
}
}
fn render(&mut self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), Constraint::Length(3), ])
.split(frame.area());
let table_height = chunks[0].height.saturating_sub(3) as usize;
self.update_scroll(table_height);
let visible_start = self.scroll_offset;
let headers = self.sheet_data.headers().to_vec();
let header_cells: Vec<Cell> = headers
.iter()
.enumerate()
.map(|(col_idx, h)| {
let style = if col_idx == self.cursor_col {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
};
Cell::from(h.as_str()).style(style)
})
.collect();
let header = Row::new(header_cells).height(1);
let (visible_rows, _visible_formulas) =
self.sheet_data.get_rows(visible_start, table_height);
let data_rows: Vec<Row> = visible_rows
.iter()
.enumerate()
.map(|(visible_idx, row)| {
let row_idx = visible_start + visible_idx; let cells: Vec<Cell> = row
.iter()
.enumerate()
.map(|(col_idx, cell)| {
let mut style = Style::default();
let is_search_match = self.search_matches.contains(&(row_idx, col_idx));
let is_current_match = self
.current_match_index
.and_then(|idx| self.search_matches.get(idx))
.map(|&pos| pos == (row_idx, col_idx))
.unwrap_or(false);
if is_current_match {
style = style
.bg(Color::Yellow)
.fg(Color::Black)
.add_modifier(Modifier::BOLD);
}
else if row_idx == self.cursor_row && col_idx == self.cursor_col {
style = style
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD);
}
else if is_search_match {
style = style.bg(Color::LightYellow).fg(Color::Black);
}
else if row_idx == self.cursor_row {
style = style.bg(Color::DarkGray);
}
else if col_idx == self.cursor_col {
style = style.fg(Color::Cyan);
}
Cell::from(cell.to_string()).style(style)
})
.collect();
Row::new(cells).height(1)
})
.collect();
let sheet_width = self.sheet_data.width();
let col_widths: Vec<Constraint> = headers
.iter()
.map(|_| Constraint::Percentage((100 / sheet_width.max(1)) as u16))
.collect();
let table_title = if self.sheet_names.len() > 1 {
format!(
" {} (Sheet {}/{}) ",
self.current_sheet_name(),
self.current_sheet_index + 1,
self.sheet_names.len()
)
} else {
format!(" {} ", self.current_sheet_name())
};
let table = Table::new(data_rows, col_widths)
.header(header)
.block(Block::default().borders(Borders::ALL).title(table_title));
frame.render_widget(table, chunks[0]);
let (cell, _) = self.sheet_data.get_cell(self.cursor_row, self.cursor_col);
let current_cell_value = cell.map(|v| v.to_string()).unwrap_or_default();
let status_text = if let Some(ref progress) = self.progress {
format!(" ⏳ {} ", progress.format())
} else if self.jump_mode {
format!(
" Jump to (row, cell like A5, or row,col): {} ",
self.jump_input
)
} else if self.search_mode {
format!(" Search: {} ", self.search_query)
} else if let Some(idx) = self.current_match_index {
let match_info = format!("Match {}/{} | ", idx + 1, self.search_matches.len());
if self.sheet_names.len() > 1 {
format!(
" {} | {}n:next N:prev Esc:clear | {} rows × {} columns | Tab:next sheet ?:help q:quit ",
match_info,
self.current_cell_address(),
self.sheet_data.height(),
self.sheet_data.width()
)
} else {
format!(
" {} | {}n:next N:prev Esc:clear | {} rows × {} columns | ?:help q:quit ",
match_info,
self.current_cell_address(),
self.sheet_data.height(),
self.sheet_data.width()
)
}
} else {
let mode_indicator = match &self.sheet_data {
SheetDataSource::Lazy { .. } => " [Lazy] ",
SheetDataSource::Eager(_) => "",
};
if self.sheet_names.len() > 1 {
format!(
" {} | {} rows × {} columns{} | /:search Tab:next sheet ?:help q:quit ",
self.current_cell_address(),
self.sheet_data.height(),
self.sheet_data.width(),
mode_indicator
)
} else {
format!(
" {} | {} rows × {} columns{} | /:search ?:help q:quit ",
self.current_cell_address(),
self.sheet_data.height(),
self.sheet_data.width(),
mode_indicator
)
}
};
let status = Paragraph::new(status_text)
.style(Style::default().bg(Color::DarkGray).fg(Color::White))
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", current_cell_value)),
);
frame.render_widget(status, chunks[1]);
if self.show_cell_detail {
self.render_cell_detail(frame);
}
if self.show_help {
self.render_help(frame);
}
if let Some((ref message, timestamp)) = self.copy_feedback {
if timestamp.elapsed() < Duration::from_secs(2) {
self.render_copy_feedback(frame, message);
} else {
self.copy_feedback = None;
}
}
}
fn render_help(&self, frame: &mut Frame) {
use ratatui::text::{Line, Span};
let help_lines = vec![
Line::from(vec![
Span::styled(
"xleak",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Interactive Excel Viewer"),
]),
Line::from(""),
Line::from(Span::styled(
"NAVIGATION",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(" ↑ ↓ ← → ", Style::default().fg(Color::Green)),
Span::raw("Move cursor one cell"),
]),
Line::from(vec![
Span::styled(" Page Up/Down ", Style::default().fg(Color::Green)),
Span::raw("Scroll 10 rows"),
]),
Line::from(vec![
Span::styled(" Home ", Style::default().fg(Color::Green)),
Span::raw("Jump to first column (start of row)"),
]),
Line::from(vec![
Span::styled(" End ", Style::default().fg(Color::Green)),
Span::raw("Jump to last column (end of row)"),
]),
Line::from(vec![
Span::styled(" Ctrl+Home ", Style::default().fg(Color::Green)),
Span::raw("Jump to first row (top of sheet)"),
]),
Line::from(vec![
Span::styled(" Ctrl+End ", Style::default().fg(Color::Green)),
Span::raw("Jump to last row (bottom of sheet)"),
]),
Line::from(vec![
Span::styled(" Ctrl+G ", Style::default().fg(Color::Green)),
Span::raw("Jump to row/cell (e.g., 100, A5, or 10,3)"),
]),
Line::from(""),
Line::from(Span::styled(
"SEARCH",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(" / ", Style::default().fg(Color::Green)),
Span::raw("Start search (type query, Enter to confirm)"),
]),
Line::from(vec![
Span::styled(" n ", Style::default().fg(Color::Green)),
Span::raw("Jump to next search match"),
]),
Line::from(vec![
Span::styled(" N (Shift+n) ", Style::default().fg(Color::Green)),
Span::raw("Jump to previous search match"),
]),
Line::from(vec![
Span::styled(" Esc ", Style::default().fg(Color::Green)),
Span::raw("Clear search results"),
]),
Line::from(""),
Line::from(Span::styled(
"CLIPBOARD",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(" c ", Style::default().fg(Color::Green)),
Span::raw("Copy current cell value"),
]),
Line::from(vec![
Span::styled(" C (Shift+c) ", Style::default().fg(Color::Green)),
Span::raw("Copy entire current row (tab-separated)"),
]),
Line::from(""),
Line::from(Span::styled(
"SHEET NAVIGATION",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(" Tab ", Style::default().fg(Color::Green)),
Span::raw("Switch to next sheet"),
]),
Line::from(vec![
Span::styled(" Shift+Tab ", Style::default().fg(Color::Green)),
Span::raw("Switch to previous sheet"),
]),
Line::from(""),
Line::from(Span::styled(
"GENERAL",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Green)),
Span::raw("Show cell details (type, formula, value)"),
]),
Line::from(vec![
Span::styled(" ? ", Style::default().fg(Color::Green)),
Span::raw("Toggle this help screen"),
]),
Line::from(vec![
Span::styled(" q ", Style::default().fg(Color::Green)),
Span::raw("Quit xleak"),
]),
Line::from(vec![
Span::styled(" Esc ", Style::default().fg(Color::Green)),
Span::raw("Quit xleak (or clear search)"),
]),
Line::from(""),
Line::from(Span::styled(
"VISUAL CUES",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(
" Blue background ",
Style::default().bg(Color::Blue).fg(Color::White),
),
Span::raw(" Current cell (selected)"),
]),
Line::from(vec![
Span::styled(" Dark gray bg ", Style::default().bg(Color::DarkGray)),
Span::raw(" Current row highlight"),
]),
Line::from(vec![
Span::styled(" Cyan text ", Style::default().fg(Color::Cyan)),
Span::raw(" Current column highlight"),
]),
Line::from(vec![
Span::styled(
" Yellow bold ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Column headers"),
]),
Line::from(vec![
Span::styled(
" Yellow bg ",
Style::default().bg(Color::Yellow).fg(Color::Black),
),
Span::raw(" Current search match"),
]),
Line::from(vec![
Span::styled(
" Light yellow bg ",
Style::default().bg(Color::LightYellow).fg(Color::Black),
),
Span::raw(" Other search matches"),
]),
Line::from(""),
Line::from(Span::styled(
"STATUS BAR INFO",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(" Cell address (e.g., B7) shown in bottom left"),
Line::from(" Current cell value displayed in status bar title"),
Line::from(" Sheet dimensions (rows × columns) shown"),
Line::from(" Match counter shown when searching (e.g., Match 3/12)"),
Line::from(""),
Line::from(vec![Span::styled(
"Press any key to close",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::ITALIC),
)]),
];
let area = frame.area();
let popup_width = (area.width as f32 * 0.7).min(80.0) as u16;
let popup_height =
(help_lines.len() + 4).min(area.height.saturating_sub(2) as usize) as u16;
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
frame.render_widget(Clear, popup_area);
let help_paragraph = Paragraph::new(help_lines)
.style(Style::default().fg(Color::White).bg(Color::Black))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.title(vec![
Span::raw(" "),
Span::styled(
"Help",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Keyboard Shortcuts "),
])
.title_alignment(Alignment::Center),
)
.wrap(Wrap { trim: false });
frame.render_widget(help_paragraph, popup_area);
}
fn render_cell_detail(&mut self, frame: &mut Frame) {
use ratatui::text::{Line, Span};
let (cell_value, cell_formula) = self.sheet_data.get_cell(self.cursor_row, self.cursor_col);
let cell_addr = self.current_cell_address();
let header = self
.sheet_data
.headers()
.get(self.cursor_col)
.map(|s| s.as_str())
.unwrap_or("");
let mut detail_lines = vec![
Line::from(vec![
Span::styled(
"Cell: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(cell_addr.clone(), Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled(
"Column: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(header),
]),
Line::from(""),
];
if let Some(ref formula) = cell_formula {
detail_lines.push(Line::from(vec![
Span::styled(
"Formula: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
formula.clone(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
]));
detail_lines.push(Line::from(""));
}
if let Some(cell) = cell_value {
let cell_type = match cell {
crate::workbook::CellValue::Empty => "Empty",
crate::workbook::CellValue::String(_) => "String",
crate::workbook::CellValue::Int(_) => "Integer",
crate::workbook::CellValue::Float(_) => "Float",
crate::workbook::CellValue::Bool(_) => "Boolean",
crate::workbook::CellValue::Error(_) => "Error",
crate::workbook::CellValue::DateTime(_) => "DateTime",
};
detail_lines.push(Line::from(vec![
Span::styled(
"Type: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(cell_type, Style::default().fg(Color::Green)),
]));
let raw_value = cell.to_raw_string();
if raw_value.is_empty() && cell_formula.is_some() {
detail_lines.push(Line::from(vec![
Span::styled(
"Value: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"(empty - formula not evaluated)",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
]));
} else {
let value_display = if raw_value.is_empty() {
"(empty)".to_string()
} else {
raw_value.clone()
};
detail_lines.push(Line::from(vec![
Span::styled(
"Value: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(value_display),
]));
}
let display_value = cell.to_string();
if display_value != raw_value {
detail_lines.push(Line::from(vec![
Span::styled(
"Display Value: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(display_value.clone()),
]));
}
detail_lines.push(Line::from(""));
detail_lines.push(Line::from(Span::styled(
"Full Content:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
detail_lines.push(Line::from(""));
for line in raw_value.lines() {
detail_lines.push(Line::from(Span::raw(line.to_string())));
}
} else {
if cell_formula.is_some() {
detail_lines.push(Line::from(vec![
Span::styled(
"Value: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"(formula not evaluated by Excel reader)",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
]));
} else {
detail_lines.push(Line::from(Span::styled(
"No cell data",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
)));
}
}
detail_lines.push(Line::from(""));
detail_lines.push(Line::from(vec![Span::styled(
"Press any key to close",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::ITALIC),
)]));
let area = frame.area();
let popup_width = (area.width as f32 * 0.6).min(80.0) as u16;
let popup_height =
(detail_lines.len() + 4).min(area.height.saturating_sub(2) as usize) as u16;
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
frame.render_widget(Clear, popup_area);
let detail_paragraph = Paragraph::new(detail_lines)
.style(Style::default().fg(Color::White).bg(Color::Black))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.title(vec![
Span::raw(" "),
Span::styled(
"Cell Details",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - "),
Span::styled(cell_addr, Style::default().fg(Color::Cyan)),
Span::raw(" "),
])
.title_alignment(Alignment::Center),
)
.wrap(Wrap { trim: false });
frame.render_widget(detail_paragraph, popup_area);
}
fn render_copy_feedback(&self, frame: &mut Frame, message: &str) {
use ratatui::text::{Line, Span};
let area = frame.area();
let popup_width = (message.len() as u16 + 6).min(60);
let popup_height = 3;
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
frame.render_widget(Clear, popup_area);
let feedback_paragraph = Paragraph::new(Line::from(vec![Span::styled(
message,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)]))
.style(Style::default().bg(Color::Green).fg(Color::White))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
.title(" ✓ ")
.title_alignment(Alignment::Center),
)
.alignment(Alignment::Center);
frame.render_widget(feedback_paragraph, popup_area);
}
}
pub fn run_tui(workbook: Workbook, sheet_name: &str) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = TuiState::new(workbook, sheet_name)?;
let res = run_event_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
res
}
fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut TuiState,
) -> Result<()> {
loop {
terminal.draw(|f| {
app.render(f);
})?;
if event::poll(std::time::Duration::from_millis(100))? {
let event = event::read()?;
app.handle_event(event);
}
if app.should_quit {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_cell_address_basic() {
assert_eq!(TuiState::parse_cell_address("A1"), Some((0, 0)));
assert_eq!(TuiState::parse_cell_address("B2"), Some((1, 1)));
assert_eq!(TuiState::parse_cell_address("Z26"), Some((25, 25)));
}
#[test]
fn test_parse_cell_address_double_letter() {
assert_eq!(TuiState::parse_cell_address("AA1"), Some((26, 0)));
assert_eq!(TuiState::parse_cell_address("AB5"), Some((27, 4)));
assert_eq!(TuiState::parse_cell_address("AZ100"), Some((51, 99)));
}
#[test]
fn test_parse_cell_address_lowercase() {
assert_eq!(TuiState::parse_cell_address("a1"), Some((0, 0)));
assert_eq!(TuiState::parse_cell_address("b2"), Some((1, 1)));
assert_eq!(TuiState::parse_cell_address("aa10"), Some((26, 9)));
}
#[test]
fn test_parse_cell_address_invalid() {
assert_eq!(TuiState::parse_cell_address(""), None);
assert_eq!(TuiState::parse_cell_address("1"), None);
assert_eq!(TuiState::parse_cell_address("A"), None);
assert_eq!(TuiState::parse_cell_address("123"), None);
assert_eq!(TuiState::parse_cell_address("!@#"), None);
assert_eq!(TuiState::parse_cell_address("A-1"), None);
}
#[test]
fn test_parse_cell_address_large_column() {
assert_eq!(TuiState::parse_cell_address("BA1"), Some((52, 0)));
assert_eq!(TuiState::parse_cell_address("ZZ1"), Some((701, 0)));
}
#[test]
fn test_column_to_letter() {
let col_a = 0;
let col_z = 25;
let col_aa = 26;
fn col_to_letter(col: usize) -> String {
let mut result = String::new();
let mut n = col + 1;
while n > 0 {
n -= 1;
result.push((b'A' + (n % 26) as u8) as char);
n /= 26;
}
result.chars().rev().collect()
}
assert_eq!(col_to_letter(col_a), "A");
assert_eq!(col_to_letter(col_z), "Z");
assert_eq!(col_to_letter(col_aa), "AA");
}
}