use crate::workbook::{CellValue, LazySheetData, SheetData, Workbook};
use anyhow::{Context, Result};
use arboard::Clipboard;
use crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
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};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Theme {
Default,
Dracula,
SolarizedDark,
SolarizedLight,
GitHubDark,
Nord,
}
impl Theme {
pub fn all() -> &'static [Theme] {
&[
Theme::Default,
Theme::Dracula,
Theme::SolarizedDark,
Theme::SolarizedLight,
Theme::GitHubDark,
Theme::Nord,
]
}
pub fn next(&self) -> Theme {
let themes = Self::all();
let current_idx = themes.iter().position(|t| t == self).unwrap_or(0);
themes[(current_idx + 1) % themes.len()]
}
pub fn name(&self) -> &'static str {
match self {
Theme::Default => "Default",
Theme::Dracula => "Dracula",
Theme::SolarizedDark => "Solarized Dark",
Theme::SolarizedLight => "Solarized Light",
Theme::GitHubDark => "GitHub Dark",
Theme::Nord => "Nord",
}
}
pub fn colors(&self) -> ColorScheme {
match self {
Theme::Default => ColorScheme::default_theme(),
Theme::Dracula => ColorScheme::dracula(),
Theme::SolarizedDark => ColorScheme::solarized_dark(),
Theme::SolarizedLight => ColorScheme::solarized_light(),
Theme::GitHubDark => ColorScheme::github_dark(),
Theme::Nord => ColorScheme::nord(),
}
}
}
#[derive(Debug, Clone)]
pub struct ColorScheme {
pub string_fg: Color,
pub number_fg: Color,
pub bool_fg: Color,
pub datetime_fg: Color,
pub error_fg: Color,
pub empty_fg: Color,
pub header_fg: Color,
pub header_bg: Option<Color>,
pub current_cell_fg: Color,
pub current_cell_bg: Color,
pub current_row_bg: Color,
pub current_col_fg: Color,
pub alternating_row_bg: Option<Color>,
pub search_match_fg: Color,
pub search_match_bg: Color,
pub current_search_fg: Color,
pub current_search_bg: Color,
pub border_fg: Color,
pub status_bar_fg: Color,
pub status_bar_bg: Option<Color>,
}
impl ColorScheme {
pub fn default_theme() -> Self {
Self {
string_fg: Color::White,
number_fg: Color::Cyan,
bool_fg: Color::Magenta,
datetime_fg: Color::Green,
error_fg: Color::Red,
empty_fg: Color::DarkGray,
header_fg: Color::Yellow,
header_bg: None,
current_cell_fg: Color::White,
current_cell_bg: Color::Blue,
current_row_bg: Color::DarkGray,
current_col_fg: Color::Cyan,
alternating_row_bg: Some(Color::Rgb(25, 25, 28)),
search_match_fg: Color::Black,
search_match_bg: Color::LightYellow,
current_search_fg: Color::Black,
current_search_bg: Color::Yellow,
border_fg: Color::White,
status_bar_fg: Color::White,
status_bar_bg: None,
}
}
pub fn dracula() -> Self {
Self {
string_fg: Color::Rgb(248, 248, 242), number_fg: Color::Rgb(189, 147, 249), bool_fg: Color::Rgb(255, 121, 198), datetime_fg: Color::Rgb(80, 250, 123), error_fg: Color::Rgb(255, 85, 85), empty_fg: Color::Rgb(98, 114, 164),
header_fg: Color::Rgb(139, 233, 253), header_bg: Some(Color::Rgb(68, 71, 90)), current_cell_fg: Color::Rgb(248, 248, 242),
current_cell_bg: Color::Rgb(98, 114, 164), current_row_bg: Color::Rgb(68, 71, 90), current_col_fg: Color::Rgb(139, 233, 253), alternating_row_bg: Some(Color::Rgb(50, 52, 65)),
search_match_fg: Color::Rgb(40, 42, 54), search_match_bg: Color::Rgb(241, 250, 140), current_search_fg: Color::Rgb(40, 42, 54),
current_search_bg: Color::Rgb(255, 184, 108),
border_fg: Color::Rgb(98, 114, 164), status_bar_fg: Color::Rgb(248, 248, 242),
status_bar_bg: Some(Color::Rgb(68, 71, 90)),
}
}
pub fn solarized_dark() -> Self {
Self {
string_fg: Color::Rgb(131, 148, 150), number_fg: Color::Rgb(38, 139, 210), bool_fg: Color::Rgb(211, 54, 130), datetime_fg: Color::Rgb(133, 153, 0), error_fg: Color::Rgb(220, 50, 47), empty_fg: Color::Rgb(88, 110, 117),
header_fg: Color::Rgb(181, 137, 0), header_bg: Some(Color::Rgb(7, 54, 66)), current_cell_fg: Color::Rgb(253, 246, 227),
current_cell_bg: Color::Rgb(88, 110, 117), current_row_bg: Color::Rgb(7, 54, 66), current_col_fg: Color::Rgb(42, 161, 152), alternating_row_bg: Some(Color::Rgb(0, 43, 54)),
search_match_fg: Color::Rgb(0, 43, 54),
search_match_bg: Color::Rgb(181, 137, 0), current_search_fg: Color::Rgb(0, 43, 54),
current_search_bg: Color::Rgb(203, 75, 22),
border_fg: Color::Rgb(88, 110, 117),
status_bar_fg: Color::Rgb(131, 148, 150),
status_bar_bg: Some(Color::Rgb(7, 54, 66)),
}
}
pub fn solarized_light() -> Self {
Self {
string_fg: Color::Rgb(101, 123, 131), number_fg: Color::Rgb(38, 139, 210), bool_fg: Color::Rgb(211, 54, 130), datetime_fg: Color::Rgb(133, 153, 0), error_fg: Color::Rgb(220, 50, 47), empty_fg: Color::Rgb(147, 161, 161),
header_fg: Color::Rgb(181, 137, 0), header_bg: Some(Color::Rgb(238, 232, 213)), current_cell_fg: Color::Rgb(0, 43, 54), current_cell_bg: Color::Rgb(147, 161, 161), current_row_bg: Color::Rgb(238, 232, 213), current_col_fg: Color::Rgb(42, 161, 152), alternating_row_bg: Some(Color::Rgb(253, 246, 227)),
search_match_fg: Color::Rgb(0, 43, 54),
search_match_bg: Color::Rgb(181, 137, 0), current_search_fg: Color::Rgb(253, 246, 227),
current_search_bg: Color::Rgb(203, 75, 22),
border_fg: Color::Rgb(147, 161, 161),
status_bar_fg: Color::Rgb(101, 123, 131),
status_bar_bg: Some(Color::Rgb(238, 232, 213)),
}
}
pub fn github_dark() -> Self {
Self {
string_fg: Color::Rgb(201, 209, 217), number_fg: Color::Rgb(121, 192, 255), bool_fg: Color::Rgb(255, 125, 163), datetime_fg: Color::Rgb(127, 219, 202), error_fg: Color::Rgb(248, 81, 73), empty_fg: Color::Rgb(110, 118, 129),
header_fg: Color::Rgb(255, 199, 119), header_bg: Some(Color::Rgb(33, 38, 45)), current_cell_fg: Color::Rgb(201, 209, 217),
current_cell_bg: Color::Rgb(56, 139, 253), current_row_bg: Color::Rgb(33, 38, 45), current_col_fg: Color::Rgb(121, 192, 255),
alternating_row_bg: Some(Color::Rgb(22, 27, 34)),
search_match_fg: Color::Rgb(13, 17, 23),
search_match_bg: Color::Rgb(187, 128, 9), current_search_fg: Color::Rgb(13, 17, 23),
current_search_bg: Color::Rgb(242, 130, 33),
border_fg: Color::Rgb(48, 54, 61), status_bar_fg: Color::Rgb(201, 209, 217),
status_bar_bg: Some(Color::Rgb(33, 38, 45)),
}
}
pub fn nord() -> Self {
Self {
string_fg: Color::Rgb(216, 222, 233), number_fg: Color::Rgb(136, 192, 208), bool_fg: Color::Rgb(180, 142, 173), datetime_fg: Color::Rgb(163, 190, 140), error_fg: Color::Rgb(191, 97, 106), empty_fg: Color::Rgb(76, 86, 106),
header_fg: Color::Rgb(235, 203, 139), header_bg: Some(Color::Rgb(59, 66, 82)), current_cell_fg: Color::Rgb(236, 239, 244),
current_cell_bg: Color::Rgb(94, 129, 172), current_row_bg: Color::Rgb(59, 66, 82), current_col_fg: Color::Rgb(136, 192, 208), alternating_row_bg: Some(Color::Rgb(46, 52, 64)),
search_match_fg: Color::Rgb(46, 52, 64),
search_match_bg: Color::Rgb(235, 203, 139), current_search_fg: Color::Rgb(46, 52, 64),
current_search_bg: Color::Rgb(208, 135, 112),
border_fg: Color::Rgb(76, 86, 106), status_bar_fg: Color::Rgb(216, 222, 233),
status_bar_bg: Some(Color::Rgb(59, 66, 82)),
}
}
pub fn cell_color(&self, cell: &CellValue) -> Color {
match cell {
CellValue::Empty => self.empty_fg,
CellValue::String(_) => self.string_fg,
CellValue::Int(_) | CellValue::Float(_) => self.number_fg,
CellValue::Bool(_) => self.bool_fg,
CellValue::Error(_) => self.error_fg,
CellValue::DateTime(_) => self.datetime_fg,
}
}
}
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, horizontal_scroll_offset: usize, horizontal_scroll_enabled: bool, column_widths: Vec<usize>, show_help: bool, show_cell_detail: bool, cell_detail_scroll: usize, 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>, current_theme: Theme, config: crate::config::Config, }
impl TuiState {
const LAZY_LOADING_THRESHOLD: usize = 1000; const ROW_CACHE_SIZE: usize = 200;
pub fn new(
mut workbook: Workbook,
initial_sheet_name: &str,
config: &crate::config::Config,
horizontal_scroll: bool,
) -> 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())
};
let mut state = Self {
workbook,
sheet_names,
current_sheet_index,
sheet_data,
should_quit: false,
cursor_row: 0,
cursor_col: 0,
scroll_offset: 0,
horizontal_scroll_offset: 0,
horizontal_scroll_enabled: horizontal_scroll,
column_widths: Vec::new(),
show_help: false,
show_cell_detail: false,
cell_detail_scroll: 0,
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,
current_theme: Self::parse_theme_name(&config.theme.default),
config: config.clone(),
};
if horizontal_scroll {
state.column_widths = state.calculate_column_widths();
}
Ok(state)
}
fn parse_theme_name(name: &str) -> Theme {
match name.to_lowercase().as_str() {
"dracula" => Theme::Dracula,
"solarized dark" | "solarizeddark" => Theme::SolarizedDark,
"solarized light" | "solarizedlight" => Theme::SolarizedLight,
"github dark" | "githubdark" => Theme::GitHubDark,
"nord" => Theme::Nord,
_ => Theme::Default, }
}
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())
};
if self.horizontal_scroll_enabled {
self.column_widths = self.calculate_column_widths();
}
Ok(())
}
fn reset_cursor(&mut self) {
self.cursor_row = 0;
self.cursor_col = 0;
self.scroll_offset = 0;
self.horizontal_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 calculate_column_widths(&mut self) -> Vec<usize> {
let num_cols = self.sheet_data.width();
let mut widths = vec![0; num_cols];
let headers = self.sheet_data.headers();
for (i, header) in headers.iter().enumerate() {
widths[i] = header.len();
}
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();
widths[col_idx] = widths[col_idx].max(len);
}
}
widths.iter().map(|&w| w.clamp(3, 30)).collect()
}
fn update_horizontal_scroll(&mut self, viewport_width: usize) {
if !self.horizontal_scroll_enabled {
return;
}
let mut total_width = 0;
let mut visible_end = self.horizontal_scroll_offset;
for i in self.horizontal_scroll_offset..self.column_widths.len() {
total_width += self.column_widths[i] + 1; visible_end = i + 1; if total_width > viewport_width {
break; }
}
if self.cursor_col >= visible_end {
self.horizontal_scroll_offset += 1;
}
if self.cursor_col < self.horizontal_scroll_offset {
self.horizontal_scroll_offset = self.cursor_col;
}
}
fn move_left(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
if self.horizontal_scroll_enabled && self.cursor_col < self.horizontal_scroll_offset {
self.horizontal_scroll_offset = self.cursor_col;
}
}
}
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;
if self.horizontal_scroll_enabled {
self.horizontal_scroll_offset = 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 key_matches(
&self,
code: KeyCode,
modifiers: crossterm::event::KeyModifiers,
action: &str,
) -> bool {
if let Some((expected_code, expected_mods)) = self.config.get_keybinding(action) {
code == expected_code && modifiers == expected_mods
} else {
false
}
}
fn handle_event(&mut self, event: Event) {
if let Event::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
..
}) = event
{
if self.show_help {
self.show_help = false;
return;
}
if self.show_cell_detail {
match code {
KeyCode::Up => {
self.cell_detail_scroll = self.cell_detail_scroll.saturating_sub(1);
}
KeyCode::Down => {
self.cell_detail_scroll = self.cell_detail_scroll.saturating_add(1);
}
KeyCode::PageUp => {
self.cell_detail_scroll = self.cell_detail_scroll.saturating_sub(10);
}
KeyCode::PageDown => {
self.cell_detail_scroll = self.cell_detail_scroll.saturating_add(10);
}
KeyCode::Home => {
self.cell_detail_scroll = 0;
}
_ => {
self.show_cell_detail = false;
self.cell_detail_scroll = 0;
}
}
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;
}
if self.key_matches(code, modifiers, "quit") {
self.should_quit = true;
} else if self.key_matches(code, modifiers, "help") {
self.show_help = true;
} else if self.key_matches(code, modifiers, "theme_toggle") {
self.current_theme = self.current_theme.next();
} else if self.key_matches(code, modifiers, "search") {
self.search_mode = true;
self.clear_search();
} else if self.key_matches(code, modifiers, "next_match") {
self.jump_to_next_match();
} else if self.key_matches(code, modifiers, "prev_match") {
self.jump_to_prev_match();
} else if self.key_matches(code, modifiers, "copy_cell") {
self.copy_current_cell();
} else if self.key_matches(code, modifiers, "copy_row") {
self.copy_current_row();
} else if self.key_matches(code, modifiers, "jump") {
self.enter_jump_mode();
} else if self.key_matches(code, modifiers, "show_cell_detail") {
self.show_cell_detail = true;
self.cell_detail_scroll = 0;
} else if self.key_matches(code, modifiers, "next_sheet") {
let _ = self.switch_to_next_sheet();
} else if self.key_matches(code, modifiers, "prev_sheet") || code == KeyCode::BackTab {
let _ = self.switch_to_prev_sheet();
} else if self.key_matches(code, modifiers, "up") {
self.move_up();
} else if self.key_matches(code, modifiers, "down") {
self.move_down();
} else if self.key_matches(code, modifiers, "left") {
self.move_left();
} else if self.key_matches(code, modifiers, "right") {
self.move_right();
} else if self.key_matches(code, modifiers, "jump_to_top") {
self.move_to_top();
} else if self.key_matches(code, modifiers, "jump_to_bottom") {
self.move_to_bottom();
} else if self.key_matches(code, modifiers, "jump_to_row_start") {
self.move_to_start_of_row();
} else if self.key_matches(code, modifiers, "jump_to_row_end") {
self.move_to_end_of_row();
} else if self.key_matches(code, modifiers, "page_up") {
self.page_up(10);
} else if self.key_matches(code, modifiers, "page_down") {
self.page_down(10);
} else if code == KeyCode::Esc {
if !self.search_matches.is_empty() {
self.clear_search();
} else {
self.should_quit = true;
}
}
}
}
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; let viewport_width = chunks[0].width.saturating_sub(2) as usize;
self.update_scroll(table_height);
self.update_horizontal_scroll(viewport_width);
let visible_start = self.scroll_offset;
let (visible_col_start, visible_col_end) = if self.horizontal_scroll_enabled {
let mut total_width = 0;
let mut end = self.horizontal_scroll_offset;
for i in self.horizontal_scroll_offset..self.column_widths.len() {
total_width += self.column_widths[i] + 1; end = i + 1; if total_width > viewport_width {
break; }
}
(self.horizontal_scroll_offset, end)
} else {
(0, self.sheet_data.width())
};
let headers = self.sheet_data.headers().to_vec();
let colors = self.current_theme.colors();
let header_cells: Vec<Cell> = headers
.iter()
.enumerate()
.skip(visible_col_start)
.take(visible_col_end - visible_col_start)
.map(|(col_idx, h)| {
let mut style = Style::default()
.fg(colors.header_fg)
.add_modifier(Modifier::BOLD);
if let Some(bg) = colors.header_bg {
style = style.bg(bg);
}
if col_idx == self.cursor_col {
style = style.fg(colors.current_col_fg);
}
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()
.skip(visible_col_start)
.take(visible_col_end - visible_col_start)
.map(|(col_idx, cell)| {
let mut style = Style::default().fg(colors.cell_color(cell));
let is_alternating_row = row_idx % 2 == 1;
if is_alternating_row && let Some(alt_bg) = colors.alternating_row_bg {
style = style.bg(alt_bg);
}
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(colors.current_search_bg)
.fg(colors.current_search_fg)
.add_modifier(Modifier::BOLD);
}
else if row_idx == self.cursor_row && col_idx == self.cursor_col {
style = style
.bg(colors.current_cell_bg)
.fg(colors.current_cell_fg)
.add_modifier(Modifier::BOLD);
}
else if is_search_match {
style = style.bg(colors.search_match_bg).fg(colors.search_match_fg);
}
else if row_idx == self.cursor_row {
style = style.bg(colors.current_row_bg);
}
else if col_idx == self.cursor_col {
style = style.fg(colors.current_col_fg);
}
Cell::from(cell.to_string()).style(style)
})
.collect();
Row::new(cells).height(1)
})
.collect();
let col_widths: Vec<Constraint> = if self.horizontal_scroll_enabled {
self.column_widths[visible_col_start..visible_col_end]
.iter()
.map(|&w| Constraint::Length(w as u16))
.collect()
} else {
let sheet_width = self.sheet_data.width();
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)
.border_style(Style::default().fg(colors.border_fg))
.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 sheet_dims = if self.horizontal_scroll_enabled && self.horizontal_scroll_offset > 0 {
let first_col = headers
.get(visible_col_start)
.map(|s| s.as_str())
.unwrap_or("?");
let last_col = headers
.get(visible_col_end.saturating_sub(1))
.map(|s| s.as_str())
.unwrap_or("?");
format!(
"{} rows × {} columns (showing {}-{})",
self.sheet_data.height(),
self.sheet_data.width(),
first_col,
last_col
)
} else {
format!(
"{} rows × {} columns",
self.sheet_data.height(),
self.sheet_data.width()
)
};
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 | {} | Tab:next sheet ?:help q:quit ",
match_info,
self.current_cell_address(),
sheet_dims
)
} else {
format!(
" {} | {}n:next N:prev Esc:clear | {} | ?:help q:quit ",
match_info,
self.current_cell_address(),
sheet_dims
)
}
} else {
let mode_indicator = match &self.sheet_data {
SheetDataSource::Lazy { .. } => " [Lazy] ",
SheetDataSource::Eager(_) => "",
};
if self.sheet_names.len() > 1 {
format!(
" {} | {}{} | Theme: {} | t:theme /:search Tab:sheet ?:help q:quit ",
self.current_cell_address(),
sheet_dims,
mode_indicator,
self.current_theme.name()
)
} else {
format!(
" {} | {}{} | Theme: {} | t:theme /:search ?:help q:quit ",
self.current_cell_address(),
sheet_dims,
mode_indicator,
self.current_theme.name()
)
}
};
let mut status_style = Style::default().fg(colors.status_bar_fg);
if let Some(bg) = colors.status_bar_bg {
status_style = status_style.bg(bg);
}
let status = Paragraph::new(status_text).style(status_style).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colors.border_fg))
.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(" t ", Style::default().fg(Color::Green)),
Span::raw("Cycle through color themes"),
]),
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(" Cell colors vary by type and current theme:"),
Line::from(" • Numbers, strings, dates, booleans, errors each have distinct colors"),
Line::from(" • Alternating row backgrounds improve readability"),
Line::from(" • Press 't' to cycle through 6 built-in themes"),
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(Span::styled(
"CONFIGURATION",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(" Customize keybindings and theme in config file:"),
Line::from(" ~/.config/xleak/config.toml"),
Line::from(""),
Line::from(" Supports VIM-style navigation (hjkl, gg, G, 0, $)"),
Line::from(" Custom keybindings per action"),
Line::from(" Default theme selection"),
Line::from(""),
Line::from(" See config.toml.example for all options"),
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(
"↑↓ to scroll | Any other 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 content_height = popup_height.saturating_sub(2) as usize; let total_lines = detail_lines.len();
let max_scroll = total_lines.saturating_sub(content_height);
let scroll_offset = self.cell_detail_scroll.min(max_scroll);
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 mut title_spans = vec![
Span::raw(" "),
Span::styled(
"Cell Details",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - "),
Span::styled(cell_addr.clone(), Style::default().fg(Color::Cyan)),
];
if total_lines > content_height {
let scroll_info = format!(" [{}/{}]", scroll_offset + 1, max_scroll + 1);
title_spans.push(Span::styled(
scroll_info,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
));
}
title_spans.push(Span::raw(" "));
let detail_paragraph = Paragraph::new(detail_lines)
.style(Style::default().fg(Color::White).bg(Color::Black))
.scroll((scroll_offset as u16, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.title(title_spans)
.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,
config: &crate::config::Config,
horizontal_scroll: bool,
) -> Result<()> {
use std::io::IsTerminal;
if !io::stdout().is_terminal() {
anyhow::bail!(
"Interactive mode requires a terminal (TTY). \
Your output is redirected or not connected to a terminal.\n\
Hint: Run this command directly in your terminal, not through pipes or automation."
);
}
enable_raw_mode().context("Failed to enable terminal raw mode. Is this a proper TTY?")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen mode")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("Failed to initialize terminal backend")?;
let mut app = TuiState::new(workbook, sheet_name, config, horizontal_scroll)?;
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");
}
}