From c2245543b81751873038b61d691d11245ceb0623 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Wed, 22 Apr 2026 18:15:49 +0800 Subject: [PATCH 01/13] refactor(ui): remove analysis panels and add structured help overlay - Remove Query Preview and Findings panels and their InputMode variants - Add :help command with a structured, scrollable key-reference overlay - Refresh title bar with EXCEL-CLI branding and inline row/col stats - Update status bar to a single-line layout; remove analysis tab buttons - Adjust theme: black background, consistent accent colors across panels - Clean up command executor and key handlers for removed preview/findings modes - Update render tests to reflect the new UI shell --- Cargo.toml | 2 +- src/app/findings.rs | 142 --- src/app/help.rs | 294 +++++++ src/app/mod.rs | 5 +- src/app/query_preview.rs | 195 ----- src/app/state.rs | 106 +-- src/app/ui.rs | 92 +- src/cli/check.rs | 8 - src/commands/executor.rs | 61 +- src/ui/handlers.rs | 149 ++-- src/ui/render.rs | 1776 +++++++++++++++++++++++++++++--------- 11 files changed, 1754 insertions(+), 1076 deletions(-) delete mode 100644 src/app/findings.rs create mode 100644 src/app/help.rs delete mode 100644 src/app/query_preview.rs diff --git a/Cargo.toml b/Cargo.toml index b60ad72..b34cfa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "excel-cli" -version = "1.3.0" +version = "1.3.1" edition = "2021" description = "Excel CLI for AI, scripting, and terminal users. Headless JSON API for automation, plus a Vim-like TUI for interactive browsing and editing." license = "MIT" diff --git a/src/app/findings.rs b/src/app/findings.rs deleted file mode 100644 index 576e02c..0000000 --- a/src/app/findings.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::app::{AppState, InputMode}; -use crate::cli::args::SeverityThreshold; -use crate::cli::check::{run_check_report, CheckFinding}; -use crate::utils::{cell_reference, parse_range}; - -#[derive(Clone, Debug, Default)] -pub(crate) struct FindingsState { - pub(crate) items: Vec, - pub(crate) selected: usize, - pub(crate) last_refresh_error: Option, -} - -impl FindingsState { - fn clamp_selected(&mut self) { - if self.items.is_empty() { - self.selected = 0; - } else { - self.selected = self.selected.min(self.items.len() - 1); - } - } -} - -impl AppState<'_> { - pub fn show_findings(&mut self) { - self.input_mode = InputMode::Findings; - self.refresh_findings(); - } - - pub fn close_findings(&mut self) { - self.input_mode = InputMode::Normal; - } - - pub fn refresh_findings(&mut self) { - let was_modified = self.workbook.is_modified(); - let result = self.ensure_findings_workbook_ready().and_then(|_| { - run_check_report(&mut self.workbook, None, None, SeverityThreshold::Info) - }); - self.workbook.set_modified(was_modified); - - match result { - Ok(report) => { - let finding_count = report.findings.len(); - self.findings.items = report.findings; - self.findings.last_refresh_error = None; - self.findings.clamp_selected(); - - if finding_count == 0 { - self.add_notification("No findings in current workbook".to_string()); - } else { - self.add_notification(format!("Loaded {finding_count} findings")); - } - } - Err(err) => { - self.findings.items.clear(); - self.findings.selected = 0; - self.findings.last_refresh_error = Some(err.to_string()); - self.add_notification(format!("Findings refresh failed: {err}")); - } - } - } - - pub fn select_next_finding(&mut self) { - if self.findings.selected + 1 < self.findings.items.len() { - self.findings.selected += 1; - } - } - - pub fn select_prev_finding(&mut self) { - self.findings.selected = self.findings.selected.saturating_sub(1); - } - - pub fn activate_selected_finding(&mut self) { - let Some(finding) = self.findings.items.get(self.findings.selected).cloned() else { - self.add_notification("No finding selected".to_string()); - return; - }; - - let target_index = match self.workbook.resolve_sheet_by_name(&finding.sheet) { - Ok(index) => index, - Err(err) => { - self.add_notification(format!( - "Finding sheet '{}' not found: {err}", - finding.sheet - )); - return; - } - }; - - if self.workbook.get_current_sheet_index() != target_index { - if let Err(err) = self.switch_sheet_by_index(target_index) { - self.add_notification(format!("Failed to switch to finding sheet: {err}")); - return; - } - } - - let Some(target_cell) = finding_target_cell(&finding) else { - self.add_notification(format!("Jumped to finding on sheet '{}'", finding.sheet)); - return; - }; - - let sheet = self.workbook.get_current_sheet(); - let max_row = sheet.max_rows.max(1); - let max_col = sheet.max_cols.max(1); - let clamped = (target_cell.0.min(max_row), target_cell.1.min(max_col)); - - self.selected_cell = clamped; - self.handle_scrolling(); - - if clamped == target_cell { - self.add_notification(format!("Jumped to finding at {}", cell_reference(clamped))); - } else { - self.add_notification(format!( - "Finding target {} was out of range; jumped to {}", - cell_reference(target_cell), - cell_reference(clamped) - )); - } - } - - fn ensure_findings_workbook_ready(&mut self) -> Result<(), crate::cli::error::AppError> { - let sheet_names = self.workbook.get_sheet_names(); - for (index, sheet_name) in sheet_names.iter().enumerate() { - self.workbook - .ensure_sheet_loaded(index, sheet_name) - .map_err(crate::cli::error::anyhow_to_app_error)?; - } - Ok(()) - } -} - -fn finding_target_cell(finding: &CheckFinding) -> Option<(usize, usize)> { - match (finding.row, finding.column) { - (Some(row), Some(col)) => Some((row, col)), - _ => finding - .range - .as_deref() - .and_then(parse_range) - .map(|(start, _)| start) - .or_else(|| finding.row.map(|row| (row, 1))) - .or_else(|| finding.column.map(|col| (1, col))), - } -} diff --git a/src/app/help.rs b/src/app/help.rs new file mode 100644 index 0000000..2da0fbf --- /dev/null +++ b/src/app/help.rs @@ -0,0 +1,294 @@ +pub struct HelpEntry { + pub keys: &'static str, + pub description: &'static str, +} + +pub struct HelpSection { + pub title: &'static str, + pub entries: &'static [HelpEntry], +} + +pub const LEFT_HELP_SECTIONS: &[HelpSection] = &[ + HelpSection { + title: "NAVIGATION", + entries: &[ + HelpEntry { + keys: "h j k l / arrows", + description: "Move cell", + }, + HelpEntry { + keys: "[ / ]", + description: "Switch sheet", + }, + HelpEntry { + keys: "gg / G", + description: "Start/end of data", + }, + HelpEntry { + keys: "0 / ^ / $", + description: "Row start / first non-empty / end", + }, + HelpEntry { + keys: "Ctrl+arrows", + description: "Jump to next non-empty cell", + }, + ], + }, + HelpSection { + title: "SEARCH", + entries: &[ + HelpEntry { + keys: "/", + description: "Search forward", + }, + HelpEntry { + keys: "?", + description: "Search backward", + }, + HelpEntry { + keys: "n / N", + description: "Next/previous search result", + }, + HelpEntry { + keys: ":noh / :nohlsearch", + description: "Disable search highlighting", + }, + ], + }, + HelpSection { + title: "JUMP & SHEETS", + entries: &[ + HelpEntry { + keys: ":", + description: "Jump to cell, e.g. :B10", + }, + HelpEntry { + keys: ":sheet ", + description: "Switch sheet", + }, + HelpEntry { + keys: ":addsheet ", + description: "Add sheet after current", + }, + HelpEntry { + keys: ":delsheet", + description: "Delete current sheet", + }, + ], + }, + HelpSection { + title: "ROWS & COLUMNS", + entries: &[ + HelpEntry { + keys: ":cw fit", + description: "Fit current column", + }, + HelpEntry { + keys: ":cw fit all", + description: "Fit all columns", + }, + HelpEntry { + keys: ":cw min", + description: "Minimize current column", + }, + HelpEntry { + keys: ":cw min all", + description: "Minimize all columns", + }, + HelpEntry { + keys: ":cw ", + description: "Set current column width", + }, + HelpEntry { + keys: ":dr / :dr ", + description: "Delete current/specific row", + }, + HelpEntry { + keys: ":dr ", + description: "Delete row range", + }, + HelpEntry { + keys: ":dc / :dc ", + description: "Delete current/specific column", + }, + HelpEntry { + keys: ":dc ", + description: "Delete column range", + }, + ], + }, +]; + +pub const RIGHT_HELP_SECTIONS: &[HelpSection] = &[ + HelpSection { + title: "ACTIONS", + entries: &[ + HelpEntry { + keys: "Enter", + description: "Edit cell", + }, + HelpEntry { + keys: "y / :y", + description: "Copy current cell", + }, + HelpEntry { + keys: "d / :d", + description: "Cut current cell", + }, + HelpEntry { + keys: "p / :put / :pu", + description: "Paste to current cell", + }, + HelpEntry { + keys: "u", + description: "Undo", + }, + HelpEntry { + keys: "Ctrl+r", + description: "Redo", + }, + HelpEntry { + keys: "+ / = / -", + description: "Resize info panel", + }, + ], + }, + HelpSection { + title: "FILE & APP", + entries: &[ + HelpEntry { + keys: ":w", + description: "Save file", + }, + HelpEntry { + keys: ":wq / :x", + description: "Save and quit", + }, + HelpEntry { + keys: ":q", + description: "Quit, warn if unsaved", + }, + HelpEntry { + keys: ":q!", + description: "Force quit without saving", + }, + HelpEntry { + keys: ":help", + description: "Show this overlay", + }, + ], + }, + HelpSection { + title: "EXPORT", + entries: &[ + HelpEntry { + keys: ":ej", + description: "Export current sheet JSON", + }, + HelpEntry { + keys: ":ej ", + description: "Export with header direction/count", + }, + HelpEntry { + keys: ":eja", + description: "Export all sheets JSON", + }, + HelpEntry { + keys: ":eja ", + description: "Export all with header settings", + }, + ], + }, + HelpSection { + title: "EDIT MODE", + entries: &[ + HelpEntry { + keys: "Esc", + description: "Save edits and return", + }, + HelpEntry { + keys: "i / v", + description: "Insert / visual mode", + }, + HelpEntry { + keys: "h j k l", + description: "Move cursor", + }, + HelpEntry { + keys: "w / b / e", + description: "Word navigation", + }, + HelpEntry { + keys: "^ / $", + description: "Line start / end", + }, + HelpEntry { + keys: "gg / G", + description: "First / last line", + }, + HelpEntry { + keys: "x / D / C", + description: "Delete/change text", + }, + HelpEntry { + keys: "y / d / c", + description: "Operator commands", + }, + HelpEntry { + keys: "p / u / Ctrl+r", + description: "Paste / undo / redo", + }, + HelpEntry { + keys: "o / O / A / I", + description: "Open or insert at line edges", + }, + ], + }, + HelpSection { + title: "HELP CONTROLS", + entries: &[ + HelpEntry { + keys: "Esc / q / Enter", + description: "Close overlay", + }, + HelpEntry { + keys: "j / k / arrows", + description: "Scroll one line", + }, + HelpEntry { + keys: "PgUp / PgDn", + description: "Scroll one page", + }, + HelpEntry { + keys: "Home / End", + description: "Jump to top/bottom", + }, + ], + }, +]; + +pub fn help_reference_line_count() -> usize { + column_line_count(LEFT_HELP_SECTIONS) + column_line_count(RIGHT_HELP_SECTIONS) + 1 +} + +pub fn help_reference_text() -> String { + let mut lines = Vec::new(); + + for section in LEFT_HELP_SECTIONS.iter().chain(RIGHT_HELP_SECTIONS.iter()) { + lines.push(section.title.to_string()); + for entry in section.entries { + lines.push(format!("{} - {}", entry.keys, entry.description)); + } + lines.push(String::new()); + } + + lines.join("\n") +} + +fn column_line_count(sections: &[HelpSection]) -> usize { + sections + .iter() + .map(|section| section.entries.len() + 2) + .sum::() + .saturating_sub(1) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 9079b85..a4b5067 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,6 @@ mod edit; -mod findings; +mod help; mod navigation; -mod query_preview; mod search; mod sheet; mod state; @@ -10,6 +9,6 @@ mod undo_manager; mod vim; mod word; -pub use query_preview::*; +pub use help::*; pub use state::*; pub use vim::*; diff --git a/src/app/query_preview.rs b/src/app/query_preview.rs deleted file mode 100644 index 692691e..0000000 --- a/src/app/query_preview.rs +++ /dev/null @@ -1,195 +0,0 @@ -use crate::app::{AppState, InputMode}; -use crate::utils::{cell_reference, index_to_col_name}; - -const SAMPLE_ROWS: usize = 6; -const SAMPLE_COLS: usize = 6; -const MAX_CELL_DISPLAY_CHARS: usize = 24; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct QueryPreview { - pub file_path: String, - pub sheet_name: String, - pub sheet_index: usize, - pub selected_cell: String, - pub used_range: String, - pub selects: String, - pub filters: String, - pub columns: Vec, - pub rows: Vec, - pub truncated: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct QueryPreviewRow { - pub row_number: usize, - pub values: Vec, -} - -impl QueryPreview { - fn from_app(app: &AppState) -> Self { - let sheet = app.workbook.get_current_sheet(); - let sheet_index = app.workbook.get_current_sheet_index(); - let selected_cell = cell_reference(app.selected_cell); - let used_range = if sheet.max_rows == 0 || sheet.max_cols == 0 { - "empty".to_string() - } else { - format!("A1:{}{}", index_to_col_name(sheet.max_cols), sheet.max_rows) - }; - - let sample_start_row = if sheet.max_rows == 0 { - 0 - } else { - app.selected_cell.0.clamp(1, sheet.max_rows) - }; - let sample_start_col = if sheet.max_cols == 0 { 0 } else { 1 }; - let sample_end_row = (sample_start_row + SAMPLE_ROWS.saturating_sub(1)).min(sheet.max_rows); - let sample_end_col = (sample_start_col + SAMPLE_COLS.saturating_sub(1)).min(sheet.max_cols); - - let columns = if sample_start_col == 0 { - Vec::new() - } else { - (sample_start_col..=sample_end_col) - .map(index_to_col_name) - .collect() - }; - - let rows = if sample_start_row == 0 || sample_start_col == 0 { - Vec::new() - } else { - (sample_start_row..=sample_end_row) - .map(|row| QueryPreviewRow { - row_number: row, - values: (sample_start_col..=sample_end_col) - .map(|col| { - sheet - .data - .get(row) - .and_then(|cells| cells.get(col)) - .map(|cell| truncate_cell(&cell.value)) - .unwrap_or_default() - }) - .collect(), - }) - .collect() - }; - - let truncated = sample_start_row > 1 - || sample_start_col > 1 - || sample_end_row < sheet.max_rows - || sample_end_col < sheet.max_cols; - - Self { - file_path: app.file_path.to_string_lossy().to_string(), - sheet_name: sheet.name.clone(), - sheet_index: sheet_index + 1, - selected_cell, - used_range, - selects: "all columns".to_string(), - filters: "none".to_string(), - columns, - rows, - truncated, - } - } -} - -impl AppState<'_> { - pub fn show_query_preview(&mut self) { - let sheet_index = self.workbook.get_current_sheet_index(); - let sheet_name = self.workbook.get_current_sheet_name(); - - if self.workbook.is_lazy_loading() && !self.workbook.is_sheet_loaded(sheet_index) { - if let Err(e) = self.workbook.ensure_sheet_loaded(sheet_index, &sheet_name) { - self.add_notification(format!("Preview failed: {e}")); - return; - } - } - - self.query_preview = Some(QueryPreview::from_app(self)); - self.input_mode = InputMode::Preview; - } - - pub fn close_query_preview(&mut self) { - self.query_preview = None; - self.input_mode = InputMode::Normal; - } -} - -fn truncate_cell(value: &str) -> String { - let mut result = String::new(); - for (idx, ch) in value.chars().enumerate() { - if idx >= MAX_CELL_DISPLAY_CHARS { - result.push_str("..."); - return result; - } - result.push(ch); - } - result -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::app::AppState; - use crate::excel::{Cell, Sheet, Workbook}; - - fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet { - let max_rows = values.len(); - let max_cols = values.iter().map(|row| row.len()).max().unwrap_or(0); - let mut data = vec![vec![Cell::empty(); max_cols + 1]; max_rows + 1]; - - for (row_idx, row) in values.iter().enumerate() { - for (col_idx, value) in row.iter().enumerate() { - data[row_idx + 1][col_idx + 1] = Cell::new((*value).to_string(), false); - } - } - - Sheet { - name: name.to_string(), - data, - max_rows, - max_cols, - is_loaded: true, - } - } - - #[test] - fn preview_snapshots_current_target_and_capped_sample() { - let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( - "Data", - &[ - &[ - "Name", "Region", "Sales", "Owner", "Quarter", "Status", "Notes", - ], - &["Ada", "West", "10", "Mina", "Q1", "Open", "A"], - &["Ben", "East", "12", "Noor", "Q1", "Won", "B"], - &["Cid", "North", "9", "Ira", "Q2", "Open", "C"], - &["Dee", "South", "7", "Ola", "Q2", "Lost", "D"], - &["Eli", "West", "8", "Paz", "Q3", "Open", "E"], - &["Fay", "East", "11", "Uma", "Q3", "Won", "F"], - ], - )]); - let mut app = AppState::new(workbook, PathBuf::from("/tmp/report.xlsx")).unwrap(); - app.selected_cell = (2, 2); - - app.show_query_preview(); - - let preview = app.query_preview.as_ref().expect("preview should be set"); - assert_eq!(preview.file_path, "/tmp/report.xlsx"); - assert_eq!(preview.sheet_name, "Data"); - assert_eq!(preview.sheet_index, 1); - assert_eq!(preview.selected_cell, "B2"); - assert_eq!(preview.used_range, "A1:G7"); - assert_eq!(preview.selects, "all columns"); - assert_eq!(preview.filters, "none"); - assert_eq!(preview.columns, vec!["A", "B", "C", "D", "E", "F"]); - assert_eq!(preview.rows.len(), 6); - assert_eq!(preview.rows[0].row_number, 2); - assert_eq!( - preview.rows[0].values, - vec!["Ada", "West", "10", "Mina", "Q1", "Open"] - ); - assert!(preview.truncated); - } -} diff --git a/src/app/state.rs b/src/app/state.rs index 07f69ff..0467570 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -4,8 +4,6 @@ use std::path::PathBuf; use tui_textarea::TextArea; use crate::actions::UndoHistory; -use crate::app::findings::FindingsState; -use crate::app::QueryPreview; use crate::app::VimState; use crate::excel::Workbook; @@ -25,8 +23,6 @@ pub enum InputMode { SearchForward, SearchBackward, Help, - Preview, - Findings, LazyLoading, CommandInLazyLoading, } @@ -60,8 +56,7 @@ pub struct AppState<'a> { pub help_text: String, pub help_scroll: usize, pub help_visible_lines: usize, - pub query_preview: Option, - pub(crate) findings: FindingsState, + pub help_total_lines: usize, pub undo_history: UndoHistory, pub vim_state: Option, } @@ -159,8 +154,7 @@ impl AppState<'_> { help_text: String::new(), help_scroll: 0, help_visible_lines: 20, - query_preview: None, - findings: FindingsState::default(), + help_total_lines: 0, undo_history: UndoHistory::new(), vim_state: None, }) @@ -228,14 +222,6 @@ impl AppState<'_> { pub fn cancel_input(&mut self) { match self.input_mode { - InputMode::Preview => { - self.close_query_preview(); - return; - } - InputMode::Findings => { - self.close_findings(); - return; - } InputMode::Help => { self.input_mode = InputMode::Normal; return; @@ -273,91 +259,3 @@ impl AppState<'_> { self.input_buffer = String::new(); } } - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::{AppState, InputMode}; - use crate::cli::check::CheckRuleId; - use crate::excel::{Cell, Sheet, Workbook}; - - fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet { - let max_rows = values.len(); - let max_cols = values.iter().map(|row| row.len()).max().unwrap_or(0); - let mut data = vec![vec![Cell::empty(); max_cols + 1]; max_rows + 1]; - - for (row_idx, row) in values.iter().enumerate() { - for (col_idx, value) in row.iter().enumerate() { - data[row_idx + 1][col_idx + 1] = Cell::new((*value).to_string(), false); - } - } - - Sheet { - name: name.to_string(), - data, - max_rows, - max_cols, - is_loaded: true, - } - } - - #[test] - fn show_findings_refreshes_report_without_marking_workbook_modified() { - let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( - "Data", - &[&["Name", "Name"], &["Ada", ""], &["", ""]], - )]); - let mut app = AppState::new(workbook, PathBuf::from("quality.xlsx")).unwrap(); - - app.show_findings(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(!app.workbook.is_modified()); - assert!(!app.findings.items.is_empty()); - assert_eq!(app.findings.selected, 0); - } - - #[test] - fn activate_selected_finding_switches_sheet_and_uses_range_fallback() { - let workbook = Workbook::from_sheets_for_test(vec![ - sheet_with_values("Summary", &[&["Status"], &["ok"]]), - sheet_with_values("报告", &[&["Name", ""], &["Ada", ""]]), - ]); - let mut app = AppState::new(workbook, PathBuf::from("quality.xlsx")).unwrap(); - - app.show_findings(); - app.findings.selected = app - .findings - .items - .iter() - .position(|finding| finding.rule_id == CheckRuleId::BlankColumns) - .expect("blank column finding should exist"); - app.activate_selected_finding(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert_eq!(app.workbook.get_current_sheet_name(), "报告"); - assert_eq!(app.selected_cell, (1, 2)); - } - - #[test] - fn activate_selected_finding_prefers_exact_row_and_column() { - let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( - "Data", - &[&["Name", "Score"], &["Ada", "10"], &["Ada", "11"]], - )]); - let mut app = AppState::new(workbook, PathBuf::from("quality.xlsx")).unwrap(); - - app.show_findings(); - app.findings.selected = app - .findings - .items - .iter() - .position(|finding| finding.rule_id == CheckRuleId::DuplicateValues) - .expect("duplicate values finding should exist"); - app.activate_selected_finding(); - - assert_eq!(app.workbook.get_current_sheet_name(), "Data"); - assert_eq!(app.selected_cell, (2, 1)); - } -} diff --git a/src/app/ui.rs b/src/app/ui.rs index afca0a3..3cecd3c 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -4,96 +4,8 @@ use crate::app::InputMode; impl AppState<'_> { pub fn show_help(&mut self) { self.help_scroll = 0; - - self.help_text = "FILE OPERATIONS:\n\ - :w - Save file\n\ - :wq, :x - Save and quit\n\ - :q - Quit (will warn if unsaved changes)\n\ - :q! - Force quit without saving\n\n\ - NAVIGATION:\n\ - :[cell] - Jump to cell (e.g., :B10)\n\ - :preview, :pv - Show read-only preview of current sheet data\n\ - :findings, :issues - Open workbook findings panel\n\ - hjkl - Move cursor (left, down, up, right)\n\ - f - Open or refresh findings panel\n\ - 0 - Jump to first column\n\ - ^ - Jump to first non-empty column\n\ - $ - Jump to last column\n\ - gg - Jump to first row\n\ - G - Jump to last row\n\ - Ctrl+arrows - Jump to next non-empty cell\n\ - [ - Switch to previous sheet\n\ - ] - Switch to next sheet\n\ - :sheet [name/number] - Switch to sheet by name or index\n\n\ - EDITING:\n\ - Enter - Edit current cell\n\ - :y - Copy current cell\n\ - :d - Cut current cell\n\ - :put, :pu - Paste to current cell\n\ - u - Undo last operation\n\ - Ctrl+r - Redo last undone operation\n\n\ - SEARCH:\n\ - / - Search forward\n\ - ? - Search backward\n\ - n - Jump to next search result\n\ - N - Jump to previous search result\n\ - :nohlsearch, :noh - Disable search highlighting\n\n\ - FINDINGS PANEL:\n\ - j/k, ↑/↓ - Move findings selection\n\ - Enter - Jump to selected finding location\n\ - r or f - Refresh findings\n\ - Esc or q - Close findings panel\n\n\ -\ - COLUMN OPERATIONS:\n\ - :cw fit - Adjust width of current column to fit its content\n\ - :cw fit all - Adjust width of all columns to fit their content\n\ - :cw min - Set current column width to minimum (5 characters)\n\ - :cw min all - Set all columns width to minimum\n\ - :cw [number] - Set current column width to specific number of characters\n\ - :dc - Delete current column\n\ - :dc [col] - Delete specific column (e.g., :dc A or :dc 1)\n\ - :dc [start] [end] - Delete columns from start to end (e.g., :dc A C)\n\n\ - ROW OPERATIONS:\n\ - :dr - Delete current row\n\ - :dr [row] - Delete specific row\n\ - :dr [start] [end] - Delete rows from start to end\n\n\ - EXPORT:\n\ - :ej [h|v] [rows] - Export current sheet to JSON\n\ - :eja [h|v] [rows] - Export all sheets to a single JSON file\n\ - h=horizontal (default), v=vertical\n\ - [rows]=number of header rows (default: 1)\n\n\ - SHEET OPERATIONS:\n\ - :addsheet [name] - Add a new sheet after the current sheet\n\ - :delsheet - Delete the current sheet\n\n\ - UI ADJUSTMENTS:\n\ - +/= - Increase info panel height\n\ - - - Decrease info panel height\n\n\ - EDITING MODE:\n\ - Esc - Exit Vim mode and save changes\n\ - i - Enter Insert mode\n\ - v - Enter Visual mode\n\ - y - Yank (copy) text in Visual mode or with operator\n\ - d - Delete text in Visual mode or with operator\n\ - c - Change text in Visual mode or with operator\n\ - p - Paste yanked or deleted text\n\ - u - Undo last change\n\ - Ctrl+r - Redo last undone change\n\ - h,j,k,l - Move cursor left, down, up, right\n\ - w - Move to next word\n\ - b - Move to beginning of word\n\ - e - Move to end of word\n\ - $ - Move to end of line\n\ - ^ - Move to first non-blank character of line\n\ - gg - Move to first line\n\ - G - Move to last line\n\ - x - Delete character under cursor\n\ - D - Delete to end of line\n\ - C - Change to end of line\n\ - o - Open new line below and enter Insert mode\n\ - O - Open new line above and enter Insert mode\n\ - A - Append at end of line\n\ - I - Insert at beginning of line" - .to_string(); + self.help_text = crate::app::help_reference_text(); + self.help_total_lines = crate::app::help_reference_line_count(); self.input_mode = InputMode::Help; } diff --git a/src/cli/check.rs b/src/cli/check.rs index cffa1b1..01f717c 100644 --- a/src/cli/check.rs +++ b/src/cli/check.rs @@ -944,14 +944,6 @@ impl Severity { SeverityThreshold::Error => Severity::Error, } } - - pub(crate) fn as_str(&self) -> &'static str { - match self { - Severity::Info => "info", - Severity::Warning => "warning", - Severity::Error => "error", - } - } } #[derive(Clone, Debug, Serialize)] diff --git a/src/commands/executor.rs b/src/commands/executor.rs index 96a95ca..c425989 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -52,8 +52,6 @@ impl AppState<'_> { } "nohlsearch" | "noh" => self.disable_search_highlight(), "help" => self.show_help(), - "preview" | "pv" => self.show_query_preview(), - "findings" | "issues" => self.show_findings(), "delsheet" => self.delete_current_sheet(), "addsheet" => self.add_notification("Usage: :addsheet ".to_string()), _ => { @@ -436,51 +434,18 @@ mod tests { } #[test] - fn preview_command_populates_preview_state() { - let mut app = app_with_sheet(); - app.input_buffer = "preview".to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Preview)); - assert_eq!( - app.query_preview - .as_ref() - .map(|preview| preview.sheet_name.as_str()), - Some("Data") - ); - } - - #[test] - fn preview_alias_populates_preview_state() { - let mut app = app_with_sheet(); - app.input_buffer = "pv".to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Preview)); - assert!(app.query_preview.is_some()); - } - - #[test] - fn findings_command_opens_findings_panel() { - let mut app = app_with_sheet(); - app.input_buffer = "findings".to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(!app.findings.items.is_empty()); - } - - #[test] - fn issues_alias_opens_findings_panel() { - let mut app = app_with_sheet(); - app.input_buffer = "issues".to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(!app.findings.items.is_empty()); + fn removed_analysis_commands_are_reported_as_unknown() { + for command in ["preview", "pv", "findings", "issues", "columns", "cols"] { + let mut app = app_with_sheet(); + app.input_buffer = command.to_string(); + + app.execute_command(); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert_eq!( + app.notification_messages.last().cloned(), + Some(format!("Unknown command: {command}")) + ); + } } } diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index 97a64c5..64ca26e 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -1,7 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tui_textarea::{Input, Key, TextArea}; -use crate::app::{AppState, InputMode}; +use crate::app::{help_reference_line_count, AppState, InputMode}; pub fn handle_key_event(app_state: &mut AppState, key: KeyEvent) { match app_state.input_mode { @@ -20,8 +20,6 @@ pub fn handle_key_event(app_state: &mut AppState, key: KeyEvent) { InputMode::SearchForward => handle_search_mode(app_state, key.code), InputMode::SearchBackward => handle_search_mode(app_state, key.code), InputMode::Help => handle_help_mode(app_state, key.code), - InputMode::Preview => handle_preview_mode(app_state, key.code), - InputMode::Findings => handle_findings_mode(app_state, key.code), InputMode::LazyLoading => handle_lazy_loading_mode(app_state, key.code), } } @@ -91,13 +89,6 @@ fn handle_command_in_lazy_loading_mode(app_state: &mut AppState, key_code: KeyCo } } -fn handle_preview_mode(app_state: &mut AppState, key_code: KeyCode) { - match key_code { - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => app_state.close_query_preview(), - _ => {} - } -} - fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { match key_code { KeyCode::Enter => { @@ -204,10 +195,6 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { app_state.g_pressed = false; app_state.start_command_mode(); } - KeyCode::Char('f') => { - app_state.g_pressed = false; - app_state.show_findings(); - } KeyCode::Char('/') => { app_state.g_pressed = false; app_state.start_search_forward(); @@ -264,17 +251,6 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { } } -fn handle_findings_mode(app_state: &mut AppState, key_code: KeyCode) { - match key_code { - KeyCode::Char('j') | KeyCode::Down => app_state.select_next_finding(), - KeyCode::Char('k') | KeyCode::Up => app_state.select_prev_finding(), - KeyCode::Enter => app_state.activate_selected_finding(), - KeyCode::Char('r') | KeyCode::Char('f') => app_state.refresh_findings(), - KeyCode::Esc | KeyCode::Char('q') => app_state.close_findings(), - _ => {} - } -} - fn handle_editing_mode(app_state: &mut AppState, key: KeyEvent) { // Convert KeyEvent to Input for tui-textarea let input = Input { @@ -386,30 +362,30 @@ fn handle_lazy_loading_mode(app_state: &mut AppState, key_code: KeyCode) { } fn handle_help_mode(app_state: &mut AppState, key_code: KeyCode) { - let line_count = app_state.help_text.lines().count(); - - let visible_lines = app_state.help_visible_lines; - + let line_count = app_state.help_total_lines.max(help_reference_line_count()); + let visible_lines = app_state.help_visible_lines.max(1); let max_scroll = line_count.saturating_sub(visible_lines); match key_code { - KeyCode::Enter | KeyCode::Esc => { + KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q') => { app_state.input_mode = InputMode::Normal; } KeyCode::Char('j') | KeyCode::Down => { - // Scroll down, but not beyond the last line app_state.help_scroll = (app_state.help_scroll + 1).min(max_scroll); } KeyCode::Char('k') | KeyCode::Up => { - // Scroll up app_state.help_scroll = app_state.help_scroll.saturating_sub(1); } + KeyCode::PageDown => { + app_state.help_scroll = (app_state.help_scroll + visible_lines).min(max_scroll); + } + KeyCode::PageUp => { + app_state.help_scroll = app_state.help_scroll.saturating_sub(visible_lines); + } KeyCode::Home => { - // Scroll to the top app_state.help_scroll = 0; } KeyCode::End => { - // Scroll to the bottom app_state.help_scroll = max_scroll; } _ => {} @@ -425,7 +401,7 @@ mod tests { use crate::app::{AppState, InputMode}; use crate::excel::{Cell, Sheet, Workbook}; - fn app_with_preview() -> AppState<'static> { + fn app_with_sheet() -> AppState<'static> { let mut data = vec![vec![Cell::empty(); 3]; 3]; data[1][1] = Cell::new("Name".to_string(), false); data[1][2] = Cell::new("Name".to_string(), false); @@ -438,67 +414,122 @@ mod tests { max_cols: 2, is_loaded: true, }; - let mut app = AppState::new( + let app = AppState::new( Workbook::from_sheets_for_test(vec![sheet]), PathBuf::from("test.xlsx"), ) .unwrap(); - app.show_query_preview(); app } #[test] - fn escape_closes_preview_without_quitting() { - let mut app = app_with_preview(); + fn f_no_longer_switches_to_analysis_mode() { + let mut app = app_with_sheet(); - handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + handle_key_event( + &mut app, + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), + ); assert!(matches!(app.input_mode, InputMode::Normal)); - assert!(app.query_preview.is_none()); assert!(!app.should_quit); } #[test] - fn q_closes_preview_without_quitting() { - let mut app = app_with_preview(); + fn c_no_longer_switches_to_analysis_mode() { + let mut app = app_with_sheet(); handle_key_event( &mut app, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()), ); assert!(matches!(app.input_mode, InputMode::Normal)); - assert!(app.query_preview.is_none()); - assert!(!app.should_quit); } #[test] - fn f_opens_findings_panel_from_normal_mode() { - let mut app = app_with_preview(); - app.close_query_preview(); + fn question_mark_starts_backward_search_from_normal_mode() { + let mut app = app_with_sheet(); handle_key_event( &mut app, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::empty()), ); - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(!app.findings.items.is_empty()); + assert!(matches!(app.input_mode, InputMode::SearchBackward)); } #[test] - fn j_moves_selected_finding_in_findings_mode() { - let mut app = app_with_preview(); - app.close_query_preview(); - app.show_findings(); + fn chinese_question_mark_does_not_open_help_overlay_from_normal_mode() { + let mut app = app_with_sheet(); - let initial = app.findings.selected; + handle_key_event( + &mut app, + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::empty()), + ); + + assert!(matches!(app.input_mode, InputMode::Normal)); + } + + #[test] + fn q_closes_help_overlay_without_quitting() { + let mut app = app_with_sheet(); + app.show_help(); + + handle_key_event( + &mut app, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()), + ); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(!app.should_quit); + } + + #[test] + fn page_keys_scroll_help_overlay_by_visible_page() { + let mut app = app_with_sheet(); + app.show_help(); + app.help_visible_lines = 8; handle_key_event( &mut app, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), + KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()), ); - assert!(app.findings.selected >= initial); + assert_eq!(app.help_scroll, 8); + + handle_key_event( + &mut app, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()), + ); + + assert_eq!(app.help_scroll, 0); + } + + #[test] + fn end_key_scrolls_help_overlay_to_full_reference_bottom() { + let mut app = app_with_sheet(); + app.show_help(); + app.help_visible_lines = 8; + + handle_key_event(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::empty())); + + let full_reference_bottom = crate::app::help_reference_text() + .lines() + .count() + .saturating_sub(app.help_visible_lines); + assert_eq!(app.help_scroll, full_reference_bottom); + } + + #[test] + fn end_key_uses_rendered_help_total_lines_when_available() { + let mut app = app_with_sheet(); + app.show_help(); + app.help_visible_lines = 8; + app.help_total_lines = 120; + + handle_key_event(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::empty())); + + assert_eq!(app.help_scroll, 112); } } diff --git a/src/ui/render.rs b/src/ui/render.rs index daa5281..e7b24c7 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -6,7 +6,7 @@ use crossterm::{ }; use ratatui::{ backend::CrosstermBackend, - layout::{Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}, @@ -15,11 +15,47 @@ use ratatui::{ use std::{io, time::Duration}; use crate::app::AppState; +use crate::app::HelpEntry; +use crate::app::HelpSection; use crate::app::InputMode; +use crate::app::VimMode; +use crate::app::LEFT_HELP_SECTIONS; +use crate::app::RIGHT_HELP_SECTIONS; use crate::ui::handlers::handle_key_event; use crate::utils::cell_reference; use crate::utils::index_to_col_name; +const HELP_ENTRY_INDENT: u16 = 2; +const HELP_ENTRY_GAP: u16 = 1; + +mod theme { + use ratatui::style::{Color, Style}; + + pub const BACKGROUND: Color = Color::Rgb(11, 16, 32); + pub const SURFACE: Color = Color::Rgb(17, 24, 39); + pub const SURFACE_MUTED: Color = Color::Rgb(31, 41, 55); + pub const GRID: Color = Color::Rgb(55, 65, 81); + pub const TEXT: Color = Color::Rgb(229, 231, 235); + pub const TEXT_SECONDARY: Color = Color::Rgb(156, 163, 175); + pub const TEXT_DISABLED: Color = Color::Rgb(107, 114, 128); + pub const ACCENT: Color = Color::Rgb(56, 189, 248); + pub const SEARCH: Color = Color::Rgb(250, 204, 21); + pub const WARNING: Color = Color::Rgb(245, 158, 11); + pub const SUCCESS: Color = Color::Rgb(34, 197, 94); + + pub fn base() -> Style { + Style::default().bg(BACKGROUND).fg(TEXT) + } + + pub fn surface() -> Style { + Style::default().bg(SURFACE).fg(TEXT) + } + + pub fn muted() -> Style { + Style::default().bg(SURFACE_MUTED).fg(TEXT_SECONDARY) + } +} + pub fn run_app(mut app_state: AppState) -> Result<()> { // Setup terminal let mut terminal = setup_terminal()?; @@ -113,14 +149,16 @@ fn update_visible_area(app_state: &mut AppState, area: Rect) { } fn ui(f: &mut Frame, app_state: &mut AppState) { - // Create the main layout + f.render_widget(Clear, f.size()); + let status_bar_height = status_bar_height(app_state, f.size().width); + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // Combined title bar and sheet tabs - Constraint::Min(1), // Spreadsheet - Constraint::Length(app_state.info_panel_height as u16), // Info panel - Constraint::Length(1), // Status bar + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(app_state.info_panel_height as u16), + Constraint::Length(status_bar_height), ]) .split(f.size()); @@ -128,9 +166,10 @@ fn ui(f: &mut Frame, app_state: &mut AppState) { update_visible_area(app_state, chunks[1]); draw_spreadsheet(f, app_state, chunks[1]); - draw_info_panel(f, app_state, chunks[2]); - draw_status_bar(f, app_state, chunks[3]); + if status_bar_height > 0 { + draw_status_bar(f, app_state, chunks[3]); + } // If in help mode, draw the help popup over everything else if let InputMode::Help = app_state.input_mode { @@ -167,25 +206,29 @@ fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { } // Set table style based on current mode - let (table_block, header_style, cell_style) = - if matches!(app_state.input_mode, InputMode::Normal) { - // In Normal mode, add color to the border of the data display area to indicate current focus - ( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)), - Style::default().bg(Color::DarkGray).fg(Color::Gray), - Style::default(), - ) + let is_editing = matches!(app_state.input_mode, InputMode::Editing); + let table_block = Block::default() + .style(theme::base()) + .borders(Borders::ALL) + .border_style(if is_editing { + Style::default().fg(theme::GRID) } else { - // In editing mode, dim the data display area - ( - Block::default().borders(Borders::ALL), - Style::default().fg(Color::DarkGray), - Style::default().fg(Color::DarkGray), // Dimmed cell content - ) - }; - + Style::default().fg(theme::ACCENT) + }); + let header_style = if is_editing { + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::TEXT_DISABLED) + } else { + theme::muted() + }; + let cell_style = if is_editing { + Style::default() + .bg(theme::BACKGROUND) + .fg(theme::TEXT_DISABLED) + } else { + theme::base() + }; // Create header row let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1); header_cells.push(Cell::from("").style(header_style)); @@ -280,9 +323,9 @@ fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { Style::default().bg(Color::White).fg(Color::Black) } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col)) { - Style::default().bg(Color::Yellow).fg(Color::Black) + Style::default().bg(theme::SEARCH).fg(Color::Black) } else { - Style::default() + cell_style }; cells.push(Cell::from(content).style(style)); @@ -322,10 +365,6 @@ fn parse_command(input: &str) -> Vec> { "nohlsearch", "noh", "help", - "preview", - "pv", - "findings", - "issues", "addsheet", "delsheet", ]; @@ -336,7 +375,7 @@ fn parse_command(input: &str) -> Vec> { // Check if input is a simple command without parameters if known_commands.contains(&input) { - return vec![Span::styled(input, Style::default().fg(Color::Yellow))]; + return vec![Span::styled(input, Style::default().fg(theme::WARNING))]; } // Extract command and parameters @@ -351,8 +390,7 @@ fn parse_command(input: &str) -> Vec> { if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) { let mut spans = Vec::new(); - // Add the command part with yellow color - spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow))); + spans.push(Span::styled(cmd, Style::default().fg(theme::WARNING))); // Add parameters if they exist if parts.len() > 1 { @@ -361,9 +399,9 @@ fn parse_command(input: &str) -> Vec> { for i in 1..parts.len() { // Determine style based on whether it's a special keyword let style = if special_keywords.contains(&parts[i]) { - Style::default().fg(Color::Yellow) // Keywords are yellow + Style::default().fg(theme::WARNING) } else { - Style::default().fg(Color::LightCyan) // Parameters are cyan + Style::default().fg(theme::ACCENT) }; spans.push(Span::styled(parts[i], style)); @@ -382,342 +420,384 @@ fn parse_command(input: &str) -> Vec> { vec![Span::raw(input)] } -fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { - let constraints = if matches!(app_state.input_mode, InputMode::Preview) { - [Constraint::Percentage(75), Constraint::Percentage(25)] +fn display_width(text: &str) -> u16 { + text.chars() + .fold(0, |acc, ch| acc + if ch.is_ascii() { 1 } else { 2 }) +} + +fn status_bar_height(app_state: &AppState, width: u16) -> u16 { + let _ = width; + if matches!(app_state.input_mode, InputMode::Help) { + 0 } else { - [Constraint::Percentage(50), Constraint::Percentage(50)] - }; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(area); + 1 + } +} - // Get the cell reference - let (row, col) = app_state.selected_cell; - let cell_ref = cell_reference(app_state.selected_cell); +fn status_bar_style() -> Style { + Style::default().bg(Color::Black).fg(theme::TEXT) +} - // Handle the top panel based on the input mode - if let InputMode::Preview = app_state.input_mode { - let preview_text = app_state - .query_preview - .as_ref() - .map(format_query_preview) - .unwrap_or_else(|| "No preview available".to_string()); - let preview_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .title(" Query Preview "); - let preview_paragraph = Paragraph::new(preview_text) - .block(preview_block) - .wrap(ratatui::widgets::Wrap { trim: false }); - - f.render_widget(preview_paragraph, chunks[0]); - } else if let InputMode::Editing = app_state.input_mode { - let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state { - match vim_state.mode { - crate::app::VimMode::Normal => ("NORMAL", Color::Green), - crate::app::VimMode::Insert => ("INSERT", Color::LightBlue), - crate::app::VimMode::Visual => ("VISUAL", Color::Yellow), - crate::app::VimMode::Operator(op) => { - let op_str = match op { - 'y' => "YANK", - 'd' => "DELETE", - 'c' => "CHANGE", - _ => "OPERATOR", - }; - (op_str, Color::LightRed) - } - } - } else { - ("VIM", Color::White) - }; +fn status_badge(label: &'static str, color: Color) -> Span<'static> { + Span::styled( + format!(" {label} "), + Style::default() + .bg(color) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) +} - let title = Line::from(vec![ - Span::raw(" Editing Cell "), - Span::raw(cell_ref.clone()), - Span::raw(" - "), - Span::styled( - vim_mode_str, - Style::default().fg(mode_color).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - ]); - - let edit_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .title(title); - - // Calculate inner area with padding - let inner_area = edit_block.inner(chunks[0]); - let padded_area = Rect { - x: inner_area.x + 1, // Add 1 character padding on the left - y: inner_area.y, - width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding - height: inner_area.height, - }; +fn subtle_span(text: impl Into) -> Span<'static> { + Span::styled(text.into(), Style::default().fg(theme::TEXT_SECONDARY)) +} - f.render_widget(edit_block, chunks[0]); - f.render_widget(app_state.text_area.widget(), padded_area); - } else if let InputMode::Findings = app_state.input_mode { - let title = format!(" Findings ({}) ", app_state.findings.items.len()); - let findings_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .title(title); - let findings_paragraph = Paragraph::new(format_findings_lines(app_state)) - .block(findings_block) - .wrap(ratatui::widgets::Wrap { trim: false }); - - f.render_widget(findings_paragraph, chunks[0]); - } else { - // Get cell content - let content = app_state.get_cell_content(row, col); +fn shortcut_key(key: &str) -> Span<'static> { + Span::styled( + format!("[{key}]"), + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) +} - let title = format!(" Cell {cell_ref} Content "); - let cell_block = Block::default().borders(Borders::ALL).title(title); +fn shortcut_spans(entries: &[(&str, &str)]) -> Vec> { + let mut spans = Vec::new(); - // Create paragraph with cell content - let cell_paragraph = Paragraph::new(content) - .block(cell_block) - .wrap(ratatui::widgets::Wrap { trim: false }); + for (index, (key, label)) in entries.iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + } + spans.push(shortcut_key(key)); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + (*label).to_string(), + Style::default().fg(theme::TEXT), + )); + } + + spans +} - f.render_widget(cell_paragraph, chunks[0]); +fn render_single_status_line<'a>( + f: &mut Frame, + area: Rect, + line: Line<'a>, + alignment: ratatui::layout::Alignment, +) { + let status_widget = Paragraph::new(line) + .style(status_bar_style()) + .alignment(alignment); + f.render_widget(status_widget, area); +} + +fn line_display_width(line: &Line<'_>) -> u16 { + line.spans + .iter() + .map(|span| display_width(&span.content)) + .sum() +} + +fn render_status_sections<'a, 'b>( + f: &mut Frame, + area: Rect, + left: Line<'a>, + right: Option>, +) { + let Some(right_line) = right else { + render_single_status_line(f, area, left, ratatui::layout::Alignment::Left); + return; + }; + + let right_width = line_display_width(&right_line).saturating_add(1); + if right_width >= area.width { + render_single_status_line(f, area, right_line, ratatui::layout::Alignment::Right); + return; } - // Create notification block - let notification_block = if matches!(app_state.input_mode, InputMode::Editing) { - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) - .title(Span::styled( - " Notifications ", - Style::default().fg(Color::DarkGray), - )) + let sections = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(area.width.saturating_sub(right_width)), + Constraint::Length(right_width), + ]) + .split(area); + + render_single_status_line(f, sections[0], left, ratatui::layout::Alignment::Left); + render_single_status_line( + f, + sections[1], + right_line, + ratatui::layout::Alignment::Right, + ); +} + +fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { + if area.height < 4 { + if matches!(app_state.input_mode, InputMode::Editing) { + draw_editing_panel(f, app_state, area); + } else { + draw_cell_details(f, app_state, area); + } + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + if matches!(app_state.input_mode, InputMode::Editing) { + draw_editing_panel(f, app_state, chunks[0]); } else { - Block::default() - .borders(Borders::ALL) - .title(" Notifications ") + draw_cell_details(f, app_state, chunks[0]); + } + draw_notifications(f, app_state, chunks[1]); +} + +fn draw_cell_details(f: &mut Frame, app_state: &AppState, area: Rect) { + let content = app_state.get_cell_content(app_state.selected_cell.0, app_state.selected_cell.1); + let cell_ref = cell_reference(app_state.selected_cell); + let value_type = cell_value_type(&content); + let length = content.chars().count(); + + let title = format!(" Cell {cell_ref} {value_type} Len {length} "); + let block = panel_block(title, theme::TEXT); + let paragraph = Paragraph::new(content) + .block(block) + .style(theme::surface()) + .wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(paragraph, area); +} + +fn draw_editing_panel(f: &mut Frame, app_state: &AppState, area: Rect) { + let cell_ref = cell_reference(app_state.selected_cell); + let mode = app_state.vim_state.as_ref().map(|state| state.mode); + let input_block = panel_block_line(editing_title_line(cell_ref, mode), theme::ACCENT); + let inner_area = input_block.inner(area); + let padded_area = Rect { + x: inner_area.x.saturating_add(1), + y: inner_area.y, + width: inner_area.width.saturating_sub(2), + height: inner_area.height, }; - // Calculate how many notifications can be shown - let notification_height = notification_block.inner(chunks[1]).height as usize; + f.render_widget(input_block, area); + f.render_widget(app_state.text_area.widget(), padded_area); +} - // Prepare notifications text - let notifications_text = if app_state.notification_messages.is_empty() { - String::new() - } else if app_state.notification_messages.len() <= notification_height { - app_state.notification_messages.join("\n") +fn draw_notifications(f: &mut Frame, app_state: &AppState, area: Rect) { + let lines = if app_state.notification_messages.is_empty() { + vec![Line::from(Span::styled( + "No notifications", + Style::default().fg(theme::TEXT_SECONDARY), + ))] } else { - // Show only the most recent notifications that fit - let start_idx = app_state.notification_messages.len() - notification_height; - app_state.notification_messages[start_idx..].join("\n") + app_state + .notification_messages + .iter() + .rev() + .take(4) + .enumerate() + .map(|(index, message)| { + let color = if index == 0 { + theme::TEXT + } else { + theme::TEXT_SECONDARY + }; + Line::from(Span::styled(message.clone(), Style::default().fg(color))) + }) + .collect() }; - let notification_paragraph = Paragraph::new(notifications_text) - .block(notification_block) - .wrap(ratatui::widgets::Wrap { trim: false }) - .style(if matches!(app_state.input_mode, InputMode::Editing) { - Style::default().fg(Color::DarkGray) - } else { + let paragraph = Paragraph::new(lines) + .block(panel_block(" NOTIFICATIONS ".to_string(), theme::TEXT)) + .style(theme::surface()) + .wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(paragraph, area); +} + +fn panel_block(title: String, border_color: Color) -> Block<'static> { + panel_block_line( + Line::from(Span::styled( + title, Style::default() - }); + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )), + border_color, + ) +} - f.render_widget(notification_paragraph, chunks[1]); +fn panel_block_line(title: Line<'static>, border_color: Color) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(border_color)) + .style(theme::surface()) } -fn format_query_preview(preview: &crate::app::QueryPreview) -> String { - let mut lines = vec![ - format!("File: {}", preview.file_path), - format!( - "Sheet: {} ({}) | Selected: {} | Range: {}", - preview.sheet_name, preview.sheet_index, preview.selected_cell, preview.used_range +fn editing_title_line(cell_ref: String, mode: Option) -> Line<'static> { + let mut spans = vec![ + Span::styled( + " Editing Cell ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + cell_ref, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), ), - format!("Select: {} | Filters: {}", preview.selects, preview.filters), ]; - if preview.rows.is_empty() { - lines.push("Sample: no rows".to_string()); - return lines.join("\n"); - } - - lines.push(format!("Sample: row | {}", preview.columns.join(" | "))); - for row in &preview.rows { - lines.push(format!("{} | {}", row.row_number, row.values.join(" | "))); + if let Some(mode) = mode { + spans.push(Span::styled( + " - ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + mode.to_string(), + Style::default() + .fg(vim_mode_color(mode)) + .add_modifier(Modifier::BOLD), + )); } - if preview.truncated { - lines.push("Sample truncated".to_string()); - } + spans.push(Span::styled( + " ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )); - lines.join("\n") + Line::from(spans) } -fn format_findings_lines(app_state: &AppState) -> Vec> { - if let Some(error) = &app_state.findings.last_refresh_error { - return vec![Line::from(format!("Refresh failed: {error}"))]; +fn vim_mode_color(mode: VimMode) -> Color { + match mode { + VimMode::Normal => theme::SUCCESS, + VimMode::Insert => theme::ACCENT, + VimMode::Visual => theme::SEARCH, + VimMode::Operator(_) => theme::WARNING, } +} - if app_state.findings.items.is_empty() { - return vec![Line::from("No findings. Press r to refresh.")]; +fn cell_value_type(content: &str) -> &'static str { + if content.is_empty() { + "Blank" + } else if content.starts_with("Formula: ") { + "Formula" + } else if content.parse::().is_ok() { + "Number" + } else { + "String" } - - app_state - .findings - .items - .iter() - .enumerate() - .map(|(index, finding)| { - let location = finding - .range - .clone() - .or_else(|| match (finding.row, finding.column) { - (Some(row), Some(col)) => Some(cell_reference((row, col))), - (Some(row), None) => Some(format!("row {row}")), - (None, Some(col)) => Some(index_to_col_name(col)), - (None, None) => None, - }) - .unwrap_or_else(|| "sheet".to_string()); - - let marker = if index == app_state.findings.selected { - ">" - } else { - " " - }; - let summary = format!( - "{marker} {} {} {} {}", - finding.severity.as_str(), - finding.rule_id.as_str(), - finding.sheet, - location - ); - let style = if index == app_state.findings.selected { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - }; - - Line::from(vec![ - Span::styled(summary, style), - Span::raw(format!(" {}", finding.message)), - ]) - }) - .collect() } fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { match app_state.input_mode { InputMode::Normal => { - let status = "Input :help for operating instructions | hjkl=move f=findings [ ]=prev/next-sheet Enter=edit y=copy d=cut p=paste /=search N/n=prev/next-search-result :=command "; - - let status_widget = Paragraph::new(status) - .style(Style::default()) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); + let left = Line::from(vec![status_badge("NORMAL", theme::ACCENT)]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Edit"), + (":", "Command"), + ("/", "Search"), + (":w", "Save"), + ])); + render_status_sections(f, area, left, Some(right)); } InputMode::Editing => { - let status_widget = Paragraph::new("Press Esc to exit editing mode") - .style(Style::default().fg(Color::DarkGray)) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); + let left = Line::from(vec![status_badge("EDIT", theme::SUCCESS)]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Save"), + ("Esc", "Normal"), + ("i", "Insert"), + ("v", "Visual"), + ])); + render_status_sections(f, area, left, Some(right)); } InputMode::Command | InputMode::CommandInLazyLoading => { - // Create a styled text with different colors for command and parameters - let mut spans = vec![Span::styled(":", Style::default())]; - let command_spans = parse_command(&app_state.input_buffer); - spans.extend(command_spans); - - let text = Line::from(spans); - let status_widget = Paragraph::new(text) - .style(Style::default()) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); + let mut left_spans = vec![ + status_badge("COMMAND", theme::WARNING), + Span::raw(" "), + Span::styled(":", Style::default().fg(theme::TEXT)), + ]; + left_spans.extend(parse_command(&app_state.input_buffer)); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Run"), + ("Esc", "Cancel"), + ("A1", "Jump"), + ])); + render_status_sections(f, area, Line::from(left_spans), Some(right)); } InputMode::SearchForward | InputMode::SearchBackward => { - // Get search prefix based on mode let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) { "/" } else { "?" }; - - // Split the area for search prefix and search input - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(1), // Search prefix - Constraint::Min(1), // Search input - ]) - .split(area); - - // Render search prefix - let prefix_widget = Paragraph::new(prefix) - .style(Style::default()) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(prefix_widget, chunks[0]); - - // Render search input with cursor visible - let mut text_area = app_state.text_area.clone(); - text_area.set_cursor_line_style(Style::default()); - text_area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); - - f.render_widget(text_area.widget(), chunks[1]); + let query = app_state.text_area.lines().join("\n"); + let left_spans = vec![ + status_badge("SEARCH", theme::SEARCH), + Span::raw(" "), + Span::styled(prefix.to_string(), Style::default().fg(theme::TEXT)), + Span::styled(query, Style::default().fg(theme::TEXT)), + ]; + let right = Line::from(shortcut_spans(&[ + ("Enter", "Apply"), + ("Esc", "Cancel"), + ("n/N", "Navigate"), + ])); + render_status_sections(f, area, Line::from(left_spans), Some(right)); } InputMode::Help => { // No status bar in help mode } - InputMode::Preview => { - let status_widget = Paragraph::new("Query preview: Esc/Enter/q closes") - .style(Style::default().fg(Color::LightCyan)) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); - } - - InputMode::Findings => { - let status_widget = - Paragraph::new("Findings: j/k or arrows=move Enter=jump r/f=refresh Esc/q=close") - .style(Style::default().fg(Color::LightCyan)) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); - } - InputMode::LazyLoading => { - // Show a status message for lazy loading mode - let status_widget = Paragraph::new( - "Sheet data not loaded... Press Enter to load, [ and ] to switch sheets, :addsheet to add a sheet, :delsheet to delete current sheet, :q to quit, :q! to quit without saving", - ) - .style(Style::default().fg(Color::LightYellow)) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); + let left = Line::from(vec![ + status_badge("LAZY", theme::WARNING), + Span::raw(" "), + subtle_span("State "), + Span::styled("not loaded", Style::default().fg(theme::WARNING)), + ]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Load"), + ("[ ]", "Switch"), + (":", "Command"), + ])); + render_status_sections(f, area, left, Some(right)); } } } +fn sheet_rows_cols(app_state: &AppState) -> String { + let sheet = app_state.workbook.get_current_sheet(); + format!("{} x {}", sheet.max_rows, sheet.max_cols) +} + fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) { // Create a semi-transparent overlay let overlay = Block::default() - .style(Style::default().bg(Color::Black).fg(Color::White)) + .style(theme::surface()) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)); + .border_style(Style::default().fg(theme::ACCENT)); f.render_widget(Clear, area); f.render_widget(overlay, area); // Calculate center position for the message - let message = "Press Enter to load the sheet, [ and ] to switch sheets"; + let message = "Sheet not loaded Enter load [ ] switch sheet : command"; let width = message.len() as u16; let x = area.x + (area.width.saturating_sub(width)) / 2; let y = area.y + area.height / 2; @@ -732,7 +812,7 @@ fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) { let message_widget = Paragraph::new(message).style( Style::default() - .fg(Color::LightYellow) + .fg(theme::WARNING) .add_modifier(Modifier::BOLD), ); @@ -741,72 +821,357 @@ fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) { } fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) { - // Clear the background + let popup_area = help_popup_area(area); + let block = Block::default() + .title(" COMMAND HELP ") + .title_alignment(Alignment::Center) + .title_style( + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::TEXT_SECONDARY)) + .style(theme::surface()); + let inner = block.inner(popup_area); + f.render_widget(Clear, area); + f.render_widget(Block::default().style(theme::base()), area); + f.render_widget(Clear, popup_area); + f.render_widget(block, popup_area); - // Calculate popup dimensions - let line_count = app_state.help_text.lines().count() as u16; - let content_height = line_count + 2; // +2 for borders + let Some((content_area, divider_area, footer_area)) = help_popup_inner_areas(inner) else { + return; + }; - let max_line_width = app_state - .help_text - .lines() - .map(|line| line.len() as u16) - .max() - .unwrap_or(40); + let lines = help_overlay_lines(content_area.width); + let visible_lines = content_area.height.max(1) as usize; + app_state.help_visible_lines = visible_lines; + app_state.help_total_lines = lines.len(); + let max_scroll = lines.len().saturating_sub(visible_lines); + app_state.help_scroll = app_state.help_scroll.min(max_scroll); - let content_width = max_line_width + 4; // +4 for borders and padding + let help_paragraph = Paragraph::new(lines) + .style(theme::surface()) + .scroll((app_state.help_scroll as u16, 0)); + f.render_widget(help_paragraph, content_area); + + let divider = Paragraph::new("-".repeat(inner.width as usize)).style(theme::surface()); + f.render_widget(divider, divider_area); + render_help_footer( + f, + footer_area, + app_state.help_scroll, + visible_lines, + max_scroll, + ); +} - // Ensure popup fits within screen - let popup_width = content_width.min(area.width.saturating_sub(4)); - let popup_height = content_height.min(area.height.saturating_sub(4)); +fn help_popup_area(area: Rect) -> Rect { + let popup_width = area.width.saturating_sub(4).min(112).max(48); + let popup_height = area.height.saturating_sub(2).min(32).max(12); + let popup_x = area.x + area.width.saturating_sub(popup_width) / 2; + let popup_y = area.y + area.height.saturating_sub(popup_height) / 2; - // Center the popup on screen - let popup_x = (area.width.saturating_sub(popup_width)) / 2; - let popup_y = (area.height.saturating_sub(popup_height)) / 2; + Rect::new(popup_x, popup_y, popup_width, popup_height) +} - let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); +fn help_popup_inner_areas(inner: Rect) -> Option<(Rect, Rect, Rect)> { + if inner.height < 4 || inner.width < 24 { + return None; + } - // Calculate scrolling parameters - let visible_lines = popup_height.saturating_sub(2) as usize; // Subtract 2 for top and bottom borders - app_state.help_visible_lines = visible_lines; + let footer_height = 2; + let content_area = Rect { + x: inner.x.saturating_add(1), + y: inner.y, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(footer_height), + }; + let divider_area = Rect::new( + inner.x, + content_area.y + content_area.height, + inner.width, + 1, + ); + let footer_area = Rect::new(inner.x, divider_area.y + 1, inner.width, 1); + + Some((content_area, divider_area, footer_area)) +} - let line_count = app_state.help_text.lines().count(); - let max_scroll = line_count.saturating_sub(visible_lines); +fn render_help_footer( + f: &mut Frame, + area: Rect, + scroll: usize, + visible_lines: usize, + max_scroll: usize, +) { + let footer = help_footer_line(scroll, visible_lines, max_scroll); + let footer_widget = Paragraph::new(footer) + .style(theme::surface()) + .alignment(Alignment::Center); + f.render_widget(footer_widget, area); +} - app_state.help_scroll = app_state.help_scroll.min(max_scroll); +fn help_overlay_lines(width: u16) -> Vec> { + if width >= 82 { + two_column_help_lines(width) + } else { + one_column_help_lines(width) + } +} - let mut title = " [ESC/Enter to close] ".to_string(); +fn two_column_help_lines(width: u16) -> Vec> { + let gap = 4; + let column_width = width.saturating_sub(gap) / 2; + let left = help_column_lines(LEFT_HELP_SECTIONS, column_width); + let right = help_column_lines(RIGHT_HELP_SECTIONS, column_width); + let row_count = left.len().max(right.len()); + let mut rows = Vec::with_capacity(row_count); + + for index in 0..row_count { + let mut line = left.get(index).cloned().unwrap_or_else(Line::default); + pad_line(&mut line, column_width); + line.spans.push(Span::raw(" ".repeat(gap as usize))); + if let Some(right_line) = right.get(index) { + line.spans.extend(right_line.spans.clone()); + } + rows.push(line); + } + + rows +} + +fn one_column_help_lines(width: u16) -> Vec> { + let mut lines = help_column_lines(LEFT_HELP_SECTIONS, width); + lines.push(Line::default()); + lines.extend(help_column_lines(RIGHT_HELP_SECTIONS, width)); + lines +} + +fn help_column_lines(sections: &[HelpSection], width: u16) -> Vec> { + let mut lines = Vec::new(); + + for (index, section) in sections.iter().enumerate() { + if index > 0 { + lines.push(Line::default()); + } + lines.push(section_title_line(section.title)); + for entry in section.entries { + lines.extend(help_entry_lines(entry, width)); + } + } - if max_scroll > 0 { - let scroll_indicator = if app_state.help_scroll == 0 { - " [↓ or j to scroll] " - } else if app_state.help_scroll >= max_scroll { - " [↑ or k to scroll] " + lines +} + +fn section_title_line(title: &'static str) -> Line<'static> { + Line::from(Span::styled( + title, + Style::default() + .fg(theme::WARNING) + .add_modifier(Modifier::BOLD), + )) +} + +fn help_entry_lines(entry: &HelpEntry, width: u16) -> Vec> { + let prefix = help_entry_prefix(entry.keys); + let prefix_width = spans_display_width(&prefix); + let description_width = width.saturating_sub(prefix_width + HELP_ENTRY_GAP).max(1); + let mut chunks = wrap_text(entry.description, description_width); + + if chunks.is_empty() { + return vec![Line::from(prefix)]; + } + + let first = chunks.remove(0); + let mut lines = vec![line_with_right_aligned_description(prefix, first, width)]; + for chunk in chunks { + lines.push(right_aligned_description_line(chunk, width)); + } + + lines +} + +fn help_entry_prefix(keys: &str) -> Vec> { + let mut spans = vec![Span::raw(" ".repeat(HELP_ENTRY_INDENT as usize))]; + + for (index, chip) in key_chips(keys).into_iter().enumerate() { + if index > 0 { + spans.push(Span::styled("/", Style::default().fg(theme::TEXT_DISABLED))); + } + spans.extend(key_chip_spans(chip)); + } + + spans +} + +fn key_chip_spans(label: String) -> Vec> { + vec![Span::styled( + format!(" {label} "), + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + )] +} + +fn key_chips(keys: &str) -> Vec { + keys.split(" / ") + .flat_map(|group| { + let group = group.trim(); + if should_split_shortcut_group(group) { + group.split_whitespace().map(str::to_string).collect() + } else { + vec![group.to_string()] + } + }) + .collect() +} + +fn spans_display_width(spans: &[Span<'_>]) -> u16 { + spans.iter().map(|span| display_width(&span.content)).sum() +} + +fn line_with_right_aligned_description( + mut spans: Vec>, + description: String, + width: u16, +) -> Line<'static> { + let prefix_width = spans_display_width(&spans); + let description_width = display_width(&description); + let gap = width.saturating_sub(prefix_width + description_width); + + spans.push(Span::raw(" ".repeat(gap as usize))); + spans.push(description_span(description)); + + Line::from(spans) +} + +fn right_aligned_description_line(description: String, width: u16) -> Line<'static> { + let description_width = display_width(&description); + let gap = width.saturating_sub(description_width); + + Line::from(vec![ + Span::raw(" ".repeat(gap as usize)), + description_span(description), + ]) +} + +fn description_span(description: String) -> Span<'static> { + Span::styled(description, Style::default().fg(theme::TEXT_SECONDARY)) +} + +fn wrap_text(text: &str, width: u16) -> Vec { + if width == 0 { + return Vec::new(); + } + + let mut lines = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() { + append_wrapped_word(&mut lines, &mut current, word, width); + } + + if !current.is_empty() { + lines.push(current); + } + + lines +} + +fn append_wrapped_word(lines: &mut Vec, current: &mut String, word: &str, width: u16) { + let word_width = display_width(word); + let current_width = display_width(current); + + if current.is_empty() && word_width <= width { + current.push_str(word); + } else if !current.is_empty() && current_width + 1 + word_width <= width { + current.push(' '); + current.push_str(word); + } else { + if !current.is_empty() { + lines.push(std::mem::take(current)); + } + append_word_chunks(lines, current, word, width); + } +} + +fn append_word_chunks(lines: &mut Vec, current: &mut String, word: &str, width: u16) { + if display_width(word) <= width { + current.push_str(word); + return; + } + + for chunk in split_word_to_width(word, width) { + if current.is_empty() { + current.push_str(&chunk); } else { - " [↑↓ or j/k to scroll] " - }; - title.push_str(scroll_indicator); + lines.push(std::mem::take(current)); + current.push_str(&chunk); + } } +} - let help_block = Block::default() - .title(title) - .title_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .style(Style::default().bg(Color::Blue).fg(Color::White)); +fn split_word_to_width(word: &str, width: u16) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut used = 0; - // Create paragraph with help text - let help_paragraph = Paragraph::new(app_state.help_text.clone()) - .block(help_block) - .wrap(ratatui::widgets::Wrap { trim: false }) - .scroll((app_state.help_scroll as u16, 0)); + for ch in word.chars() { + let char_width = if ch.is_ascii() { 1 } else { 2 }; + if used + char_width > width && !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + used = 0; + } + current.push(ch); + used += char_width; + } - f.render_widget(help_paragraph, popup_area); + if !current.is_empty() { + chunks.push(current); + } + + chunks +} + +fn should_split_shortcut_group(group: &str) -> bool { + let parts: Vec<&str> = group.split_whitespace().collect(); + + parts.len() > 1 + && parts.iter().all(|part| { + part.chars().count() == 1 && part.chars().all(|ch| ch.is_ascii_alphabetic()) + }) +} + +fn pad_line(line: &mut Line<'static>, width: u16) { + let line_width = line_display_width(line); + if line_width < width { + line.spans + .push(Span::raw(" ".repeat((width - line_width) as usize))); + } +} + +fn help_footer_line(scroll: usize, visible_lines: usize, max_scroll: usize) -> Line<'static> { + let total_pages = if max_scroll == 0 { + 1 + } else { + (max_scroll + visible_lines) / visible_lines + }; + let current_page = (scroll / visible_lines).saturating_add(1).min(total_pages); + + Line::from(vec![ + Span::styled("Press ESC or q to close", Style::default().fg(theme::TEXT)), + Span::styled( + " | j/k scroll | ", + Style::default().fg(theme::TEXT_SECONDARY), + ), + Span::styled( + format!("Page {current_page}/{total_pages}"), + Style::default().fg(theme::ACCENT), + ), + ]) } fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { @@ -820,36 +1185,72 @@ fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { .and_then(|n| n.to_str()) .unwrap_or("Untitled"); + let brand_content = " EXCEL-CLI "; let title_content = format!(" {file_name} "); - let title_width = title_content - .chars() - .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }) as u16; - - let available_width = area.width.saturating_sub(title_width) as usize; + let brand_width = display_width(brand_content); + let title_width = display_width(&title_content); + let max_title_width = (area.width / 3).min(title_width); let mut tab_widths = Vec::new(); let mut total_width = 0; let mut visible_tabs = Vec::new(); + let horizontal_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(brand_width), + Constraint::Length(max_title_width), + Constraint::Min(0), + ]) + .split(area); + + let title_style = if is_editing { + Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) + } else { + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY) + }; + let brand_style = Style::default() + .bg(Color::Black) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD); + + let brand_widget = Paragraph::new(brand_content).style(brand_style); + let title_widget = Paragraph::new(title_content).style(title_style); + + f.render_widget(brand_widget, horizontal_layout[0]); + f.render_widget(title_widget, horizontal_layout[1]); + + let tabs_area = horizontal_layout[2]; + let rows_cols = sheet_rows_cols(app_state); + let rows_cols_plain = format!("Rows/Cols: {rows_cols}"); + let base_rows_width = display_width(&rows_cols_plain); + let total_tab_width: u16 = sheet_names.iter().map(|name| display_width(name)).sum(); + let visible_tabs_width = tabs_area.width.saturating_sub(base_rows_width); + let tabs_overflow = total_tab_width > visible_tabs_width; + let rows_cols_plain = if tabs_overflow { + format!("... {rows_cols_plain}") + } else { + rows_cols_plain + }; + let rows_cols_width = display_width(&rows_cols_plain); + let available_width = tabs_area.width as usize; + for (i, name) in sheet_names.iter().enumerate() { - let tab_width = name.len(); + let tab_width = display_width(name) as usize; if total_width + tab_width <= available_width { tab_widths.push(tab_width as u16); total_width += tab_width; visible_tabs.push(i); } else { - // If current tab isn't visible, make room for it if !visible_tabs.contains(¤t_index) { - // Remove tabs from the beginning until there's enough space while !visible_tabs.is_empty() && total_width + tab_width > available_width { let removed_width = tab_widths.remove(0) as usize; visible_tabs.remove(0); total_width -= removed_width; } - // Add current tab if there's now enough space if total_width + tab_width <= available_width { tab_widths.push(tab_width as u16); visible_tabs.push(current_index); @@ -859,25 +1260,6 @@ fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { } } - // Limit title width to at most 2/3 of the area - let max_title_width = (area.width * 2 / 3).min(title_width); - - // Create a two-column layout: title column and tab column - let horizontal_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(max_title_width), Constraint::Min(0)]) - .split(area); - - let title_style = if is_editing { - Style::default().bg(Color::DarkGray).fg(Color::Gray) - } else { - Style::default().bg(Color::DarkGray).fg(Color::White) - }; - - let title_widget = Paragraph::new(title_content).style(title_style); - - f.render_widget(title_widget, horizontal_layout[0]); - // Create constraints for tab layout let mut tab_constraints = Vec::new(); for &width in &tab_widths { @@ -888,7 +1270,7 @@ fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { let tab_layout = Layout::default() .direction(Direction::Horizontal) .constraints(tab_constraints) - .split(horizontal_layout[1]); + .split(tabs_area); // Render each visible tab for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() { @@ -901,14 +1283,17 @@ fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { let style = if is_editing { if is_current { - Style::default().bg(Color::DarkGray).fg(Color::Gray) + Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) } else { - Style::default().fg(Color::DarkGray) + Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) } } else if is_current { - Style::default().bg(Color::DarkGray).fg(Color::White) - } else { Style::default() + .bg(Color::Black) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY) }; let tab_widget = Paragraph::new(name.to_string()) @@ -918,35 +1303,47 @@ fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { f.render_widget(tab_widget, tab_layout[layout_idx]); } - // Show indicator if not all tabs are visible - if visible_tabs.len() < sheet_names.len() { - let more_indicator = "..."; - let indicator_style = Style::default().bg(Color::DarkGray).fg(Color::White); - let indicator_width = more_indicator.len() as u16; - - // Position indicator at the right edge - let indicator_rect = Rect { - x: area.x + area.width - indicator_width, - y: area.y, - width: indicator_width, - height: 1, - }; - - let indicator_widget = Paragraph::new(more_indicator).style(indicator_style); - f.render_widget(indicator_widget, indicator_rect); + let rows_cols_rect = Rect { + x: tabs_area.x + + tabs_area + .width + .saturating_sub(rows_cols_width.min(tabs_area.width)), + y: tabs_area.y, + width: rows_cols_width.min(tabs_area.width), + height: 1, + }; + let mut rows_cols_spans = Vec::new(); + if tabs_overflow { + rows_cols_spans.push(Span::styled( + "... ", + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY), + )); } + rows_cols_spans.push(Span::styled( + "Rows/Cols: ", + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY), + )); + rows_cols_spans.push(Span::styled( + rows_cols, + Style::default().bg(Color::Black).fg(theme::ACCENT), + )); + + let rows_cols_widget = Paragraph::new(Line::from(rows_cols_spans)) + .style(Style::default().bg(Color::Black)) + .alignment(ratatui::layout::Alignment::Right); + f.render_widget(rows_cols_widget, rows_cols_rect); } #[cfg(test)] mod tests { - use ratatui::{backend::TestBackend, Terminal}; + use ratatui::{backend::TestBackend, style::Color, Terminal}; use std::path::PathBuf; - use super::ui; - use crate::app::{AppState, InputMode}; + use super::{theme, ui}; + use crate::app::{AppState, HelpEntry, InputMode}; use crate::excel::{Cell, Sheet, Workbook}; - fn app_with_preview() -> AppState<'static> { + fn app_with_sheet() -> AppState<'static> { let mut data = vec![vec![Cell::empty(); 3]; 3]; data[1][1] = Cell::new("Name".to_string(), false); data[1][2] = Cell::new("Name".to_string(), false); @@ -960,20 +1357,425 @@ mod tests { max_cols: 2, is_loaded: true, }; - let mut app = AppState::new( + let app = AppState::new( Workbook::from_sheets_for_test(vec![sheet]), PathBuf::from("scores.xlsx"), ) .unwrap(); - app.show_query_preview(); app } + fn app_with_many_sheets() -> AppState<'static> { + let make_sheet = |name: &str| Sheet { + name: name.to_string(), + data: vec![vec![Cell::empty(); 2]; 2], + max_rows: 1, + max_cols: 1, + is_loaded: true, + }; + + AppState::new( + Workbook::from_sheets_for_test(vec![ + make_sheet("Alpha"), + make_sheet("Beta"), + make_sheet("Gamma"), + make_sheet("Delta"), + make_sheet("Epsilon"), + make_sheet("Zeta"), + ]), + PathBuf::from("many.xlsx"), + ) + .unwrap() + } + + fn rendered_lines(terminal: &Terminal) -> Vec { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + + buffer + .content + .chunks(width) + .map(|row| row.iter().map(|cell| cell.symbol.as_str()).collect()) + .collect() + } + + fn text_fg_at(terminal: &Terminal, needle: &str) -> Color { + let lines = rendered_lines(terminal); + let row = line_index(&lines, needle); + let col = lines[row] + .find(needle) + .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")); + let offset = needle + .chars() + .position(|ch| !ch.is_whitespace()) + .unwrap_or(0); + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col + offset].fg + } + + fn fg_before_needle(terminal: &Terminal, needle: &str) -> Color { + let lines = rendered_lines(terminal); + let row = line_index(&lines, needle); + let col = lines[row] + .find(needle) + .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")); + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col.saturating_sub(1)].fg + } + + fn fg_at(terminal: &Terminal, row: usize, col: usize) -> Color { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].fg + } + + fn bg_at(terminal: &Terminal, row: usize, col: usize) -> Color { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].bg + } + + fn symbol_at(terminal: &Terminal, row: usize, col: usize) -> String { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].symbol.clone() + } + + fn line_index(lines: &[String], needle: &str) -> usize { + lines + .iter() + .position(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")) + } + + fn help_overlay_text(width: u16) -> String { + super::help_overlay_lines(width) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } + #[test] - fn renders_query_preview_pane_with_target_and_sample() { - let backend = TestBackend::new(100, 30); + fn renders_help_overlay_as_structured_command_reference() { + let backend = TestBackend::new(140, 40); let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_preview(); + let mut app = app_with_sheet(); + app.show_help(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = rendered_lines(&terminal).join("\n"); + + assert!(matches!(app.input_mode, InputMode::Help)); + assert!(rendered.contains("COMMAND HELP")); + assert!(rendered.contains("NAVIGATION")); + assert!(rendered.contains("ACTIONS")); + assert!(rendered.contains("SEARCH")); + assert!(rendered.contains("FILE & APP")); + assert!(rendered.contains("JUMP & SHEETS")); + assert!(rendered.contains("Press ESC or q to close")); + assert!(rendered.contains("Page ")); + assert!(!rendered.contains("preview")); + assert!(!rendered.contains("findings")); + } + + #[test] + fn help_overlay_uses_solid_backdrop_to_hide_underlying_sheet() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.show_help(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + assert_eq!(symbol_at(&terminal, 0, 0), " "); + assert_eq!(bg_at(&terminal, 0, 0), theme::BACKGROUND); + } + + #[test] + fn help_entries_render_grouped_shortcuts_as_individual_chips() { + let entry = HelpEntry { + keys: "h j k l / arrows", + description: "Move cell", + }; + + let line_text = super::help_entry_lines(&entry, 60)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(line_text.contains(" h ")); + assert!(line_text.contains(" j ")); + assert!(line_text.contains(" k ")); + assert!(line_text.contains(" l ")); + assert!(line_text.contains(" arrows ")); + assert!(line_text.contains(" h / j / k / l / arrows ")); + assert!(!line_text.contains(" / ")); + assert!(!line_text.contains("")); + assert!(!line_text.contains("")); + assert!(!line_text.contains("‹")); + assert!(!line_text.contains("›")); + assert!(!line_text.contains(" h j k l ")); + } + + #[test] + fn help_entry_separator_slashes_are_dimmed() { + let spans = super::help_entry_prefix("h / j"); + + assert!(spans.iter().any( + |span| span.content.as_ref() == "/" && span.style.fg == Some(theme::TEXT_DISABLED) + )); + } + + #[test] + fn help_entry_chips_use_compact_square_background_without_caps() { + let spans = super::help_entry_prefix("h"); + let text = spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains(" h ")); + assert!(!text.contains("")); + assert!(!text.contains("")); + assert!(spans + .iter() + .any(|span| span.content.as_ref() == " h " + && span.style.bg == Some(theme::SURFACE_MUTED))); + } + + #[test] + fn help_entry_descriptions_align_to_the_right_edge() { + let short_entry = HelpEntry { + keys: "h", + description: "Move cell", + }; + let long_entry = HelpEntry { + keys: "Ctrl+arrows", + description: "Jump to next non-empty cell", + }; + + let short_line = super::help_entry_lines(&short_entry, 42)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let long_line = super::help_entry_lines(&long_entry, 42)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(super::display_width(&short_line), 42); + assert_eq!(super::display_width(&long_line), 42); + assert!(short_line.ends_with("Move cell")); + assert!(long_line.ends_with("Jump to next non-empty")); + } + + #[test] + fn help_entry_keeps_description_on_first_line_for_long_shortcut_groups() { + let entry = HelpEntry { + keys: ":noh / :nohlsearch", + description: "Disable search highlighting", + }; + + let rendered = super::help_entry_lines(&entry, 44) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + + assert!(rendered[0].contains(":noh")); + assert!(rendered[0].contains(":nohlsearch")); + assert!(rendered[0].contains("Disable search")); + assert_eq!(super::display_width(&rendered[0]), 44); + assert!(rendered[0].ends_with("Disable search")); + assert_eq!(super::display_width(&rendered[1]), 44); + assert!(rendered[1].ends_with("highlighting")); + } + + #[test] + fn help_entry_descriptions_wrap_right_aligned_inside_column_width() { + let entry = HelpEntry { + keys: ":sheet ", + description: "Switch sheet by exact name or one based index", + }; + + let lines = super::help_entry_lines(&entry, 34); + let rendered = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + + let normalized = rendered + .join(" ") + .split_whitespace() + .collect::>() + .join(" "); + + assert!(rendered.len() > 1); + assert!(rendered.iter().all(|line| super::display_width(line) <= 34)); + assert!(normalized.contains("one based index")); + assert!(rendered.iter().all(|line| { + super::display_width(line) == 34 || !line.contains(|ch: char| ch.is_alphabetic()) + })); + } + + #[test] + fn help_overlay_model_lists_complete_command_reference() { + let help_text = help_overlay_text(112); + + for required in [ + ":cw fit all", + ":dr ", + ":dc ", + ":ej ", + ":eja ", + "EDIT MODE", + "HELP CONTROLS", + ] { + assert!( + help_text.contains(required), + "expected help overlay to contain {required}" + ); + } + + assert!(!help_text.contains("preview")); + assert!(!help_text.contains("findings")); + } + + #[test] + fn renders_help_overlay_later_command_sections_when_scrolled() { + let backend = TestBackend::new(120, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.show_help(); + app.help_scroll = 17; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let mid_page = rendered_lines(&terminal).join("\n"); + + assert!(mid_page.contains("ROWS & COLUMNS")); + assert!(mid_page.contains(":cw fit all")); + assert!(mid_page.contains(":dr ")); + assert!(mid_page.contains(":dc ")); + } + + #[test] + fn renders_visual_refresh_shell_with_inspector_and_short_status() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(rendered.contains("EXCEL-CLI")); + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(rendered.contains("NORMAL")); + assert!(rendered.contains("[:w] Save")); + assert!(!rendered.contains("INSPECTOR")); + assert!(!rendered.contains("Run Diagnostics")); + assert!(!rendered.contains("Settings")); + assert!(!rendered.contains("Execute Script")); + assert!(!rendered.contains("Findings")); + assert!(!rendered.contains("Columns")); + assert!(!rendered.contains("Preview")); + } + + #[test] + fn renders_normal_mode_status_bar_as_single_row_on_wide_layout() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let status_row = &lines[lines.len() - 1]; + let title_row = &lines[0]; + + assert!(status_row.contains(" NORMAL ")); + assert!(status_row.contains("[Enter] Edit")); + assert!(status_row.contains("[/] Search")); + assert!(status_row.contains("[:w] Save")); + assert!(status_row.trim_end().ends_with("[:w] Save")); + assert!(!status_row.contains("Rows/Cols")); + assert!(!status_row.contains("Findings")); + assert!(!status_row.contains("Columns")); + assert!(!status_row.contains("Preview")); + assert!(title_row.contains("Rows/Cols: 2 x 2")); + assert!(title_row.trim_end().ends_with("Rows/Cols: 2 x 2")); + } + + #[test] + fn renders_cell_panel_above_notifications_in_vertical_info_layout() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let cell_row = line_index(&lines, "Cell A1"); + let notifications_row = line_index(&lines, " NOTIFICATIONS "); + + assert!(cell_row < notifications_row); + } + + #[test] + fn does_not_render_analysis_tabs_or_inspector_shell() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let full_text = lines.join("\n"); + + assert!(!full_text.contains("INSPECTOR")); + assert!(!full_text.contains("Analysis Panel")); + assert!(!full_text.contains(" Details Preview Findings Columns ")); + assert!(!full_text.contains("Query Preview")); + assert!(!full_text.contains("FINDINGS")); + assert!(!full_text.contains("COLUMNS PROFILE")); + } + + #[test] + fn renders_cell_details_with_dynamic_title_and_compact_fields() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.selected_cell = (2, 2); terminal.draw(|frame| ui(frame, &mut app)).unwrap(); @@ -985,22 +1787,124 @@ mod tests { .map(|cell| cell.symbol.as_str()) .collect::(); - assert!(matches!(app.input_mode, InputMode::Preview)); - assert!(rendered.contains("Query Preview")); - assert!(rendered.contains("Sheet: Data")); - assert!(rendered.contains("Range: A1:B2")); - assert!(rendered.contains("Select: all columns")); - assert!(rendered.contains("Filters: none")); - assert!(rendered.contains("Ada")); + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(rendered.contains("Cell B2 Number Len 2")); + assert!(rendered.contains("10")); + assert!(!rendered.contains("Type: Number")); + assert!(!rendered.contains("Length: 2")); + assert!(!rendered.contains("Content: 10")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(!rendered.contains("SHEET CONTEXT")); + assert!(!rendered.contains("QUALITY")); + assert!(!rendered.contains("No findings for active cell")); + } + + #[test] + fn renders_cell_panel_title_and_border_with_primary_text_color_by_default() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + assert_eq!( + text_fg_at(&terminal, " Cell A1 String Len 4 "), + theme::TEXT + ); + assert_eq!( + fg_before_needle(&terminal, " Cell A1 String Len 4 "), + theme::TEXT + ); + } + + #[test] + fn renders_notifications_panel_when_inspector_moves_below_table() { + let backend = TestBackend::new(90, 28); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.add_notification("Loaded 2 findings".to_string()); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("Loaded 2 findings")); + assert!(rendered.contains("NOTIFICATIONS")); + } + + #[test] + fn renders_notifications_title_and_border_with_primary_text_color() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.add_notification("Loaded 2 findings".to_string()); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + assert_eq!(text_fg_at(&terminal, " NOTIFICATIONS "), theme::TEXT); + assert_eq!(fg_before_needle(&terminal, " NOTIFICATIONS "), theme::TEXT); } #[test] - fn renders_findings_panel_with_selected_entry() { - let backend = TestBackend::new(100, 30); + fn renders_editing_panel_with_vim_mode_in_title_and_without_status_mode() { + let backend = TestBackend::new(140, 40); let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_preview(); - app.close_query_preview(); - app.show_findings(); + let mut app = app_with_sheet(); + app.start_editing(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let full_text = lines.join("\n"); + let status_row = &lines[lines.len() - 1]; + let title_row = &lines[0]; + + assert!(full_text.contains("Editing Cell A1")); + assert!(full_text.contains("NORMAL")); + assert!(!full_text.contains("TARGET CELL")); + assert!(!full_text.contains("INPUT BUFFER [EDITING]")); + assert_eq!( + fg_at(&terminal, line_index(&lines, " Editing Cell A1 "), 0), + theme::ACCENT + ); + assert_eq!(text_fg_at(&terminal, "NORMAL"), theme::SUCCESS); + assert!(status_row.contains(" EDIT ")); + assert!(status_row.contains("[Enter] Save")); + assert!(status_row.trim_end().ends_with("[v] Visual")); + assert!(!status_row.contains("Rows/Cols")); + assert!(!status_row.contains("Mode ")); + assert!(title_row.contains("Rows/Cols: 2 x 2")); + } + + #[test] + fn renders_latest_notification_brighter_than_history() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.add_notification("older notification".to_string()); + app.add_notification("latest notification".to_string()); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + assert_eq!(text_fg_at(&terminal, "latest notification"), theme::TEXT); + assert_eq!( + text_fg_at(&terminal, "older notification"), + theme::TEXT_SECONDARY + ); + } + + #[test] + fn removed_analysis_modes_do_not_appear_in_rendered_ui() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); terminal.draw(|frame| ui(frame, &mut app)).unwrap(); @@ -1012,9 +1916,29 @@ mod tests { .map(|cell| cell.symbol.as_str()) .collect::(); - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(rendered.contains("Findings")); - assert!(rendered.contains("duplicate_headers")); - assert!(rendered.contains("Data")); + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(!rendered.contains("Findings")); + assert!(!rendered.contains("Preview")); + assert!(!rendered.contains("Columns")); + assert!(!rendered.contains("COLUMNS PROFILE")); + assert!(!rendered.contains("SHEET PROFILE")); + } + + #[test] + fn renders_rows_cols_in_top_right_with_overflow_hint_when_tabs_exceed_space() { + let backend = TestBackend::new(60, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_many_sheets(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let title_row = &lines[0]; + + assert!(title_row.contains("Rows/Cols: 1 x 1")); + assert!(title_row.trim_end().ends_with("... Rows/Cols: 1 x 1")); + assert!(title_row.contains("Alpha")); + assert!(!title_row.contains("Zeta")); } } From fd0265f67c331c355c3d9837c559cdb0f78f69a2 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Wed, 22 Apr 2026 18:44:07 +0800 Subject: [PATCH 02/13] chore(tests): remove over-specified visual and stale regression tests Delete 8 tests that lock down implementation details rather than behavior: Visual style tests (too brittle against theme changes): - help_entry_separator_slashes_are_dimmed - help_entry_chips_use_compact_square_background_without_caps - renders_cell_panel_title_and_border_with_primary_text_color_by_default - renders_notifications_title_and_border_with_primary_text_color - renders_latest_notification_brighter_than_history Stale regression tests (features already removed): - f_no_longer_switches_to_analysis_mode - c_no_longer_switches_to_analysis_mode - removed_analysis_commands_are_reported_as_unknown No functional changes; remaining suite still covers help content, panel layout, and structural regressions. --- Cargo.lock | 2 +- src/commands/executor.rs | 18 +--------- src/ui/handlers.rs | 25 -------------- src/ui/render.rs | 74 ---------------------------------------- 4 files changed, 2 insertions(+), 117 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25dcd1e..9be5dd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,7 +437,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "excel-cli" -version = "1.3.0" +version = "1.3.1" dependencies = [ "anyhow", "calamine", diff --git a/src/commands/executor.rs b/src/commands/executor.rs index c425989..ed0d55a 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -432,20 +432,4 @@ mod tests { ) .unwrap() } - - #[test] - fn removed_analysis_commands_are_reported_as_unknown() { - for command in ["preview", "pv", "findings", "issues", "columns", "cols"] { - let mut app = app_with_sheet(); - app.input_buffer = command.to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Normal)); - assert_eq!( - app.notification_messages.last().cloned(), - Some(format!("Unknown command: {command}")) - ); - } - } -} +} \ No newline at end of file diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index 64ca26e..11058d7 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -422,31 +422,6 @@ mod tests { app } - #[test] - fn f_no_longer_switches_to_analysis_mode() { - let mut app = app_with_sheet(); - - handle_key_event( - &mut app, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - - assert!(matches!(app.input_mode, InputMode::Normal)); - assert!(!app.should_quit); - } - - #[test] - fn c_no_longer_switches_to_analysis_mode() { - let mut app = app_with_sheet(); - - handle_key_event( - &mut app, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()), - ); - - assert!(matches!(app.input_mode, InputMode::Normal)); - } - #[test] fn question_mark_starts_backward_search_from_normal_mode() { let mut app = app_with_sheet(); diff --git a/src/ui/render.rs b/src/ui/render.rs index e7b24c7..816ed65 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -1527,32 +1527,6 @@ mod tests { assert!(!line_text.contains(" h j k l ")); } - #[test] - fn help_entry_separator_slashes_are_dimmed() { - let spans = super::help_entry_prefix("h / j"); - - assert!(spans.iter().any( - |span| span.content.as_ref() == "/" && span.style.fg == Some(theme::TEXT_DISABLED) - )); - } - - #[test] - fn help_entry_chips_use_compact_square_background_without_caps() { - let spans = super::help_entry_prefix("h"); - let text = spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - - assert!(text.contains(" h ")); - assert!(!text.contains("")); - assert!(!text.contains("")); - assert!(spans - .iter() - .any(|span| span.content.as_ref() == " h " - && span.style.bg == Some(theme::SURFACE_MUTED))); - } - #[test] fn help_entry_descriptions_align_to_the_right_edge() { let short_entry = HelpEntry { @@ -1799,24 +1773,6 @@ mod tests { assert!(!rendered.contains("No findings for active cell")); } - #[test] - fn renders_cell_panel_title_and_border_with_primary_text_color_by_default() { - let backend = TestBackend::new(140, 40); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - assert_eq!( - text_fg_at(&terminal, " Cell A1 String Len 4 "), - theme::TEXT - ); - assert_eq!( - fg_before_needle(&terminal, " Cell A1 String Len 4 "), - theme::TEXT - ); - } - #[test] fn renders_notifications_panel_when_inspector_moves_below_table() { let backend = TestBackend::new(90, 28); @@ -1839,19 +1795,6 @@ mod tests { assert!(rendered.contains("NOTIFICATIONS")); } - #[test] - fn renders_notifications_title_and_border_with_primary_text_color() { - let backend = TestBackend::new(140, 40); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - app.add_notification("Loaded 2 findings".to_string()); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - assert_eq!(text_fg_at(&terminal, " NOTIFICATIONS "), theme::TEXT); - assert_eq!(fg_before_needle(&terminal, " NOTIFICATIONS "), theme::TEXT); - } - #[test] fn renders_editing_panel_with_vim_mode_in_title_and_without_status_mode() { let backend = TestBackend::new(140, 40); @@ -1883,23 +1826,6 @@ mod tests { assert!(title_row.contains("Rows/Cols: 2 x 2")); } - #[test] - fn renders_latest_notification_brighter_than_history() { - let backend = TestBackend::new(140, 40); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - app.add_notification("older notification".to_string()); - app.add_notification("latest notification".to_string()); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - assert_eq!(text_fg_at(&terminal, "latest notification"), theme::TEXT); - assert_eq!( - text_fg_at(&terminal, "older notification"), - theme::TEXT_SECONDARY - ); - } - #[test] fn removed_analysis_modes_do_not_appear_in_rendered_ui() { let backend = TestBackend::new(140, 32); From ffce7ace06943de2056415fb4047f3930bad2fb9 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Wed, 22 Apr 2026 19:12:45 +0800 Subject: [PATCH 03/13] =?UTF-8?q?refactor(core):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E5=9F=BA=E7=BA=BF=E5=B9=B6=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/actions/cell.rs | 9 - src/actions/column.rs | 17 -- src/actions/row.rs | 17 -- src/actions/sheet.rs | 9 - src/actions/types.rs | 10 +- src/cli/read.rs | 249 +++++++++++++--------- src/commands/executor.rs | 28 +-- src/excel/workbook.rs | 329 +---------------------------- src/excel/workbook/save.rs | 129 +++++++++++ src/excel/workbook/sheet_parse.rs | 109 ++++++++++ src/excel/workbook/tests.rs | 97 +++++++++ src/ui/mod.rs | 1 + src/ui/render.rs | 50 +---- src/ui/theme.rs | 25 +++ tests/help_and_version_cli_test.rs | 5 +- 15 files changed, 541 insertions(+), 543 deletions(-) create mode 100644 src/excel/workbook/save.rs create mode 100644 src/excel/workbook/sheet_parse.rs create mode 100644 src/excel/workbook/tests.rs create mode 100644 src/ui/theme.rs diff --git a/src/actions/cell.rs b/src/actions/cell.rs index a0eeea1..ba1e66f 100644 --- a/src/actions/cell.rs +++ b/src/actions/cell.rs @@ -1,6 +1,5 @@ use super::{ActionType, Command}; use crate::excel::Cell; -use anyhow::Result; #[derive(Clone)] pub struct CellAction { @@ -37,14 +36,6 @@ impl CellAction { } impl Command for CellAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { self.action_type.clone() } diff --git a/src/actions/column.rs b/src/actions/column.rs index 106aa65..6a4a3ab 100644 --- a/src/actions/column.rs +++ b/src/actions/column.rs @@ -1,6 +1,5 @@ use super::{ActionType, Command}; use crate::excel::Cell; -use anyhow::Result; #[derive(Clone)] pub struct ColumnAction { @@ -12,14 +11,6 @@ pub struct ColumnAction { } impl Command for ColumnAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { ActionType::DeleteColumn } @@ -36,14 +27,6 @@ pub struct MultiColumnAction { } impl Command for MultiColumnAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { ActionType::DeleteMultiColumns } diff --git a/src/actions/row.rs b/src/actions/row.rs index c9f19b1..3bc34b7 100644 --- a/src/actions/row.rs +++ b/src/actions/row.rs @@ -1,6 +1,5 @@ use super::{ActionType, Command}; use crate::excel::Cell; -use anyhow::Result; #[derive(Clone)] pub struct RowAction { @@ -11,14 +10,6 @@ pub struct RowAction { } impl Command for RowAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { ActionType::DeleteRow } @@ -34,14 +25,6 @@ pub struct MultiRowAction { } impl Command for MultiRowAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { ActionType::DeleteMultiRows } diff --git a/src/actions/sheet.rs b/src/actions/sheet.rs index d1c12a2..a1269c2 100644 --- a/src/actions/sheet.rs +++ b/src/actions/sheet.rs @@ -1,6 +1,5 @@ use super::{ActionType, Command}; use crate::excel::Sheet; -use anyhow::Result; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SheetOperation { @@ -18,14 +17,6 @@ pub struct SheetAction { } impl Command for SheetAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { match self.operation { SheetOperation::Create => ActionType::CreateSheet, diff --git a/src/actions/types.rs b/src/actions/types.rs index 61ab7fa..b81b4d3 100644 --- a/src/actions/types.rs +++ b/src/actions/types.rs @@ -42,8 +42,14 @@ pub trait ActionExecutor { // Command interface for actions that can be executed and undone pub trait Command { - fn execute(&self) -> anyhow::Result<()>; - fn undo(&self) -> anyhow::Result<()>; + fn execute(&self) -> anyhow::Result<()> { + unimplemented!("Requires an ActionExecutor implementation") + } + + fn undo(&self) -> anyhow::Result<()> { + unimplemented!("Requires an ActionExecutor implementation") + } + fn action_type(&self) -> ActionType; } diff --git a/src/cli/read.rs b/src/cli/read.rs index 0ea42f3..c76cf70 100644 --- a/src/cli/read.rs +++ b/src/cli/read.rs @@ -11,7 +11,7 @@ use zip::ZipArchive; use crate::cli::args::{resolve_sheet_target, OutputFormat, OutputShape, ReadCommands}; use crate::cli::envelope; use crate::cli::error::AppError; -use crate::excel::{open_workbook, CellType}; +use crate::excel::{open_workbook, CellType, Sheet}; use crate::utils::{index_to_col_name, parse_cell_reference, parse_range}; pub fn handle(cmd: ReadCommands) -> Result { @@ -235,12 +235,135 @@ struct RowReadRequest { format: OutputFormat, } +#[derive(Clone, Copy)] +struct RowBounds { + start_row: usize, + end_row: usize, + start_col: usize, + end_col: usize, +} + +struct RowOutput { + values: Vec, + row_count: usize, + truncated: bool, +} + +#[derive(Clone, Copy)] +struct RowOutputFormat<'a> { + selected_indices: &'a [usize], + columns: &'a [String], + output_shape: OutputShape, +} + +struct RowCollectRequest<'a> { + sheet: &'a Sheet, + bounds: RowBounds, + output_format: RowOutputFormat<'a>, + filters: &'a [FilterSpec], + non_empty: bool, + offset: usize, + limit: Option, +} + fn invalid_query(message: impl Into) -> AppError { AppError::InvalidQuery { message: message.into(), } } +fn sheet_row_values(sheet: &Sheet, row: usize, bounds: RowBounds) -> Option> { + if row >= sheet.data.len() { + return None; + } + + let values = (bounds.start_col..=bounds.end_col) + .map(|col| { + if col < sheet.data[row].len() { + crate::json_export::process_cell_value(&sheet.data[row][col]) + } else { + Value::Null + } + }) + .collect(); + + Some(values) +} + +fn row_passes_filters(row: &[Value], filters: &[FilterSpec], non_empty: bool) -> bool { + if non_empty && row.iter().all(is_empty_cell) { + return false; + } + + filters.iter().all(|filter| filter_matches(row, filter)) +} + +fn output_row(row: &[Value], output_format: RowOutputFormat<'_>) -> Value { + if matches!( + output_format.output_shape, + OutputShape::Records | OutputShape::Jsonl + ) { + let mut record = serde_json::Map::new(); + for idx in output_format.selected_indices { + let value = row.get(*idx).cloned().unwrap_or(Value::Null); + record.insert(output_format.columns[*idx].clone(), value); + } + return Value::Object(record); + } + + Value::Array( + output_format + .selected_indices + .iter() + .map(|idx| row.get(*idx).cloned().unwrap_or(Value::Null)) + .collect(), + ) +} + +fn collect_row_output(request: RowCollectRequest<'_>) -> RowOutput { + let mut values = Vec::new(); + let mut skipped = 0usize; + let mut truncated = false; + + for row_idx in request.bounds.start_row..=request.bounds.end_row { + let Some(row) = sheet_row_values(request.sheet, row_idx, request.bounds) else { + break; + }; + if !row_passes_filters(&row, request.filters, request.non_empty) { + continue; + } + if skipped < request.offset { + skipped += 1; + continue; + } + if request.limit.is_some_and(|size| values.len() >= size) { + truncated = true; + break; + } + + values.push(output_row(&row, request.output_format)); + } + + let row_count = values.len(); + RowOutput { + values, + row_count, + truncated, + } +} + +fn read_header_values(sheet: &Sheet, header_row_idx: usize, bounds: RowBounds) -> Vec { + (bounds.start_col..=bounds.end_col) + .map(|col| { + if header_row_idx < sheet.data.len() && col < sheet.data[header_row_idx].len() { + sheet.data[header_row_idx][col].value.clone() + } else { + String::new() + } + }) + .collect() +} + fn parse_selected_columns( select: Option, columns: &[String], @@ -735,6 +858,12 @@ fn read_rows( index_to_col_name(end_col), end_row ); + let requested_bounds = RowBounds { + start_row, + end_row, + start_col, + end_col, + }; if resolved_header.is_none() && (command_requires_header @@ -745,61 +874,16 @@ fn read_rows( )); } - let (has_header, columns, row_values) = if let Some(header_row_idx) = resolved_header { - let mut headers = Vec::new(); - for col in start_col..=end_col { - let val = if header_row_idx < sheet_obj.data.len() - && col < sheet_obj.data[header_row_idx].len() - { - sheet_obj.data[header_row_idx][col].value.clone() - } else { - String::new() - }; - headers.push(val); - } + let (has_header, columns, data_start_row) = if let Some(header_row_idx) = resolved_header { + let headers = read_header_values(sheet_obj, header_row_idx, requested_bounds); let columns = stable_record_keys(&headers, start_col); - - let mut row_values = Vec::new(); let data_start_row = start_row.max(header_row_idx.saturating_add(1)); - for row in data_start_row..=end_row { - if row >= sheet_obj.data.len() { - break; - } - let mut values = Vec::new(); - for col in start_col..=end_col { - let value = if col < sheet_obj.data[row].len() { - crate::json_export::process_cell_value(&sheet_obj.data[row][col]) - } else { - Value::Null - }; - values.push(value); - } - row_values.push(values); - } - - (true, columns, row_values) + (true, columns, data_start_row) } else { let columns: Vec = (start_col..=end_col) .map(|col| format!("col_{}", index_to_col_name(col))) .collect(); - let mut row_values = Vec::new(); - for row in start_row..=end_row { - if row >= sheet_obj.data.len() { - break; - } - let mut values = Vec::new(); - for col in start_col..=end_col { - let value = if col < sheet_obj.data[row].len() { - crate::json_export::process_cell_value(&sheet_obj.data[row][col]) - } else { - Value::Null - }; - values.push(value); - } - row_values.push(values); - } - - (false, columns, row_values) + (false, columns, start_row) }; let selected_indices = parse_selected_columns(select, &columns)?; @@ -813,56 +897,33 @@ fn read_rows( .map(|idx| columns[*idx].clone()) .collect(); - let mut filtered_rows: Vec> = row_values; - if non_empty { - filtered_rows.retain(|row| row.iter().any(|cell| !is_empty_cell(cell))); - } - filtered_rows.retain(|row| { - parsed_filters - .iter() - .all(|filter| filter_matches(row, filter)) + let row_output = collect_row_output(RowCollectRequest { + sheet: sheet_obj, + bounds: RowBounds { + start_row: data_start_row, + end_row, + start_col, + end_col, + }, + output_format: RowOutputFormat { + selected_indices: &selected_indices, + columns: &columns, + output_shape, + }, + filters: &parsed_filters, + non_empty, + offset: offset.unwrap_or(0), + limit, }); - let offset = offset.unwrap_or(0); - let rows_after_offset: Vec> = filtered_rows.into_iter().skip(offset).collect(); - let truncated = limit.is_some_and(|size| rows_after_offset.len() > size); - let returned_rows: Vec> = if let Some(size) = limit { - rows_after_offset.into_iter().take(size).collect() - } else { - rows_after_offset - }; - - let row_count = returned_rows.len(); - - let records: Vec = returned_rows - .iter() - .map(|row| { - let mut record = serde_json::Map::new(); - for idx in &selected_indices { - let value = row.get(*idx).cloned().unwrap_or(Value::Null); - record.insert(columns[*idx].clone(), value); - } - Value::Object(record) - }) - .collect(); - - let rows: Vec = returned_rows - .into_iter() - .map(|row| { - Value::Array( - selected_indices - .iter() - .map(|idx| row.get(*idx).cloned().unwrap_or(Value::Null)) - .collect(), - ) - }) - .collect(); + let row_count = row_output.row_count; + let truncated = row_output.truncated; let data = if matches!(output_shape, OutputShape::Records | OutputShape::Jsonl) { json!({ "resolved_header_row": resolved_header.unwrap(), "mode": output_shape.as_str(), - "records": records, + "records": row_output.values, }) } else { json!({ @@ -872,7 +933,7 @@ fn read_rows( Value::Null }, "mode": "rows", - "rows": rows, + "rows": row_output.values, }) }; diff --git a/src/commands/executor.rs b/src/commands/executor.rs index ed0d55a..f49ae8d 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -393,11 +393,7 @@ fn parse_cell_reference(input: &str) -> Option<(usize, usize)> { #[cfg(test)] mod tests { - use std::path::PathBuf; - use super::parse_cell_reference; - use crate::app::{AppState, InputMode}; - use crate::excel::{Cell, Sheet, Workbook}; #[test] fn parses_valid_cell_references() { @@ -410,26 +406,4 @@ mod tests { assert_eq!(parse_cell_reference("addsheet 测试1"), None); assert_eq!(parse_cell_reference("测试1"), None); } - - fn app_with_sheet() -> AppState<'static> { - let mut data = vec![vec![Cell::empty(); 3]; 3]; - data[1][1] = Cell::new("Name".to_string(), false); - data[1][2] = Cell::new("Name".to_string(), false); - data[2][1] = Cell::new("Ada".to_string(), false); - data[2][2] = Cell::new("10".to_string(), false); - - let sheet = Sheet { - name: "Data".to_string(), - data, - max_rows: 2, - max_cols: 2, - is_loaded: true, - }; - - AppState::new( - Workbook::from_sheets_for_test(vec![sheet]), - PathBuf::from("scores.xlsx"), - ) - .unwrap() - } -} \ No newline at end of file +} diff --git a/src/excel/workbook.rs b/src/excel/workbook.rs index 22c5caf..63267af 100644 --- a/src/excel/workbook.rs +++ b/src/excel/workbook.rs @@ -1,16 +1,19 @@ use anyhow::{Context, Result}; -use calamine::{open_workbook_auto, Data, Reader, Xls, Xlsx}; -use chrono::Local; -use rust_xlsxwriter::{Format, Workbook as XlsxWorkbook}; +use calamine::{open_workbook_auto, Reader, Xls, Xlsx}; use std::collections::HashSet; use std::fs::File; use std::io::BufReader; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; -use crate::excel::{Cell, CellType, DataTypeInfo, Sheet}; +use crate::excel::{Cell, CellType, Sheet}; use crate::utils::index_to_col_name; +mod save; +mod sheet_parse; + +use sheet_parse::create_sheet_from_range; + pub enum CalamineWorkbook { Xlsx(Box>>), Xls(Xls>), @@ -162,119 +165,6 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res }) } -fn create_sheet_from_range( - name: &str, - range: calamine::Range, - formula_range: Option>, -) -> Sheet { - let (height, width) = range.get_size(); - - // Create a data grid with empty cells, adding 1 to dimensions for 1-based indexing - let mut data = vec![vec![Cell::empty(); width + 1]; height + 1]; - - // Process only non-empty cells - for (row_idx, col_idx, cell) in range.used_cells() { - // Extract value, cell_type, and original_type from the Data - let (value, cell_type, original_type) = match cell { - Data::Empty => (String::new(), CellType::Empty, Some(DataTypeInfo::Empty)), - - Data::String(s) => { - let value = s.clone(); - (value, CellType::Text, Some(DataTypeInfo::String)) - } - - Data::Float(f) => { - let value = if *f == (*f as i64) as f64 && f.abs() < 1e10 { - (*f as i64).to_string() - } else { - f.to_string() - }; - (value, CellType::Number, Some(DataTypeInfo::Float(*f))) - } - - Data::Int(i) => (i.to_string(), CellType::Number, Some(DataTypeInfo::Int(*i))), - - Data::Bool(b) => ( - if *b { - "TRUE".to_string() - } else { - "FALSE".to_string() - }, - CellType::Boolean, - Some(DataTypeInfo::Bool(*b)), - ), - - Data::Error(e) => { - let mut value = String::with_capacity(15); - value.push_str("Error: "); - value.push_str(&format!("{:?}", e)); - (value, CellType::Text, Some(DataTypeInfo::Error)) - } - - Data::DateTime(dt) => ( - dt.to_string(), - CellType::Date, - Some(DataTypeInfo::DateTime(dt.as_f64())), - ), - - Data::DateTimeIso(s) => { - let value = s.clone(); - ( - value.clone(), - CellType::Date, - Some(DataTypeInfo::DateTimeIso(value)), - ) - } - - Data::DurationIso(s) => { - let value = s.clone(); - ( - value.clone(), - CellType::Text, - Some(DataTypeInfo::DurationIso(value)), - ) - } - }; - - let is_formula = !value.is_empty() && value.starts_with('='); - - // Store the cell in data grid (using 1-based indexing) - data[row_idx + 1][col_idx + 1] = - Cell::new_with_type(value, is_formula, cell_type, original_type); - } - - if let Some(formulas) = formula_range { - let (start_row, start_col) = formulas.start().unwrap_or((0, 0)); - for (row_idx, col_idx, formula) in formulas.used_cells() { - if formula.is_empty() { - continue; - } - - let normalized = if formula.starts_with('=') { - formula.to_string() - } else { - format!("={formula}") - }; - - let row = start_row as usize + row_idx + 1; - let col = start_col as usize + col_idx + 1; - if row < data.len() && col < data[row].len() { - let cell = &mut data[row][col]; - cell.is_formula = true; - cell.formula = Some(normalized); - } - } - } - - Sheet { - name: name.to_string(), - data, - max_rows: height, - max_cols: width, - is_loaded: true, - } -} - impl Workbook { pub fn get_current_sheet(&self) -> &Sheet { &self.sheets[self.current_sheet_index] @@ -791,111 +681,6 @@ impl Workbook { self.sheets[sheet_index].is_loaded } - pub fn save(&mut self) -> Result<()> { - if !self.is_modified { - println!("No changes to save."); - return Ok(()); - } - - self.ensure_all_sheets_loaded()?; - - // Create a new workbook with rust_xlsxwriter - let mut workbook = XlsxWorkbook::new(); - - let now = Local::now(); - let timestamp = now.format("%Y%m%d_%H%M%S").to_string(); - let path = Path::new(&self.file_path); - let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet"); - let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("xlsx"); - let parent_dir = path.parent().unwrap_or_else(|| Path::new("")); - let new_filename = format!("{}_{}.{}", file_stem, timestamp, extension); - let new_filepath = parent_dir.join(new_filename); - - // Create formats - let number_format = Format::new().set_num_format("General"); - let date_format = Format::new().set_num_format("yyyy-mm-dd"); - - // Process each sheet - for sheet in &self.sheets { - let worksheet = workbook.add_worksheet().set_name(&sheet.name)?; - - // Set column widths - for col in 0..sheet.max_cols { - worksheet.set_column_width(col as u16, 15)?; - } - - // Write cell data - for row in 1..sheet.data.len() { - if row <= sheet.max_rows { - for col in 1..sheet.data[0].len() { - if col <= sheet.max_cols { - let cell = &sheet.data[row][col]; - - // Skip empty cells - if cell.value.is_empty() { - continue; - } - - let row_idx = (row - 1) as u32; - let col_idx = (col - 1) as u16; - - if cell.is_formula { - let formula_text = - cell.formula.as_deref().unwrap_or(cell.value.as_str()); - let formula = rust_xlsxwriter::Formula::new(formula_text); - worksheet.write_formula(row_idx, col_idx, formula)?; - if !cell.value.is_empty() && cell.value != formula_text { - worksheet.set_formula_result(row_idx, col_idx, &cell.value); - } - continue; - } - - // Write cell based on its type - match cell.cell_type { - CellType::Number => { - if let Ok(num) = cell.value.parse::() { - worksheet.write_number_with_format( - row_idx, - col_idx, - num, - &number_format, - )?; - } else { - worksheet.write_string(row_idx, col_idx, &cell.value)?; - } - } - CellType::Date => { - worksheet.write_string_with_format( - row_idx, - col_idx, - &cell.value, - &date_format, - )?; - } - CellType::Boolean => { - if let Ok(b) = cell.value.parse::() { - worksheet.write_boolean(row_idx, col_idx, b)?; - } else { - worksheet.write_string(row_idx, col_idx, &cell.value)?; - } - } - CellType::Text => { - worksheet.write_string(row_idx, col_idx, &cell.value)?; - } - CellType::Empty => {} - } - } - } - } - } - } - - workbook.save(&new_filepath)?; - self.is_modified = false; - - Ok(()) - } - pub fn insert_sheet_at_index(&mut self, sheet: Sheet, index: usize) -> Result<()> { if index > self.sheets.len() { anyhow::bail!( @@ -1020,102 +805,4 @@ impl Workbook { } #[cfg(test)] -mod tests { - use super::Workbook; - use crate::excel::Sheet; - - fn blank_sheet(name: &str) -> Sheet { - Sheet::blank(name.to_string()) - } - - #[test] - fn adds_blank_sheet_after_current_sheet() { - let mut workbook = - Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1"), blank_sheet("Sheet2")]); - - let sheet_name = workbook.add_sheet("Added", 1).unwrap(); - - assert_eq!(sheet_name, "Added"); - assert_eq!( - workbook.get_sheet_names(), - vec!["Sheet1", "Added", "Sheet2"] - ); - - let added_sheet = workbook.get_sheet_by_index(1).unwrap(); - assert_eq!(added_sheet.name, "Added"); - assert_eq!(added_sheet.max_rows, 1); - assert_eq!(added_sheet.max_cols, 1); - assert!(added_sheet.is_loaded); - assert_eq!(added_sheet.data.len(), 2); - assert_eq!(added_sheet.data[1].len(), 2); - } - - #[test] - fn rejects_duplicate_sheet_names_case_insensitively() { - let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Summary")]); - - let error = workbook.add_sheet("summary", 1).unwrap_err().to_string(); - - assert!(error.contains("already exists")); - } - - #[test] - fn rejects_invalid_sheet_names() { - let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); - - assert!(workbook.add_sheet("", 1).is_err()); - assert!(workbook.add_sheet("Bad/Name", 1).is_err()); - assert!(workbook.add_sheet("'quoted", 1).is_err()); - assert!(workbook - .add_sheet("this-sheet-name-is-definitely-too-long", 1) - .is_err()); - } - - #[test] - fn counts_sheet_name_length_by_characters() { - let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); - let valid_name = "表".repeat(31); - let invalid_name = "表".repeat(32); - - assert!(workbook.add_sheet(&valid_name, 1).is_ok()); - assert!(workbook.add_sheet(&invalid_name, 2).is_err()); - } - - #[test] - fn resolves_sheet_by_index_and_name() { - let workbook = Workbook::from_sheets_for_test(vec![ - blank_sheet("Sheet1"), - blank_sheet("Orders"), - blank_sheet("客户"), - ]); - - assert_eq!(workbook.resolve_sheet("0").unwrap(), 0); - assert_eq!(workbook.resolve_sheet("2").unwrap(), 2); - assert_eq!(workbook.resolve_sheet("Sheet1").unwrap(), 0); - assert_eq!(workbook.resolve_sheet("Orders").unwrap(), 1); - assert_eq!(workbook.resolve_sheet("客户").unwrap(), 2); - - assert!(workbook.resolve_sheet("99").is_err()); - assert!(workbook.resolve_sheet("Missing").is_err()); - } - - #[test] - fn computes_used_range_for_sheet() { - let mut sheet = Sheet::blank("Test".to_string()); - sheet.max_rows = 10; - sheet.max_cols = 5; - let workbook = Workbook::from_sheets_for_test(vec![sheet]); - - assert_eq!(workbook.get_used_range(0).unwrap(), "A1:E10"); - assert!(workbook.get_used_range(99).is_err()); - } - - #[test] - fn empty_sheet_has_no_used_range() { - let mut sheet = Sheet::blank("Empty".to_string()); - sheet.max_rows = 0; - sheet.max_cols = 0; - let workbook = Workbook::from_sheets_for_test(vec![sheet]); - assert_eq!(workbook.get_used_range(0).unwrap(), ""); - } -} +mod tests; diff --git a/src/excel/workbook/save.rs b/src/excel/workbook/save.rs new file mode 100644 index 0000000..b135046 --- /dev/null +++ b/src/excel/workbook/save.rs @@ -0,0 +1,129 @@ +use anyhow::Result; +use chrono::Local; +use rust_xlsxwriter::{Format, Workbook as XlsxWorkbook, Worksheet}; +use std::path::{Path, PathBuf}; + +use super::Workbook; +use crate::excel::{Cell, CellType, Sheet}; + +impl Workbook { + pub fn save(&mut self) -> Result<()> { + if !self.is_modified { + println!("No changes to save."); + return Ok(()); + } + + self.ensure_all_sheets_loaded()?; + + let mut workbook = XlsxWorkbook::new(); + let new_filepath = timestamped_save_path(&self.file_path); + let number_format = Format::new().set_num_format("General"); + let date_format = Format::new().set_num_format("yyyy-mm-dd"); + + for sheet in &self.sheets { + write_sheet(&mut workbook, sheet, &number_format, &date_format)?; + } + + workbook.save(&new_filepath)?; + self.is_modified = false; + + Ok(()) + } +} + +fn timestamped_save_path(file_path: &str) -> PathBuf { + let timestamp = Local::now().format("%Y%m%d_%H%M%S").to_string(); + let path = Path::new(file_path); + let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet"); + let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("xlsx"); + let parent_dir = path.parent().unwrap_or_else(|| Path::new("")); + parent_dir.join(format!("{file_stem}_{timestamp}.{extension}")) +} + +fn write_sheet( + workbook: &mut XlsxWorkbook, + sheet: &Sheet, + number_format: &Format, + date_format: &Format, +) -> Result<()> { + let worksheet = workbook.add_worksheet().set_name(&sheet.name)?; + + for col in 0..sheet.max_cols { + worksheet.set_column_width(col as u16, 15)?; + } + + for row in 1..sheet.data.len() { + if row > sheet.max_rows { + continue; + } + + for col in 1..sheet.data[0].len() { + if col > sheet.max_cols { + continue; + } + + let cell = &sheet.data[row][col]; + if cell.value.is_empty() { + continue; + } + + let row_idx = (row - 1) as u32; + let col_idx = (col - 1) as u16; + write_cell( + worksheet, + cell, + row_idx, + col_idx, + number_format, + date_format, + )?; + } + } + + Ok(()) +} + +fn write_cell( + worksheet: &mut Worksheet, + cell: &Cell, + row_idx: u32, + col_idx: u16, + number_format: &Format, + date_format: &Format, +) -> Result<()> { + if cell.is_formula { + let formula_text = cell.formula.as_deref().unwrap_or(cell.value.as_str()); + let formula = rust_xlsxwriter::Formula::new(formula_text); + worksheet.write_formula(row_idx, col_idx, formula)?; + if !cell.value.is_empty() && cell.value != formula_text { + worksheet.set_formula_result(row_idx, col_idx, &cell.value); + } + return Ok(()); + } + + match cell.cell_type { + CellType::Number => { + if let Ok(num) = cell.value.parse::() { + worksheet.write_number_with_format(row_idx, col_idx, num, number_format)?; + } else { + worksheet.write_string(row_idx, col_idx, &cell.value)?; + } + } + CellType::Date => { + worksheet.write_string_with_format(row_idx, col_idx, &cell.value, date_format)?; + } + CellType::Boolean => { + if let Ok(b) = cell.value.parse::() { + worksheet.write_boolean(row_idx, col_idx, b)?; + } else { + worksheet.write_string(row_idx, col_idx, &cell.value)?; + } + } + CellType::Text => { + worksheet.write_string(row_idx, col_idx, &cell.value)?; + } + CellType::Empty => {} + } + + Ok(()) +} diff --git a/src/excel/workbook/sheet_parse.rs b/src/excel/workbook/sheet_parse.rs new file mode 100644 index 0000000..2d1268b --- /dev/null +++ b/src/excel/workbook/sheet_parse.rs @@ -0,0 +1,109 @@ +use calamine::{Data, Range}; + +use crate::excel::{Cell, CellType, DataTypeInfo, Sheet}; + +pub(super) fn create_sheet_from_range( + name: &str, + range: Range, + formula_range: Option>, +) -> Sheet { + let (height, width) = range.get_size(); + let mut data = vec![vec![Cell::empty(); width + 1]; height + 1]; + + for (row_idx, col_idx, cell) in range.used_cells() { + let (value, cell_type, original_type) = cell_value_parts(cell); + let is_formula = !value.is_empty() && value.starts_with('='); + + data[row_idx + 1][col_idx + 1] = + Cell::new_with_type(value, is_formula, cell_type, original_type); + } + + apply_formula_metadata(&mut data, formula_range); + + Sheet { + name: name.to_string(), + data, + max_rows: height, + max_cols: width, + is_loaded: true, + } +} + +fn cell_value_parts(cell: &Data) -> (String, CellType, Option) { + match cell { + Data::Empty => (String::new(), CellType::Empty, Some(DataTypeInfo::Empty)), + Data::String(s) => (s.clone(), CellType::Text, Some(DataTypeInfo::String)), + Data::Float(f) => { + let value = if *f == (*f as i64) as f64 && f.abs() < 1e10 { + (*f as i64).to_string() + } else { + f.to_string() + }; + (value, CellType::Number, Some(DataTypeInfo::Float(*f))) + } + Data::Int(i) => (i.to_string(), CellType::Number, Some(DataTypeInfo::Int(*i))), + Data::Bool(b) => ( + if *b { + "TRUE".to_string() + } else { + "FALSE".to_string() + }, + CellType::Boolean, + Some(DataTypeInfo::Bool(*b)), + ), + Data::Error(e) => { + let mut value = String::with_capacity(15); + value.push_str("Error: "); + value.push_str(&format!("{:?}", e)); + (value, CellType::Text, Some(DataTypeInfo::Error)) + } + Data::DateTime(dt) => ( + dt.to_string(), + CellType::Date, + Some(DataTypeInfo::DateTime(dt.as_f64())), + ), + Data::DateTimeIso(s) => { + let value = s.clone(); + ( + value.clone(), + CellType::Date, + Some(DataTypeInfo::DateTimeIso(value)), + ) + } + Data::DurationIso(s) => { + let value = s.clone(); + ( + value.clone(), + CellType::Text, + Some(DataTypeInfo::DurationIso(value)), + ) + } + } +} + +fn apply_formula_metadata(data: &mut [Vec], formula_range: Option>) { + let Some(formulas) = formula_range else { + return; + }; + + let (start_row, start_col) = formulas.start().unwrap_or((0, 0)); + for (row_idx, col_idx, formula) in formulas.used_cells() { + if formula.is_empty() { + continue; + } + + let normalized = if formula.starts_with('=') { + formula.to_string() + } else { + format!("={formula}") + }; + + let row = start_row as usize + row_idx + 1; + let col = start_col as usize + col_idx + 1; + if row < data.len() && col < data[row].len() { + let cell = &mut data[row][col]; + cell.is_formula = true; + cell.formula = Some(normalized); + } + } +} diff --git a/src/excel/workbook/tests.rs b/src/excel/workbook/tests.rs new file mode 100644 index 0000000..6536253 --- /dev/null +++ b/src/excel/workbook/tests.rs @@ -0,0 +1,97 @@ +use super::Workbook; +use crate::excel::Sheet; + +fn blank_sheet(name: &str) -> Sheet { + Sheet::blank(name.to_string()) +} + +#[test] +fn adds_blank_sheet_after_current_sheet() { + let mut workbook = + Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1"), blank_sheet("Sheet2")]); + + let sheet_name = workbook.add_sheet("Added", 1).unwrap(); + + assert_eq!(sheet_name, "Added"); + assert_eq!( + workbook.get_sheet_names(), + vec!["Sheet1", "Added", "Sheet2"] + ); + + let added_sheet = workbook.get_sheet_by_index(1).unwrap(); + assert_eq!(added_sheet.name, "Added"); + assert_eq!(added_sheet.max_rows, 1); + assert_eq!(added_sheet.max_cols, 1); + assert!(added_sheet.is_loaded); + assert_eq!(added_sheet.data.len(), 2); + assert_eq!(added_sheet.data[1].len(), 2); +} + +#[test] +fn rejects_duplicate_sheet_names_case_insensitively() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Summary")]); + + let error = workbook.add_sheet("summary", 1).unwrap_err().to_string(); + + assert!(error.contains("already exists")); +} + +#[test] +fn rejects_invalid_sheet_names() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); + + assert!(workbook.add_sheet("", 1).is_err()); + assert!(workbook.add_sheet("Bad/Name", 1).is_err()); + assert!(workbook.add_sheet("'quoted", 1).is_err()); + assert!(workbook + .add_sheet("this-sheet-name-is-definitely-too-long", 1) + .is_err()); +} + +#[test] +fn counts_sheet_name_length_by_characters() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); + let valid_name = "表".repeat(31); + let invalid_name = "表".repeat(32); + + assert!(workbook.add_sheet(&valid_name, 1).is_ok()); + assert!(workbook.add_sheet(&invalid_name, 2).is_err()); +} + +#[test] +fn resolves_sheet_by_index_and_name() { + let workbook = Workbook::from_sheets_for_test(vec![ + blank_sheet("Sheet1"), + blank_sheet("Orders"), + blank_sheet("客户"), + ]); + + assert_eq!(workbook.resolve_sheet("0").unwrap(), 0); + assert_eq!(workbook.resolve_sheet("2").unwrap(), 2); + assert_eq!(workbook.resolve_sheet("Sheet1").unwrap(), 0); + assert_eq!(workbook.resolve_sheet("Orders").unwrap(), 1); + assert_eq!(workbook.resolve_sheet("客户").unwrap(), 2); + + assert!(workbook.resolve_sheet("99").is_err()); + assert!(workbook.resolve_sheet("Missing").is_err()); +} + +#[test] +fn computes_used_range_for_sheet() { + let mut sheet = Sheet::blank("Test".to_string()); + sheet.max_rows = 10; + sheet.max_cols = 5; + let workbook = Workbook::from_sheets_for_test(vec![sheet]); + + assert_eq!(workbook.get_used_range(0).unwrap(), "A1:E10"); + assert!(workbook.get_used_range(99).is_err()); +} + +#[test] +fn empty_sheet_has_no_used_range() { + let mut sheet = Sheet::blank("Empty".to_string()); + sheet.max_rows = 0; + sheet.max_cols = 0; + let workbook = Workbook::from_sheets_for_test(vec![sheet]); + assert_eq!(workbook.get_used_range(0).unwrap(), ""); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c566118..0483466 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ mod handlers; mod render; +mod theme; pub use crate::ui::render::run_app; diff --git a/src/ui/render.rs b/src/ui/render.rs index 816ed65..6aadc9b 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -22,40 +22,13 @@ use crate::app::VimMode; use crate::app::LEFT_HELP_SECTIONS; use crate::app::RIGHT_HELP_SECTIONS; use crate::ui::handlers::handle_key_event; +use crate::ui::theme; use crate::utils::cell_reference; use crate::utils::index_to_col_name; const HELP_ENTRY_INDENT: u16 = 2; const HELP_ENTRY_GAP: u16 = 1; -mod theme { - use ratatui::style::{Color, Style}; - - pub const BACKGROUND: Color = Color::Rgb(11, 16, 32); - pub const SURFACE: Color = Color::Rgb(17, 24, 39); - pub const SURFACE_MUTED: Color = Color::Rgb(31, 41, 55); - pub const GRID: Color = Color::Rgb(55, 65, 81); - pub const TEXT: Color = Color::Rgb(229, 231, 235); - pub const TEXT_SECONDARY: Color = Color::Rgb(156, 163, 175); - pub const TEXT_DISABLED: Color = Color::Rgb(107, 114, 128); - pub const ACCENT: Color = Color::Rgb(56, 189, 248); - pub const SEARCH: Color = Color::Rgb(250, 204, 21); - pub const WARNING: Color = Color::Rgb(245, 158, 11); - pub const SUCCESS: Color = Color::Rgb(34, 197, 94); - - pub fn base() -> Style { - Style::default().bg(BACKGROUND).fg(TEXT) - } - - pub fn surface() -> Style { - Style::default().bg(SURFACE).fg(TEXT) - } - - pub fn muted() -> Style { - Style::default().bg(SURFACE_MUTED).fg(TEXT_SECONDARY) - } -} - pub fn run_app(mut app_state: AppState) -> Result<()> { // Setup terminal let mut terminal = setup_terminal()?; @@ -868,8 +841,8 @@ fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) { } fn help_popup_area(area: Rect) -> Rect { - let popup_width = area.width.saturating_sub(4).min(112).max(48); - let popup_height = area.height.saturating_sub(2).min(32).max(12); + let popup_width = area.width.saturating_sub(4).clamp(48, 112); + let popup_height = area.height.saturating_sub(2).clamp(12, 32); let popup_x = area.x + area.width.saturating_sub(popup_width) / 2; let popup_y = area.y + area.height.saturating_sub(popup_height) / 2; @@ -1282,11 +1255,7 @@ fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { let is_current = sheet_idx == current_index; let style = if is_editing { - if is_current { - Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) - } else { - Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) - } + Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) } else if is_current { Style::default() .bg(Color::Black) @@ -1414,17 +1383,6 @@ mod tests { buffer.content[row * width + col + offset].fg } - fn fg_before_needle(terminal: &Terminal, needle: &str) -> Color { - let lines = rendered_lines(terminal); - let row = line_index(&lines, needle); - let col = lines[row] - .find(needle) - .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")); - let buffer = terminal.backend().buffer(); - let width = buffer.area.width as usize; - buffer.content[row * width + col.saturating_sub(1)].fg - } - fn fg_at(terminal: &Terminal, row: usize, col: usize) -> Color { let buffer = terminal.backend().buffer(); let width = buffer.area.width as usize; diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..5d1f95b --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,25 @@ +use ratatui::style::{Color, Style}; + +pub const BACKGROUND: Color = Color::Rgb(11, 16, 32); +pub const SURFACE: Color = Color::Rgb(17, 24, 39); +pub const SURFACE_MUTED: Color = Color::Rgb(31, 41, 55); +pub const GRID: Color = Color::Rgb(55, 65, 81); +pub const TEXT: Color = Color::Rgb(229, 231, 235); +pub const TEXT_SECONDARY: Color = Color::Rgb(156, 163, 175); +pub const TEXT_DISABLED: Color = Color::Rgb(107, 114, 128); +pub const ACCENT: Color = Color::Rgb(56, 189, 248); +pub const SEARCH: Color = Color::Rgb(250, 204, 21); +pub const WARNING: Color = Color::Rgb(245, 158, 11); +pub const SUCCESS: Color = Color::Rgb(34, 197, 94); + +pub fn base() -> Style { + Style::default().bg(BACKGROUND).fg(TEXT) +} + +pub fn surface() -> Style { + Style::default().bg(SURFACE).fg(TEXT) +} + +pub fn muted() -> Style { + Style::default().bg(SURFACE_MUTED).fg(TEXT_SECONDARY) +} diff --git a/tests/help_and_version_cli_test.rs b/tests/help_and_version_cli_test.rs index 1027a5e..a5f5b6c 100644 --- a/tests/help_and_version_cli_test.rs +++ b/tests/help_and_version_cli_test.rs @@ -142,5 +142,8 @@ fn version_prints_to_stdout_and_exits_zero() { ); let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "excel-cli 1.3.0"); + assert_eq!( + stdout.trim(), + format!("excel-cli {}", env!("CARGO_PKG_VERSION")) + ); } From e08bda0af47511c2bfdd5dc2fdb57243ae8e03a2 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Thu, 23 Apr 2026 01:10:54 +0800 Subject: [PATCH 04/13] refactor(cli): consolidate headless sheet query helpers --- src/cli/check.rs | 21 +-- src/cli/inspect.rs | 176 ++++++---------------- src/cli/mod.rs | 1 + src/cli/read.rs | 197 +++++++----------------- src/cli/sheet_query.rs | 329 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 430 insertions(+), 294 deletions(-) create mode 100644 src/cli/sheet_query.rs diff --git a/src/cli/check.rs b/src/cli/check.rs index 01f717c..41dea79 100644 --- a/src/cli/check.rs +++ b/src/cli/check.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use crate::cli::args::SeverityThreshold; use crate::cli::envelope; use crate::cli::error::{AppError, EXIT_CHECK_FINDINGS, EXIT_SUCCESS}; +use crate::cli::sheet_query::{cell_at, cell_has_formula, cell_is_present, header_value}; use crate::excel::{open_workbook, Cell, CellType, Sheet, Workbook}; use crate::utils::{cell_reference, index_to_col_name}; @@ -688,31 +689,11 @@ fn check_formula_presence(context: &SheetCheckContext<'_>) -> Vec }] } -fn cell_at(sheet: &Sheet, row: usize, col: usize) -> Option<&Cell> { - sheet.data.get(row).and_then(|row_data| row_data.get(col)) -} - -fn header_value(sheet: &Sheet, row: usize, col: usize) -> String { - cell_at(sheet, row, col) - .filter(|cell| !cell_has_formula(cell)) - .map(|cell| cell.value.trim().to_string()) - .unwrap_or_default() -} - fn is_blank_cell(cell: Option<&Cell>) -> bool { cell.map(|cell| !cell_has_formula(cell) && cell.value.trim().is_empty()) .unwrap_or(true) } -fn cell_has_formula(cell: &Cell) -> bool { - cell.is_formula || cell.formula.is_some() -} - -fn cell_is_present(cell: Option<&Cell>) -> bool { - cell.map(|cell| !cell.value.trim().is_empty() || cell_has_formula(cell)) - .unwrap_or(false) -} - fn data_column_has_value(context: &SheetCheckContext<'_>, col: usize) -> bool { (context.data_start_row..=context.sheet.max_rows) .any(|row| cell_is_present(cell_at(context.sheet, row, col))) diff --git a/src/cli/inspect.rs b/src/cli/inspect.rs index 6f9f1ab..dfe658e 100644 --- a/src/cli/inspect.rs +++ b/src/cli/inspect.rs @@ -2,11 +2,14 @@ use anyhow::Context; use serde_json::{json, Value}; use std::collections::HashMap; -use crate::cli::args::{resolve_sheet_target, InspectCommands}; +use crate::cli::args::InspectCommands; use crate::cli::envelope; use crate::cli::error::AppError; +use crate::cli::sheet_query::{ + cell_at, load_target_sheet, resolve_bounds, resolve_header_row, resolve_optional_header_row, +}; use crate::excel::{open_workbook, Cell, CellType, Sheet}; -use crate::utils::{index_to_col_name, parse_range}; +use crate::utils::index_to_col_name; pub fn handle(cmd: InspectCommands) -> Result { match cmd { @@ -99,36 +102,35 @@ fn inspect_sheet( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) + .get_sheet_by_index(resolved_sheet.index) + .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) .map_err(crate::cli::error::anyhow_to_app_error)?; let used_range = workbook - .get_used_range(index) + .get_used_range(resolved_sheet.index) .map_err(crate::cli::error::anyhow_to_app_error)?; let non_empty_rows = workbook - .count_non_empty_rows(index) + .count_non_empty_rows(resolved_sheet.index) .map_err(crate::cli::error::anyhow_to_app_error)?; let non_empty_cols = workbook - .count_non_empty_cols(index) + .count_non_empty_cols(resolved_sheet.index) .map_err(crate::cli::error::anyhow_to_app_error)?; let (header_candidates, recommended_header_row) = workbook - .find_header_candidates(index) + .find_header_candidates(resolved_sheet.index) .map_err(crate::cli::error::anyhow_to_app_error)?; let data = json!({ "name": sheet_obj.name, - "index": index, + "index": resolved_sheet.index, "used_range": used_range, "max_rows": sheet_obj.max_rows, "max_cols": sheet_obj.max_cols, @@ -142,7 +144,7 @@ fn inspect_sheet( "inspect.sheet", &path_str, &format_str, - envelope::target_sheet(&sheet_name, index), + envelope::target_sheet(&resolved_sheet.name, resolved_sheet.index), json!({}), data, vec![], @@ -163,61 +165,24 @@ fn inspect_sample( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) + .get_sheet_by_index(resolved_sheet.index) + .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) .map_err(crate::cli::error::anyhow_to_app_error)?; - - let used_range = workbook.get_used_range(index).unwrap_or_default(); - - // Determine the sample range - let ((mut start_row, mut start_col), (mut end_row, mut end_col)) = if let Some(ref r) = range { - parse_range(r).ok_or_else(|| AppError::InvalidQuery { - message: format!("Invalid range format: {}", r), - })? - } else if !used_range.is_empty() { - parse_range(&used_range).unwrap_or(((1, 1), (1, 1))) - } else { - ((1, 1), (1, 1)) - }; - - // Clamp to actual bounds - let max_row = sheet_obj.max_rows.max(1); - let max_col = sheet_obj.max_cols.max(1); - start_row = start_row.min(max_row); - start_col = start_col.min(max_col); - end_row = end_row.min(max_row); - end_col = end_col.min(max_col); - if start_row > end_row { - std::mem::swap(&mut start_row, &mut end_row); - } - if start_col > end_col { - std::mem::swap(&mut start_col, &mut end_col); - } + let bounds = resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, range.as_deref())?; // Apply row limit let row_limit = rows.unwrap_or(10); - let sample_end_row = (start_row + row_limit.saturating_sub(1)).min(end_row); - - // Resolve header row - let resolved_header = if header_row == "auto" { - let (_, recommended) = workbook - .find_header_candidates(index) - .map_err(crate::cli::error::anyhow_to_app_error)?; - recommended - } else { - header_row - .parse::() - .ok() - .filter(|&r| r >= 1 && r <= sheet_obj.max_rows) - }; + let sample_end_row = (bounds.start_row + row_limit.saturating_sub(1)).min(bounds.end_row); + + let resolved_header = + resolve_optional_header_row(&workbook, sheet_obj, resolved_sheet.index, &header_row)?; let sample_mode = if resolved_header.is_some() { "records" @@ -227,9 +192,9 @@ fn inspect_sample( let range_str = format!( "{}{}:{}{}", - index_to_col_name(start_col), - start_row, - index_to_col_name(end_col), + index_to_col_name(bounds.start_col), + bounds.start_row, + index_to_col_name(bounds.end_col), sample_end_row ); @@ -237,7 +202,7 @@ fn inspect_sample( // Build records with headers let mut headers = Vec::new(); if header_row_idx < sheet_obj.data.len() { - for col in start_col..=end_col { + for col in bounds.start_col..=bounds.end_col { let val = if col < sheet_obj.data[header_row_idx].len() { sheet_obj.data[header_row_idx][col].value.clone() } else { @@ -248,7 +213,7 @@ fn inspect_sample( } let mut records = Vec::new(); - for row in start_row..=sample_end_row { + for row in bounds.start_row..=sample_end_row { if row == header_row_idx { continue; } @@ -256,7 +221,7 @@ fn inspect_sample( break; } let mut record = serde_json::Map::new(); - for (col_idx, col) in (start_col..=end_col).enumerate() { + for (col_idx, col) in (bounds.start_col..=bounds.end_col).enumerate() { let key = headers.get(col_idx).cloned().unwrap_or_default(); let key = if key.is_empty() { format!("col_{}", col_idx + 1) @@ -281,12 +246,12 @@ fn inspect_sample( } else { // Raw rows let mut row_values = Vec::new(); - for row in start_row..=sample_end_row { + for row in bounds.start_row..=sample_end_row { if row >= sheet_obj.data.len() { break; } let mut cols = Vec::new(); - for col in start_col..=end_col { + for col in bounds.start_col..=bounds.end_col { let value = if col < sheet_obj.data[row].len() { crate::json_export::process_cell_value(&sheet_obj.data[row][col]) } else { @@ -308,7 +273,7 @@ fn inspect_sample( "inspect.sample", &path_str, &format_str, - envelope::target_range(&sheet_name, index, &range_str), + envelope::target_range(&resolved_sheet.name, resolved_sheet.index, &range_str), json!({}), data, vec![], @@ -326,21 +291,21 @@ fn inspect_columns( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = workbook - .resolve_sheet_by_name(&sheet) - .map_err(|e| AppError::TargetNotFound { - message: e.to_string(), - })?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &Some(sheet), &None)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let resolved_header = resolve_columns_header_row(&workbook, index, &header_row)?; let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) + .get_sheet_by_index(resolved_sheet.index) + .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) + .map_err(crate::cli::error::anyhow_to_app_error)?; + let resolved_header = + resolve_header_row(&workbook, sheet_obj, resolved_sheet.index, &header_row)?; + let sheet_obj = workbook + .get_sheet_by_index(resolved_sheet.index) + .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) .map_err(crate::cli::error::anyhow_to_app_error)?; let header_names = column_header_names(sheet_obj, resolved_header); @@ -383,7 +348,7 @@ fn inspect_columns( "inspect.columns", &path_str, &format_str, - envelope::target_sheet(&sheet_name, index), + envelope::target_sheet(&resolved_sheet.name, resolved_sheet.index), json!({ "header_row_mode": header_row, "resolved_header_row": resolved_header, @@ -404,17 +369,15 @@ fn inspect_tables(file: std::path::PathBuf, sheet: String) -> Result Result Result Result, AppError> { - if header_row == "auto" { - let (_, recommended) = workbook - .find_header_candidates(sheet_index) - .map_err(crate::cli::error::anyhow_to_app_error)?; - return Ok(recommended); - } - - let row = header_row - .parse::() - .map_err(|_| AppError::InvalidQuery { - message: format!("Invalid header row: {}", header_row), - })?; - - let sheet = - workbook - .get_sheet_by_index(sheet_index) - .ok_or_else(|| AppError::TargetNotFound { - message: "Sheet index out of range".to_string(), - })?; - - if row < 1 || row > sheet.max_rows { - return Err(AppError::InvalidQuery { - message: format!( - "Header row {} is outside the used row range 1..={}", - row, sheet.max_rows - ), - }); - } - - Ok(Some(row)) -} - fn column_header_names(sheet: &Sheet, resolved_header: Option) -> Vec { (1..=sheet.max_cols) .map(|col| { @@ -605,10 +531,6 @@ fn analyze_column( } } -fn cell_at(sheet: &Sheet, row: usize, col: usize) -> Option<&Cell> { - sheet.data.get(row).and_then(|row_data| row_data.get(col)) -} - fn is_non_null(cell: &Cell) -> bool { !cell.value.is_empty() } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3f9864d..15d0645 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,3 +6,4 @@ pub mod error; pub mod inspect; pub mod output; pub mod read; +pub mod sheet_query; diff --git a/src/cli/read.rs b/src/cli/read.rs index c76cf70..db136dd 100644 --- a/src/cli/read.rs +++ b/src/cli/read.rs @@ -2,17 +2,20 @@ use anyhow::Context; use quick_xml::events::Event; use regex::Regex; use serde_json::{json, Value}; -use std::collections::HashMap; use std::fs::File; use std::io::{Read, Seek}; use std::path::{Path, PathBuf}; use zip::ZipArchive; -use crate::cli::args::{resolve_sheet_target, OutputFormat, OutputShape, ReadCommands}; +use crate::cli::args::{OutputFormat, OutputShape, ReadCommands}; use crate::cli::envelope; use crate::cli::error::AppError; +use crate::cli::sheet_query::{ + load_target_sheet, read_header_values, resolve_bounds, resolve_optional_header_row, + stable_record_keys, SheetBounds, +}; use crate::excel::{open_workbook, CellType, Sheet}; -use crate::utils::{index_to_col_name, parse_cell_reference, parse_range}; +use crate::utils::{index_to_col_name, parse_cell_reference}; pub fn handle(cmd: ReadCommands) -> Result { match cmd { @@ -118,16 +121,15 @@ fn read_cell( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) + .get_sheet_by_index(resolved_sheet.index) + .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) .map_err(crate::cli::error::anyhow_to_app_error)?; let cell_ref = cell.to_ascii_uppercase(); @@ -137,7 +139,7 @@ fn read_cell( let formula = c .formula .clone() - .or_else(|| lookup_formula_in_xlsx(&file, &sheet_name, &cell_ref)); + .or_else(|| lookup_formula_in_xlsx(&file, &resolved_sheet.name, &cell_ref)); let type_str = if c.is_formula || formula.is_some() { "formula" } else { @@ -166,37 +168,13 @@ fn read_cell( "read.cell", &path_str, &format_str, - envelope::target_cell(&sheet_name, index, &cell_ref), + envelope::target_cell(&resolved_sheet.name, resolved_sheet.index, &cell_ref), json!({}), Value::Object(data), vec![], )) } -fn stable_record_keys(headers: &[String], start_col: usize) -> Vec { - let mut counts = HashMap::new(); - - headers - .iter() - .enumerate() - .map(|(offset, header)| { - let base = if header.trim().is_empty() { - format!("col_{}", index_to_col_name(start_col + offset)) - } else { - header.trim().to_string() - }; - - let count = counts.entry(base.clone()).or_insert(0usize); - *count += 1; - if *count == 1 { - base - } else { - format!("{base}_{count}") - } - }) - .collect() -} - #[derive(Clone, Copy)] enum FilterOp { Eq, @@ -272,6 +250,16 @@ fn invalid_query(message: impl Into) -> AppError { } } +fn format_bounds(bounds: SheetBounds) -> String { + format!( + "{}{}:{}{}", + index_to_col_name(bounds.start_col), + bounds.start_row, + index_to_col_name(bounds.end_col), + bounds.end_row + ) +} + fn sheet_row_values(sheet: &Sheet, row: usize, bounds: RowBounds) -> Option> { if row >= sheet.data.len() { return None; @@ -352,18 +340,6 @@ fn collect_row_output(request: RowCollectRequest<'_>) -> RowOutput { } } -fn read_header_values(sheet: &Sheet, header_row_idx: usize, bounds: RowBounds) -> Vec { - (bounds.start_col..=bounds.end_col) - .map(|col| { - if header_row_idx < sheet.data.len() && col < sheet.data[header_row_idx].len() { - sheet.data[header_row_idx][col].value.clone() - } else { - String::new() - } - }) - .collect() -} - fn parse_selected_columns( select: Option, columns: &[String], @@ -694,44 +670,25 @@ fn read_range( let format_str = file_format(&file); let path_str = file.to_string_lossy().to_string(); - let ((mut start_row, mut start_col), (mut end_row, mut end_col)) = parse_range(&range) - .ok_or_else(|| AppError::InvalidQuery { - message: format!("Invalid range format: {}", range), - })?; - let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) + .get_sheet_by_index(resolved_sheet.index) + .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) .map_err(crate::cli::error::anyhow_to_app_error)?; - - // Clamp to actual bounds - let max_row = sheet_obj.max_rows.max(1); - let max_col = sheet_obj.max_cols.max(1); - start_row = start_row.min(max_row); - start_col = start_col.min(max_col); - end_row = end_row.min(max_row); - end_col = end_col.min(max_col); - if start_row > end_row { - std::mem::swap(&mut start_row, &mut end_row); - } - if start_col > end_col { - std::mem::swap(&mut start_col, &mut end_col); - } + let bounds = resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, Some(&range))?; let mut rows = Vec::new(); - for row in start_row..=end_row { + for row in bounds.start_row..=bounds.end_row { let mut cols = Vec::new(); - for col in start_col..=end_col { + for col in bounds.start_col..=bounds.end_col { let value = if row < sheet_obj.data.len() && col < sheet_obj.data[row].len() { crate::json_export::process_cell_value(&sheet_obj.data[row][col]) } else { @@ -742,13 +699,7 @@ fn read_range( rows.push(Value::Array(cols)); } - let range_str = format!( - "{}{}:{}{}", - index_to_col_name(start_col), - start_row, - index_to_col_name(end_col), - end_row - ); + let range_str = format_bounds(bounds); let data = json!({ "range": range_str, @@ -759,7 +710,7 @@ fn read_range( "read.range", &path_str, &format_str, - envelope::target_range(&sheet_name, index, &range_str), + envelope::target_range(&resolved_sheet.name, resolved_sheet.index, &range_str), json!({}), data, vec![], @@ -798,72 +749,22 @@ fn read_rows( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) + .get_sheet_by_index(resolved_sheet.index) + .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) .map_err(crate::cli::error::anyhow_to_app_error)?; + let requested_bounds = + resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, range.as_deref())?; + let resolved_header = + resolve_optional_header_row(&workbook, sheet_obj, resolved_sheet.index, &header_row)?; - // Determine the range - let ((mut start_row, mut start_col), (mut end_row, mut end_col)) = if let Some(ref r) = range { - parse_range(r).ok_or_else(|| AppError::InvalidQuery { - message: format!("Invalid range format: {}", r), - })? - } else { - let used = workbook.get_used_range(index).unwrap_or_default(); - if used.is_empty() { - ((1, 1), (1, 1)) - } else { - parse_range(&used).unwrap_or(((1, 1), (1, 1))) - } - }; - - // Clamp to actual bounds - let max_row = sheet_obj.max_rows.max(1); - let max_col = sheet_obj.max_cols.max(1); - start_row = start_row.min(max_row); - start_col = start_col.min(max_col); - end_row = end_row.min(max_row); - end_col = end_col.min(max_col); - if start_row > end_row { - std::mem::swap(&mut start_row, &mut end_row); - } - if start_col > end_col { - std::mem::swap(&mut start_col, &mut end_col); - } - - // Resolve header row - let resolved_header = if header_row == "auto" { - let (_, recommended) = workbook - .find_header_candidates(index) - .map_err(crate::cli::error::anyhow_to_app_error)?; - recommended - } else { - header_row - .parse::() - .ok() - .filter(|&r| r >= 1 && r <= sheet_obj.max_rows) - }; - - let range_str = format!( - "{}{}:{}{}", - index_to_col_name(start_col), - start_row, - index_to_col_name(end_col), - end_row - ); - let requested_bounds = RowBounds { - start_row, - end_row, - start_col, - end_col, - }; + let range_str = format_bounds(requested_bounds); if resolved_header.is_none() && (command_requires_header @@ -876,14 +777,16 @@ fn read_rows( let (has_header, columns, data_start_row) = if let Some(header_row_idx) = resolved_header { let headers = read_header_values(sheet_obj, header_row_idx, requested_bounds); - let columns = stable_record_keys(&headers, start_col); - let data_start_row = start_row.max(header_row_idx.saturating_add(1)); + let columns = stable_record_keys(&headers, requested_bounds.start_col); + let data_start_row = requested_bounds + .start_row + .max(header_row_idx.saturating_add(1)); (true, columns, data_start_row) } else { - let columns: Vec = (start_col..=end_col) + let columns: Vec = (requested_bounds.start_col..=requested_bounds.end_col) .map(|col| format!("col_{}", index_to_col_name(col))) .collect(); - (false, columns, start_row) + (false, columns, requested_bounds.start_row) }; let selected_indices = parse_selected_columns(select, &columns)?; @@ -901,9 +804,9 @@ fn read_rows( sheet: sheet_obj, bounds: RowBounds { start_row: data_start_row, - end_row, - start_col, - end_col, + end_row: requested_bounds.end_row, + start_col: requested_bounds.start_col, + end_col: requested_bounds.end_col, }, output_format: RowOutputFormat { selected_indices: &selected_indices, @@ -949,7 +852,7 @@ fn read_rows( command, &path_str, &format_str, - envelope::target_range(&sheet_name, index, &range_str), + envelope::target_range(&resolved_sheet.name, resolved_sheet.index, &range_str), meta, data, vec![], diff --git a/src/cli/sheet_query.rs b/src/cli/sheet_query.rs new file mode 100644 index 0000000..94cc5ac --- /dev/null +++ b/src/cli/sheet_query.rs @@ -0,0 +1,329 @@ +use std::collections::HashMap; + +use crate::cli::args::resolve_sheet_target; +use crate::cli::error::{anyhow_to_app_error, AppError}; +use crate::excel::{Cell, Sheet, Workbook}; +use crate::utils::{index_to_col_name, parse_range}; + +pub(crate) struct ResolvedSheet { + pub(crate) index: usize, + pub(crate) name: String, +} + +#[derive(Clone, Copy)] +pub(crate) struct SheetBounds { + pub(crate) start_row: usize, + pub(crate) end_row: usize, + pub(crate) start_col: usize, + pub(crate) end_col: usize, +} + +pub(crate) fn load_target_sheet( + workbook: &Workbook, + sheet: &Option, + sheet_index: &Option, +) -> Result { + let index = resolve_sheet_target(workbook, sheet, sheet_index)?; + let name = workbook + .get_sheet_names() + .get(index) + .cloned() + .ok_or_else(|| AppError::TargetNotFound { + message: format!("Sheet index {} not found", index), + })?; + + Ok(ResolvedSheet { index, name }) +} + +pub(crate) fn resolve_bounds( + workbook: &Workbook, + sheet: &Sheet, + sheet_index: usize, + range: Option<&str>, +) -> Result { + let ((mut start_row, mut start_col), (mut end_row, mut end_col)) = if let Some(range) = range { + parse_range(range).ok_or_else(|| AppError::InvalidQuery { + message: format!("Invalid range format: {}", range), + })? + } else { + let used_range = workbook.get_used_range(sheet_index).unwrap_or_default(); + if used_range.is_empty() { + ((1, 1), (1, 1)) + } else { + parse_range(&used_range).unwrap_or(((1, 1), (1, 1))) + } + }; + + let max_row = sheet.max_rows.max(1); + let max_col = sheet.max_cols.max(1); + start_row = start_row.min(max_row); + start_col = start_col.min(max_col); + end_row = end_row.min(max_row); + end_col = end_col.min(max_col); + + if start_row > end_row { + std::mem::swap(&mut start_row, &mut end_row); + } + if start_col > end_col { + std::mem::swap(&mut start_col, &mut end_col); + } + + Ok(SheetBounds { + start_row, + end_row, + start_col, + end_col, + }) +} + +pub(crate) fn resolve_header_row( + workbook: &Workbook, + sheet: &Sheet, + sheet_index: usize, + header_row: &str, +) -> Result, AppError> { + if header_row == "auto" { + let (_, recommended) = workbook + .find_header_candidates(sheet_index) + .map_err(anyhow_to_app_error)?; + return Ok(recommended); + } + + let row = header_row + .parse::() + .map_err(|_| AppError::InvalidQuery { + message: format!("Invalid header row: {}", header_row), + })?; + + if row < 1 || row > sheet.max_rows { + return Err(AppError::InvalidQuery { + message: format!( + "Header row {} is outside the used row range 1..={}", + row, sheet.max_rows + ), + }); + } + + Ok(Some(row)) +} + +pub(crate) fn resolve_optional_header_row( + workbook: &Workbook, + sheet: &Sheet, + sheet_index: usize, + header_row: &str, +) -> Result, AppError> { + if header_row == "auto" { + let (_, recommended) = workbook + .find_header_candidates(sheet_index) + .map_err(anyhow_to_app_error)?; + return Ok(recommended); + } + + Ok(header_row + .parse::() + .ok() + .filter(|row| *row >= 1 && *row <= sheet.max_rows)) +} + +pub(crate) fn cell_at(sheet: &Sheet, row: usize, col: usize) -> Option<&Cell> { + sheet.data.get(row).and_then(|row_data| row_data.get(col)) +} + +pub(crate) fn cell_has_formula(cell: &Cell) -> bool { + cell.is_formula || cell.formula.is_some() +} + +pub(crate) fn cell_is_present(cell: Option<&Cell>) -> bool { + cell.map(|cell| !cell.value.trim().is_empty() || cell_has_formula(cell)) + .unwrap_or(false) +} + +pub(crate) fn header_value(sheet: &Sheet, row: usize, col: usize) -> String { + cell_at(sheet, row, col) + .filter(|cell| !cell_has_formula(cell)) + .map(|cell| cell.value.trim().to_string()) + .unwrap_or_default() +} + +pub(crate) fn stable_record_keys(headers: &[String], start_col: usize) -> Vec { + let mut counts = HashMap::new(); + + headers + .iter() + .enumerate() + .map(|(offset, header)| { + let base = if header.trim().is_empty() { + format!("col_{}", index_to_col_name(start_col + offset)) + } else { + header.trim().to_string() + }; + + let count = counts.entry(base.clone()).or_insert(0usize); + *count += 1; + if *count == 1 { + base + } else { + format!("{base}_{count}") + } + }) + .collect() +} + +pub(crate) fn read_header_values( + sheet: &Sheet, + header_row: usize, + bounds: SheetBounds, +) -> Vec { + (bounds.start_col..=bounds.end_col) + .map(|col| { + if header_row < sheet.data.len() && col < sheet.data[header_row].len() { + sheet.data[header_row][col].value.clone() + } else { + String::new() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::excel::{CellType, Sheet}; + + fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet { + let max_rows = values.len(); + let max_cols = values.iter().map(|row| row.len()).max().unwrap_or(0); + let mut data = vec![vec![Cell::empty(); max_cols + 1]; max_rows + 1]; + + for (row_idx, row) in values.iter().enumerate() { + for (col_idx, value) in row.iter().enumerate() { + data[row_idx + 1][col_idx + 1] = Cell::new((*value).to_string(), false); + } + } + + Sheet { + name: name.to_string(), + data, + max_rows, + max_cols, + is_loaded: true, + } + } + + #[test] + fn resolve_bounds_clamps_and_normalizes_explicit_ranges() { + let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( + "Orders", + &[&["order_id", "customer"], &["1001", "Alice"]], + )]); + let sheet = workbook.get_sheet_by_index(0).unwrap(); + + let bounds = resolve_bounds(&workbook, sheet, 0, Some("D5:B2")).unwrap(); + + assert_eq!(bounds.start_row, 2); + assert_eq!(bounds.end_row, 2); + assert_eq!(bounds.start_col, 2); + assert_eq!(bounds.end_col, 2); + } + + #[test] + fn resolve_bounds_falls_back_to_a1_for_empty_used_ranges() { + let mut sheet = Sheet::blank("Empty".to_string()); + sheet.max_rows = 0; + sheet.max_cols = 0; + let workbook = Workbook::from_sheets_for_test(vec![sheet]); + let sheet = workbook.get_sheet_by_index(0).unwrap(); + + let bounds = resolve_bounds(&workbook, sheet, 0, None).unwrap(); + + assert_eq!(bounds.start_row, 1); + assert_eq!(bounds.end_row, 1); + assert_eq!(bounds.start_col, 1); + assert_eq!(bounds.end_col, 1); + } + + #[test] + fn resolve_header_row_rejects_non_numeric_and_out_of_range_values() { + let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( + "Orders", + &[&["order_id", "customer"], &["1001", "Alice"]], + )]); + let sheet = workbook.get_sheet_by_index(0).unwrap(); + + let invalid = resolve_header_row(&workbook, sheet, 0, "header").unwrap_err(); + assert_eq!(invalid.code(), "invalid_query"); + + let out_of_range = resolve_header_row(&workbook, sheet, 0, "9").unwrap_err(); + assert_eq!(out_of_range.code(), "invalid_query"); + } + + #[test] + fn resolve_optional_header_row_preserves_lenient_record_resolution() { + let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( + "Orders", + &[&["order_id", "customer"], &["1001", "Alice"]], + )]); + let sheet = workbook.get_sheet_by_index(0).unwrap(); + + assert_eq!( + resolve_optional_header_row(&workbook, sheet, 0, "header").unwrap(), + None + ); + assert_eq!( + resolve_optional_header_row(&workbook, sheet, 0, "9").unwrap(), + None + ); + assert_eq!( + resolve_optional_header_row(&workbook, sheet, 0, "1").unwrap(), + Some(1) + ); + } + + #[test] + fn header_and_cell_helpers_preserve_existing_formula_semantics() { + let mut sheet = sheet_with_values("Orders", &[&["order_id", ""], &["1001", "Alice"]]); + sheet.data[1][2] = Cell { + value: "total".to_string(), + formula: Some("=UPPER(\"total\")".to_string()), + is_formula: false, + cell_type: CellType::Text, + original_type: None, + }; + sheet.data[2][2] = Cell { + value: String::new(), + formula: Some("=A2".to_string()), + is_formula: false, + cell_type: CellType::Text, + original_type: None, + }; + + assert_eq!(header_value(&sheet, 1, 1), "order_id"); + assert_eq!(header_value(&sheet, 1, 2), ""); + assert!(cell_has_formula(cell_at(&sheet, 1, 2).unwrap())); + assert!(cell_is_present(cell_at(&sheet, 2, 2))); + } + + #[test] + fn record_helpers_generate_stable_column_names_and_header_values() { + let sheet = sheet_with_values( + "Orders", + &[ + &["order_id", "customer", "customer", ""], + &["1001", "Alice", "VIP", "true"], + ], + ); + let bounds = SheetBounds { + start_row: 1, + end_row: 2, + start_col: 1, + end_col: 4, + }; + + let headers = read_header_values(&sheet, 1, bounds); + let columns = stable_record_keys(&headers, bounds.start_col); + + assert_eq!(headers, vec!["order_id", "customer", "customer", ""]); + assert_eq!(columns, vec!["order_id", "customer", "customer_2", "col_D"]); + } +} From 8cd2936d328cd3a4108da0a69a53b88fe82d7363 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Thu, 23 Apr 2026 01:37:24 +0800 Subject: [PATCH 05/13] refactor: move formula lookup into workbook layer --- src/cli/read.rs | 154 +-------------------------- src/excel/workbook.rs | 22 +++- src/excel/workbook/formula_lookup.rs | 151 ++++++++++++++++++++++++++ src/excel/workbook/tests.rs | 43 +++++++- 4 files changed, 217 insertions(+), 153 deletions(-) create mode 100644 src/excel/workbook/formula_lookup.rs diff --git a/src/cli/read.rs b/src/cli/read.rs index db136dd..c510bfe 100644 --- a/src/cli/read.rs +++ b/src/cli/read.rs @@ -1,11 +1,7 @@ use anyhow::Context; -use quick_xml::events::Event; use regex::Regex; use serde_json::{json, Value}; -use std::fs::File; -use std::io::{Read, Seek}; -use std::path::{Path, PathBuf}; -use zip::ZipArchive; +use std::path::PathBuf; use crate::cli::args::{OutputFormat, OutputShape, ReadCommands}; use crate::cli::envelope; @@ -136,10 +132,8 @@ fn read_cell( let in_bounds = row < sheet_obj.data.len() && col < sheet_obj.data[row].len(); let (value, cell_type, formula) = if in_bounds { let c = &sheet_obj.data[row][col]; - let formula = c - .formula - .clone() - .or_else(|| lookup_formula_in_xlsx(&file, &resolved_sheet.name, &cell_ref)); + let formula = + workbook.formula_for_cell(resolved_sheet.index, &resolved_sheet.name, &cell_ref); let type_str = if c.is_formula || formula.is_some() { "formula" } else { @@ -519,148 +513,6 @@ fn filter_matches(row: &[Value], filter: &FilterSpec) -> bool { } } -fn read_zip_entry(archive: &mut ZipArchive, entry_name: &str) -> Option { - let mut entry = archive.by_name(entry_name).ok()?; - let mut contents = String::new(); - entry.read_to_string(&mut contents).ok()?; - Some(contents) -} - -fn attr_value( - reader: &quick_xml::Reader<&[u8]>, - event: &quick_xml::events::BytesStart<'_>, - key: &[u8], -) -> Option { - for attr in event.attributes().flatten() { - if attr.key.as_ref() == key { - return attr - .decode_and_unescape_value(reader.decoder()) - .ok() - .map(|value| value.into_owned()); - } - } - None -} - -fn resolve_xlsx_sheet_path( - archive: &mut ZipArchive, - sheet_name: &str, -) -> Option { - let workbook_xml = read_zip_entry(archive, "xl/workbook.xml")?; - let mut workbook_reader = quick_xml::Reader::from_str(&workbook_xml); - workbook_reader.config_mut().trim_text(true); - let mut workbook_buf = Vec::new(); - let mut relationship_id = None; - - loop { - match workbook_reader.read_event_into(&mut workbook_buf).ok()? { - Event::Start(event) | Event::Empty(event) if event.name().as_ref() == b"sheet" => { - let name = attr_value(&workbook_reader, &event, b"name"); - if name.as_deref() == Some(sheet_name) { - relationship_id = attr_value(&workbook_reader, &event, b"r:id"); - break; - } - } - Event::Eof => break, - _ => {} - } - workbook_buf.clear(); - } - - let relationship_id = relationship_id?; - let rels_xml = read_zip_entry(archive, "xl/_rels/workbook.xml.rels")?; - let mut rels_reader = quick_xml::Reader::from_str(&rels_xml); - rels_reader.config_mut().trim_text(true); - let mut rels_buf = Vec::new(); - - loop { - match rels_reader.read_event_into(&mut rels_buf).ok()? { - Event::Start(event) | Event::Empty(event) - if event.name().as_ref() == b"Relationship" => - { - let id = attr_value(&rels_reader, &event, b"Id"); - if id.as_deref() == Some(relationship_id.as_str()) { - let target = attr_value(&rels_reader, &event, b"Target")?; - return Some(if target.starts_with('/') { - target.trim_start_matches('/').to_string() - } else { - format!("xl/{target}") - }); - } - } - Event::Eof => break, - _ => {} - } - rels_buf.clear(); - } - - None -} - -fn lookup_formula_in_xlsx(file: &Path, sheet_name: &str, cell_ref: &str) -> Option { - let extension = file - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase())?; - if extension != "xlsx" && extension != "xlsm" { - return None; - } - - let archive_file = File::open(file).ok()?; - let mut archive = ZipArchive::new(archive_file).ok()?; - let sheet_path = resolve_xlsx_sheet_path(&mut archive, sheet_name)?; - let sheet_xml = read_zip_entry(&mut archive, &sheet_path)?; - let target_ref = cell_ref.to_ascii_uppercase(); - - let mut reader = quick_xml::Reader::from_str(&sheet_xml); - reader.config_mut().trim_text(false); - let mut buf = Vec::new(); - let mut current_cell = None; - - loop { - match reader.read_event_into(&mut buf).ok()? { - Event::Start(event) if event.name().as_ref() == b"c" => { - current_cell = attr_value(&reader, &event, b"r") - .map(|reference| reference.to_ascii_uppercase()); - } - Event::End(event) if event.name().as_ref() == b"c" => { - current_cell = None; - } - Event::Start(event) if event.name().as_ref() == b"f" => { - let mut formula = String::new(); - let end_tag = event.name().as_ref().to_vec(); - let mut inner_buf = Vec::new(); - loop { - match reader.read_event_into(&mut inner_buf).ok()? { - Event::Text(text) => formula.push_str(&text.unescape().ok()?), - Event::End(end_event) - if end_event.name().as_ref() == end_tag.as_slice() => - { - break; - } - Event::Eof => return None, - _ => {} - } - inner_buf.clear(); - } - - if current_cell.as_deref() == Some(target_ref.as_str()) && !formula.is_empty() { - return Some(if formula.starts_with('=') { - formula - } else { - format!("={formula}") - }); - } - } - Event::Eof => break, - _ => {} - } - buf.clear(); - } - - None -} - fn read_range( file: std::path::PathBuf, sheet: Option, diff --git a/src/excel/workbook.rs b/src/excel/workbook.rs index 63267af..d84f30f 100644 --- a/src/excel/workbook.rs +++ b/src/excel/workbook.rs @@ -7,11 +7,13 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; use crate::excel::{Cell, CellType, Sheet}; -use crate::utils::index_to_col_name; +use crate::utils::{index_to_col_name, parse_cell_reference}; +mod formula_lookup; mod save; mod sheet_parse; +use formula_lookup::lookup_formula_in_xlsx; use sheet_parse::create_sheet_from_range; pub enum CalamineWorkbook { @@ -248,6 +250,24 @@ impl Workbook { self.sheets.iter().find(|s| s.name == name) } + pub(crate) fn formula_for_cell( + &self, + sheet_index: usize, + sheet_name: &str, + cell_ref: &str, + ) -> Option { + let (row, col) = parse_cell_reference(cell_ref)?; + let loaded_formula = self + .sheets + .get(sheet_index) + .and_then(|sheet| sheet.data.get(row)) + .and_then(|cells| cells.get(col)) + .and_then(|cell| cell.formula.clone()); + + loaded_formula + .or_else(|| lookup_formula_in_xlsx(Path::new(&self.file_path), sheet_name, cell_ref)) + } + /// Resolve a sheet specifier (name or 0-based index) to a sheet index. pub fn resolve_sheet(&self, spec: &str) -> Result { // Try parsing as 0-based index first diff --git a/src/excel/workbook/formula_lookup.rs b/src/excel/workbook/formula_lookup.rs new file mode 100644 index 0000000..0b35226 --- /dev/null +++ b/src/excel/workbook/formula_lookup.rs @@ -0,0 +1,151 @@ +use quick_xml::events::Event; +use std::fs::File; +use std::io::{Read, Seek}; +use std::path::Path; +use zip::ZipArchive; + +pub(super) fn lookup_formula_in_xlsx( + file: &Path, + sheet_name: &str, + cell_ref: &str, +) -> Option { + let extension = file + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase())?; + if extension != "xlsx" && extension != "xlsm" { + return None; + } + + let archive_file = File::open(file).ok()?; + let mut archive = ZipArchive::new(archive_file).ok()?; + let sheet_path = resolve_xlsx_sheet_path(&mut archive, sheet_name)?; + let sheet_xml = read_zip_entry(&mut archive, &sheet_path)?; + let target_ref = cell_ref.to_ascii_uppercase(); + + let mut reader = quick_xml::Reader::from_str(&sheet_xml); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + let mut current_cell = None; + + loop { + match reader.read_event_into(&mut buf).ok()? { + Event::Start(event) if event.name().as_ref() == b"c" => { + current_cell = attr_value(&reader, &event, b"r") + .map(|reference| reference.to_ascii_uppercase()); + } + Event::End(event) if event.name().as_ref() == b"c" => { + current_cell = None; + } + Event::Start(event) if event.name().as_ref() == b"f" => { + let mut formula = String::new(); + let end_tag = event.name().as_ref().to_vec(); + let mut inner_buf = Vec::new(); + loop { + match reader.read_event_into(&mut inner_buf).ok()? { + Event::Text(text) => formula.push_str(&text.unescape().ok()?), + Event::End(end_event) + if end_event.name().as_ref() == end_tag.as_slice() => + { + break; + } + Event::Eof => return None, + _ => {} + } + inner_buf.clear(); + } + + if current_cell.as_deref() == Some(target_ref.as_str()) && !formula.is_empty() { + return Some(if formula.starts_with('=') { + formula + } else { + format!("={formula}") + }); + } + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + + None +} + +fn read_zip_entry(archive: &mut ZipArchive, entry_name: &str) -> Option { + let mut entry = archive.by_name(entry_name).ok()?; + let mut contents = String::new(); + entry.read_to_string(&mut contents).ok()?; + Some(contents) +} + +fn attr_value( + reader: &quick_xml::Reader<&[u8]>, + event: &quick_xml::events::BytesStart<'_>, + key: &[u8], +) -> Option { + for attr in event.attributes().flatten() { + if attr.key.as_ref() == key { + return attr + .decode_and_unescape_value(reader.decoder()) + .ok() + .map(|value| value.into_owned()); + } + } + None +} + +fn resolve_xlsx_sheet_path( + archive: &mut ZipArchive, + sheet_name: &str, +) -> Option { + let workbook_xml = read_zip_entry(archive, "xl/workbook.xml")?; + let mut workbook_reader = quick_xml::Reader::from_str(&workbook_xml); + workbook_reader.config_mut().trim_text(true); + let mut workbook_buf = Vec::new(); + let mut relationship_id = None; + + loop { + match workbook_reader.read_event_into(&mut workbook_buf).ok()? { + Event::Start(event) | Event::Empty(event) if event.name().as_ref() == b"sheet" => { + let name = attr_value(&workbook_reader, &event, b"name"); + if name.as_deref() == Some(sheet_name) { + relationship_id = attr_value(&workbook_reader, &event, b"r:id"); + break; + } + } + Event::Eof => break, + _ => {} + } + workbook_buf.clear(); + } + + let relationship_id = relationship_id?; + let rels_xml = read_zip_entry(archive, "xl/_rels/workbook.xml.rels")?; + let mut rels_reader = quick_xml::Reader::from_str(&rels_xml); + rels_reader.config_mut().trim_text(true); + let mut rels_buf = Vec::new(); + + loop { + match rels_reader.read_event_into(&mut rels_buf).ok()? { + Event::Start(event) | Event::Empty(event) + if event.name().as_ref() == b"Relationship" => + { + let id = attr_value(&rels_reader, &event, b"Id"); + if id.as_deref() == Some(relationship_id.as_str()) { + let target = attr_value(&rels_reader, &event, b"Target")?; + return Some(if target.starts_with('/') { + target.trim_start_matches('/').to_string() + } else { + format!("xl/{target}") + }); + } + } + Event::Eof => break, + _ => {} + } + rels_buf.clear(); + } + + None +} diff --git a/src/excel/workbook/tests.rs b/src/excel/workbook/tests.rs index 6536253..3692933 100644 --- a/src/excel/workbook/tests.rs +++ b/src/excel/workbook/tests.rs @@ -1,10 +1,35 @@ -use super::Workbook; +use std::path::{Path, PathBuf}; + +use super::{open_workbook, Workbook}; use crate::excel::Sheet; fn blank_sheet(name: &str) -> Sheet { Sheet::blank(name.to_string()) } +fn temp_path(name: &str) -> PathBuf { + std::env::temp_dir().join(name) +} + +fn create_formula_workbook(path: &Path) { + use rust_xlsxwriter::Workbook as XlsxWorkbook; + + let mut workbook = XlsxWorkbook::new(); + let sheet = workbook.add_worksheet(); + sheet.set_name("TypedCells").unwrap(); + sheet.write_string(0, 0, "text_value").unwrap(); + sheet.write_string(0, 1, "number_value").unwrap(); + sheet.write_string(0, 2, "date_value").unwrap(); + sheet.write_string(0, 3, "boolean_value").unwrap(); + sheet.write_string(0, 4, "formula_value").unwrap(); + sheet.write_string(1, 0, "hello").unwrap(); + sheet.write_number(1, 1, 42.5).unwrap(); + sheet.write_boolean(1, 3, true).unwrap(); + sheet.write_formula(1, 4, "=B2*2").unwrap(); + sheet.set_formula_result(1, 4, "85"); + workbook.save(path).unwrap(); +} + #[test] fn adds_blank_sheet_after_current_sheet() { let mut workbook = @@ -95,3 +120,19 @@ fn empty_sheet_has_no_used_range() { let workbook = Workbook::from_sheets_for_test(vec![sheet]); assert_eq!(workbook.get_used_range(0).unwrap(), ""); } + +#[test] +fn formula_for_cell_falls_back_to_xlsx_archive_metadata() { + let path = temp_path("excel_cli_workbook_formula_lookup.xlsx"); + create_formula_workbook(&path); + + let workbook = open_workbook(&path, true).unwrap(); + let sheet_index = workbook.resolve_sheet_by_name("TypedCells").unwrap(); + + let sheet = workbook.get_sheet_by_index(sheet_index).unwrap(); + assert!(!sheet.is_loaded); + assert_eq!( + workbook.formula_for_cell(sheet_index, "TypedCells", "E2"), + Some("=B2*2".to_string()) + ); +} From 4cbf8a5656dcc3f57d9cf89f440371b87777f20b Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Thu, 23 Apr 2026 14:07:55 +0800 Subject: [PATCH 06/13] refactor(ui): split render module into focused submodules --- src/ui/render.rs | 1828 --------------------------------- src/ui/render/help_overlay.rs | 369 +++++++ src/ui/render/mod.rs | 336 ++++++ src/ui/render/spreadsheet.rs | 377 +++++++ src/ui/render/status.rs | 268 +++++ src/ui/render/tests.rs | 520 ++++++++++ 6 files changed, 1870 insertions(+), 1828 deletions(-) delete mode 100644 src/ui/render.rs create mode 100644 src/ui/render/help_overlay.rs create mode 100644 src/ui/render/mod.rs create mode 100644 src/ui/render/spreadsheet.rs create mode 100644 src/ui/render/status.rs create mode 100644 src/ui/render/tests.rs diff --git a/src/ui/render.rs b/src/ui/render.rs deleted file mode 100644 index 6aadc9b..0000000 --- a/src/ui/render.rs +++ /dev/null @@ -1,1828 +0,0 @@ -use anyhow::Result; -use crossterm::{ - event::{self, Event, KeyEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, -}; -use ratatui::{ - backend::CrosstermBackend, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}, - Frame, Terminal, -}; -use std::{io, time::Duration}; - -use crate::app::AppState; -use crate::app::HelpEntry; -use crate::app::HelpSection; -use crate::app::InputMode; -use crate::app::VimMode; -use crate::app::LEFT_HELP_SECTIONS; -use crate::app::RIGHT_HELP_SECTIONS; -use crate::ui::handlers::handle_key_event; -use crate::ui::theme; -use crate::utils::cell_reference; -use crate::utils::index_to_col_name; - -const HELP_ENTRY_INDENT: u16 = 2; -const HELP_ENTRY_GAP: u16 = 1; - -pub fn run_app(mut app_state: AppState) -> Result<()> { - // Setup terminal - let mut terminal = setup_terminal()?; - - // Main event loop - while !app_state.should_quit { - terminal.draw(|f| ui(f, &mut app_state))?; - - if event::poll(Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - handle_key_event(&mut app_state, key); - } - } - } - } - - // Restore terminal - restore_terminal(&mut terminal)?; - - Ok(()) -} - -/// Setup the terminal for the application -fn setup_terminal() -> Result>> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - stdout.execute(EnterAlternateScreen)?; - - let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; - - Ok(terminal) -} - -/// Restore the terminal to its original state -fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { - disable_raw_mode()?; - terminal.backend_mut().execute(LeaveAlternateScreen)?; - terminal.show_cursor()?; - - Ok(()) -} - -/// Update the visible area of the spreadsheet based on the available space -fn update_visible_area(app_state: &mut AppState, area: Rect) { - // Calculate visible rows based on available height (subtract header and borders) - app_state.visible_rows = (area.height as usize).saturating_sub(3); - - // Ensure the selected column is visible - app_state.ensure_column_visible(app_state.selected_cell.1); - - // Update row number width based on the maximum row number - app_state.update_row_number_width(); - - // Calculate available width for columns (subtract row numbers and borders) - let available_width = (area.width as usize).saturating_sub(app_state.row_number_width + 2); // row_number_width + 2 for borders - - // Calculate how many columns can fit in the available width - let mut visible_cols = 0; - let mut width_used = 0; - - // Iterate through columns starting from the leftmost visible column - for col_idx in app_state.start_col.. { - let col_width = app_state.get_column_width(col_idx); - - if col_idx == app_state.start_col { - // Always include the first column even if it's wider than available space - width_used += col_width; - visible_cols += 1; - - if width_used >= available_width { - break; - } - } else if width_used + col_width <= available_width { - // Add columns that fit completely - width_used += col_width; - visible_cols += 1; - } else if width_used < available_width { - // Excel-like behavior: include one partially visible column - visible_cols += 1; - break; - } else { - // No more space available - break; - } - } - - // Ensure at least one column is visible - app_state.visible_cols = visible_cols.max(1); -} - -fn ui(f: &mut Frame, app_state: &mut AppState) { - f.render_widget(Clear, f.size()); - let status_bar_height = status_bar_height(app_state, f.size().width); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(app_state.info_panel_height as u16), - Constraint::Length(status_bar_height), - ]) - .split(f.size()); - - draw_title_with_tabs(f, app_state, chunks[0]); - - update_visible_area(app_state, chunks[1]); - draw_spreadsheet(f, app_state, chunks[1]); - draw_info_panel(f, app_state, chunks[2]); - if status_bar_height > 0 { - draw_status_bar(f, app_state, chunks[3]); - } - - // If in help mode, draw the help popup over everything else - if let InputMode::Help = app_state.input_mode { - draw_help_popup(f, app_state, f.size()); - } - - // If in lazy loading mode or CommandInLazyLoading mode and the current sheet is not loaded, draw the lazy loading overlay - match app_state.input_mode { - InputMode::LazyLoading | InputMode::CommandInLazyLoading => { - let current_index = app_state.workbook.get_current_sheet_index(); - if !app_state.workbook.is_sheet_loaded(current_index) { - draw_lazy_loading_overlay(f, app_state, chunks[1]); - } else if matches!(app_state.input_mode, InputMode::LazyLoading) { - // If the sheet is loaded, switch back to Normal mode - app_state.input_mode = crate::app::InputMode::Normal; - } - } - _ => {} - } -} - -fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { - // Calculate visible row and column ranges - let start_row = app_state.start_row; - let end_row = start_row + app_state.visible_rows - 1; - let start_col = app_state.start_col; - let end_col = start_col + app_state.visible_cols - 1; - - let mut constraints = Vec::with_capacity(app_state.visible_cols + 1); - constraints.push(Constraint::Length(app_state.row_number_width as u16)); // Dynamic row header width - - for col in start_col..=end_col { - constraints.push(Constraint::Length(app_state.get_column_width(col) as u16)); - } - - // Set table style based on current mode - let is_editing = matches!(app_state.input_mode, InputMode::Editing); - let table_block = Block::default() - .style(theme::base()) - .borders(Borders::ALL) - .border_style(if is_editing { - Style::default().fg(theme::GRID) - } else { - Style::default().fg(theme::ACCENT) - }); - let header_style = if is_editing { - Style::default() - .bg(theme::SURFACE_MUTED) - .fg(theme::TEXT_DISABLED) - } else { - theme::muted() - }; - let cell_style = if is_editing { - Style::default() - .bg(theme::BACKGROUND) - .fg(theme::TEXT_DISABLED) - } else { - theme::base() - }; - // Create header row - let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1); - header_cells.push(Cell::from("").style(header_style)); - - // Add column headers - for col in start_col..=end_col { - let col_name = index_to_col_name(col); - header_cells.push(Cell::from(col_name).style(header_style)); - } - - let header = Row::new(header_cells).height(1); - - // Create data rows - let rows = (start_row..=end_row).map(|row| { - let mut cells = Vec::with_capacity(app_state.visible_cols + 1); - - // Add row header - cells.push(Cell::from(row.to_string()).style(header_style)); - - // Add cells for this row - for col in start_col..=end_col { - let content = if app_state.selected_cell == (row, col) - && matches!(app_state.input_mode, InputMode::Editing) - { - // Handle editing mode content - let current_content = app_state.text_area.lines().join("\n"); - let col_width = app_state.get_column_width(col); - - // Calculate display width - let display_width = current_content - .chars() - .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }); - - if display_width > col_width.saturating_sub(2) { - // Truncate content if it's too wide - let mut result = String::with_capacity(col_width); - let mut cumulative_width = 0; - - // Process characters from the end to show the most recent input - for c in current_content.chars().rev().take(col_width * 2) { - let char_width = if c.is_ascii() { 1 } else { 2 }; - if cumulative_width + char_width <= col_width.saturating_sub(2) { - cumulative_width += char_width; - result.push(c); - } else { - break; - } - } - - // Reverse the characters to get the correct order - result.chars().rev().collect::() - } else { - current_content - } - } else { - // Handle normal cell content - let content = app_state.get_cell_content(row, col); - let col_width = app_state.get_column_width(col); - - // Calculate display width - let display_width = content - .chars() - .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }); - - if display_width > col_width { - // Truncate content if it's too wide - let mut result = String::with_capacity(col_width); - let mut current_width = 0; - - for c in content.chars() { - let char_width = if c.is_ascii() { 1 } else { 2 }; - if current_width + char_width < col_width { - result.push(c); - current_width += char_width; - } else { - break; - } - } - - if !content.is_empty() && result.len() < content.len() { - result.push('…'); - } - - result - } else { - content - } - }; - - // Determine cell style - let style = if app_state.selected_cell == (row, col) { - Style::default().bg(Color::White).fg(Color::Black) - } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col)) - { - Style::default().bg(theme::SEARCH).fg(Color::Black) - } else { - cell_style - }; - - cells.push(Cell::from(content).style(style)); - } - - Row::new(cells) - }); - - // Create table with header and rows - let table = Table::new( - // Combine header and data rows - std::iter::once(header).chain(rows), - ) - .block(table_block) - .style(cell_style) - .widths(&constraints); - - f.render_widget(table, area); -} - -// Parse command input and identify keywords and parameters for highlighting -fn parse_command(input: &str) -> Vec> { - if input.is_empty() { - return vec![Span::raw("")]; - } - - let known_commands = [ - "w", - "wq", - "q", - "q!", - "x", - "y", - "d", - "put", - "pu", - "nohlsearch", - "noh", - "help", - "addsheet", - "delsheet", - ]; - - let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"]; - - let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"]; - - // Check if input is a simple command without parameters - if known_commands.contains(&input) { - return vec![Span::styled(input, Style::default().fg(theme::WARNING))]; - } - - // Extract command and parameters - let parts: Vec<&str> = input.split_whitespace().collect(); - if parts.is_empty() { - return vec![Span::raw(input)]; - } - - let cmd = parts[0]; - - // Check if it's a known command with parameters - if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) { - let mut spans = Vec::new(); - - spans.push(Span::styled(cmd, Style::default().fg(theme::WARNING))); - - // Add parameters if they exist - if parts.len() > 1 { - spans.push(Span::raw(" ")); - - for i in 1..parts.len() { - // Determine style based on whether it's a special keyword - let style = if special_keywords.contains(&parts[i]) { - Style::default().fg(theme::WARNING) - } else { - Style::default().fg(theme::ACCENT) - }; - - spans.push(Span::styled(parts[i], style)); - - // Add space between parameters - if i < parts.len() - 1 { - spans.push(Span::raw(" ")); - } - } - } - - return spans; - } - - // For cell references or unknown commands, return as is - vec![Span::raw(input)] -} - -fn display_width(text: &str) -> u16 { - text.chars() - .fold(0, |acc, ch| acc + if ch.is_ascii() { 1 } else { 2 }) -} - -fn status_bar_height(app_state: &AppState, width: u16) -> u16 { - let _ = width; - if matches!(app_state.input_mode, InputMode::Help) { - 0 - } else { - 1 - } -} - -fn status_bar_style() -> Style { - Style::default().bg(Color::Black).fg(theme::TEXT) -} - -fn status_badge(label: &'static str, color: Color) -> Span<'static> { - Span::styled( - format!(" {label} "), - Style::default() - .bg(color) - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - ) -} - -fn subtle_span(text: impl Into) -> Span<'static> { - Span::styled(text.into(), Style::default().fg(theme::TEXT_SECONDARY)) -} - -fn shortcut_key(key: &str) -> Span<'static> { - Span::styled( - format!("[{key}]"), - Style::default() - .bg(theme::SURFACE_MUTED) - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD), - ) -} - -fn shortcut_spans(entries: &[(&str, &str)]) -> Vec> { - let mut spans = Vec::new(); - - for (index, (key, label)) in entries.iter().enumerate() { - if index > 0 { - spans.push(Span::raw(" ")); - } - spans.push(shortcut_key(key)); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - (*label).to_string(), - Style::default().fg(theme::TEXT), - )); - } - - spans -} - -fn render_single_status_line<'a>( - f: &mut Frame, - area: Rect, - line: Line<'a>, - alignment: ratatui::layout::Alignment, -) { - let status_widget = Paragraph::new(line) - .style(status_bar_style()) - .alignment(alignment); - f.render_widget(status_widget, area); -} - -fn line_display_width(line: &Line<'_>) -> u16 { - line.spans - .iter() - .map(|span| display_width(&span.content)) - .sum() -} - -fn render_status_sections<'a, 'b>( - f: &mut Frame, - area: Rect, - left: Line<'a>, - right: Option>, -) { - let Some(right_line) = right else { - render_single_status_line(f, area, left, ratatui::layout::Alignment::Left); - return; - }; - - let right_width = line_display_width(&right_line).saturating_add(1); - if right_width >= area.width { - render_single_status_line(f, area, right_line, ratatui::layout::Alignment::Right); - return; - } - - let sections = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(area.width.saturating_sub(right_width)), - Constraint::Length(right_width), - ]) - .split(area); - - render_single_status_line(f, sections[0], left, ratatui::layout::Alignment::Left); - render_single_status_line( - f, - sections[1], - right_line, - ratatui::layout::Alignment::Right, - ); -} - -fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { - if area.height < 4 { - if matches!(app_state.input_mode, InputMode::Editing) { - draw_editing_panel(f, app_state, area); - } else { - draw_cell_details(f, app_state, area); - } - return; - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); - - if matches!(app_state.input_mode, InputMode::Editing) { - draw_editing_panel(f, app_state, chunks[0]); - } else { - draw_cell_details(f, app_state, chunks[0]); - } - draw_notifications(f, app_state, chunks[1]); -} - -fn draw_cell_details(f: &mut Frame, app_state: &AppState, area: Rect) { - let content = app_state.get_cell_content(app_state.selected_cell.0, app_state.selected_cell.1); - let cell_ref = cell_reference(app_state.selected_cell); - let value_type = cell_value_type(&content); - let length = content.chars().count(); - - let title = format!(" Cell {cell_ref} {value_type} Len {length} "); - let block = panel_block(title, theme::TEXT); - let paragraph = Paragraph::new(content) - .block(block) - .style(theme::surface()) - .wrap(ratatui::widgets::Wrap { trim: false }); - f.render_widget(paragraph, area); -} - -fn draw_editing_panel(f: &mut Frame, app_state: &AppState, area: Rect) { - let cell_ref = cell_reference(app_state.selected_cell); - let mode = app_state.vim_state.as_ref().map(|state| state.mode); - let input_block = panel_block_line(editing_title_line(cell_ref, mode), theme::ACCENT); - let inner_area = input_block.inner(area); - let padded_area = Rect { - x: inner_area.x.saturating_add(1), - y: inner_area.y, - width: inner_area.width.saturating_sub(2), - height: inner_area.height, - }; - - f.render_widget(input_block, area); - f.render_widget(app_state.text_area.widget(), padded_area); -} - -fn draw_notifications(f: &mut Frame, app_state: &AppState, area: Rect) { - let lines = if app_state.notification_messages.is_empty() { - vec![Line::from(Span::styled( - "No notifications", - Style::default().fg(theme::TEXT_SECONDARY), - ))] - } else { - app_state - .notification_messages - .iter() - .rev() - .take(4) - .enumerate() - .map(|(index, message)| { - let color = if index == 0 { - theme::TEXT - } else { - theme::TEXT_SECONDARY - }; - Line::from(Span::styled(message.clone(), Style::default().fg(color))) - }) - .collect() - }; - - let paragraph = Paragraph::new(lines) - .block(panel_block(" NOTIFICATIONS ".to_string(), theme::TEXT)) - .style(theme::surface()) - .wrap(ratatui::widgets::Wrap { trim: false }); - f.render_widget(paragraph, area); -} - -fn panel_block(title: String, border_color: Color) -> Block<'static> { - panel_block_line( - Line::from(Span::styled( - title, - Style::default() - .fg(theme::TEXT) - .add_modifier(Modifier::BOLD), - )), - border_color, - ) -} - -fn panel_block_line(title: Line<'static>, border_color: Color) -> Block<'static> { - Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::default().fg(border_color)) - .style(theme::surface()) -} - -fn editing_title_line(cell_ref: String, mode: Option) -> Line<'static> { - let mut spans = vec![ - Span::styled( - " Editing Cell ", - Style::default() - .fg(theme::TEXT) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - cell_ref, - Style::default() - .fg(theme::TEXT) - .add_modifier(Modifier::BOLD), - ), - ]; - - if let Some(mode) = mode { - spans.push(Span::styled( - " - ", - Style::default() - .fg(theme::TEXT) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - mode.to_string(), - Style::default() - .fg(vim_mode_color(mode)) - .add_modifier(Modifier::BOLD), - )); - } - - spans.push(Span::styled( - " ", - Style::default() - .fg(theme::TEXT) - .add_modifier(Modifier::BOLD), - )); - - Line::from(spans) -} - -fn vim_mode_color(mode: VimMode) -> Color { - match mode { - VimMode::Normal => theme::SUCCESS, - VimMode::Insert => theme::ACCENT, - VimMode::Visual => theme::SEARCH, - VimMode::Operator(_) => theme::WARNING, - } -} - -fn cell_value_type(content: &str) -> &'static str { - if content.is_empty() { - "Blank" - } else if content.starts_with("Formula: ") { - "Formula" - } else if content.parse::().is_ok() { - "Number" - } else { - "String" - } -} - -fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { - match app_state.input_mode { - InputMode::Normal => { - let left = Line::from(vec![status_badge("NORMAL", theme::ACCENT)]); - let right = Line::from(shortcut_spans(&[ - ("Enter", "Edit"), - (":", "Command"), - ("/", "Search"), - (":w", "Save"), - ])); - render_status_sections(f, area, left, Some(right)); - } - - InputMode::Editing => { - let left = Line::from(vec![status_badge("EDIT", theme::SUCCESS)]); - let right = Line::from(shortcut_spans(&[ - ("Enter", "Save"), - ("Esc", "Normal"), - ("i", "Insert"), - ("v", "Visual"), - ])); - render_status_sections(f, area, left, Some(right)); - } - - InputMode::Command | InputMode::CommandInLazyLoading => { - let mut left_spans = vec![ - status_badge("COMMAND", theme::WARNING), - Span::raw(" "), - Span::styled(":", Style::default().fg(theme::TEXT)), - ]; - left_spans.extend(parse_command(&app_state.input_buffer)); - let right = Line::from(shortcut_spans(&[ - ("Enter", "Run"), - ("Esc", "Cancel"), - ("A1", "Jump"), - ])); - render_status_sections(f, area, Line::from(left_spans), Some(right)); - } - - InputMode::SearchForward | InputMode::SearchBackward => { - let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) { - "/" - } else { - "?" - }; - let query = app_state.text_area.lines().join("\n"); - let left_spans = vec![ - status_badge("SEARCH", theme::SEARCH), - Span::raw(" "), - Span::styled(prefix.to_string(), Style::default().fg(theme::TEXT)), - Span::styled(query, Style::default().fg(theme::TEXT)), - ]; - let right = Line::from(shortcut_spans(&[ - ("Enter", "Apply"), - ("Esc", "Cancel"), - ("n/N", "Navigate"), - ])); - render_status_sections(f, area, Line::from(left_spans), Some(right)); - } - - InputMode::Help => { - // No status bar in help mode - } - - InputMode::LazyLoading => { - let left = Line::from(vec![ - status_badge("LAZY", theme::WARNING), - Span::raw(" "), - subtle_span("State "), - Span::styled("not loaded", Style::default().fg(theme::WARNING)), - ]); - let right = Line::from(shortcut_spans(&[ - ("Enter", "Load"), - ("[ ]", "Switch"), - (":", "Command"), - ])); - render_status_sections(f, area, left, Some(right)); - } - } -} - -fn sheet_rows_cols(app_state: &AppState) -> String { - let sheet = app_state.workbook.get_current_sheet(); - format!("{} x {}", sheet.max_rows, sheet.max_cols) -} - -fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) { - // Create a semi-transparent overlay - let overlay = Block::default() - .style(theme::surface()) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::ACCENT)); - - f.render_widget(Clear, area); - f.render_widget(overlay, area); - - // Calculate center position for the message - let message = "Sheet not loaded Enter load [ ] switch sheet : command"; - let width = message.len() as u16; - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + area.height / 2; - - if x < area.width && y < area.height { - let message_area = Rect { - x, - y, - width: width.min(area.width), - height: 1, - }; - - let message_widget = Paragraph::new(message).style( - Style::default() - .fg(theme::WARNING) - .add_modifier(Modifier::BOLD), - ); - - f.render_widget(message_widget, message_area); - } -} - -fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) { - let popup_area = help_popup_area(area); - let block = Block::default() - .title(" COMMAND HELP ") - .title_alignment(Alignment::Center) - .title_style( - Style::default() - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::TEXT_SECONDARY)) - .style(theme::surface()); - let inner = block.inner(popup_area); - - f.render_widget(Clear, area); - f.render_widget(Block::default().style(theme::base()), area); - f.render_widget(Clear, popup_area); - f.render_widget(block, popup_area); - - let Some((content_area, divider_area, footer_area)) = help_popup_inner_areas(inner) else { - return; - }; - - let lines = help_overlay_lines(content_area.width); - let visible_lines = content_area.height.max(1) as usize; - app_state.help_visible_lines = visible_lines; - app_state.help_total_lines = lines.len(); - let max_scroll = lines.len().saturating_sub(visible_lines); - app_state.help_scroll = app_state.help_scroll.min(max_scroll); - - let help_paragraph = Paragraph::new(lines) - .style(theme::surface()) - .scroll((app_state.help_scroll as u16, 0)); - f.render_widget(help_paragraph, content_area); - - let divider = Paragraph::new("-".repeat(inner.width as usize)).style(theme::surface()); - f.render_widget(divider, divider_area); - render_help_footer( - f, - footer_area, - app_state.help_scroll, - visible_lines, - max_scroll, - ); -} - -fn help_popup_area(area: Rect) -> Rect { - let popup_width = area.width.saturating_sub(4).clamp(48, 112); - let popup_height = area.height.saturating_sub(2).clamp(12, 32); - let popup_x = area.x + area.width.saturating_sub(popup_width) / 2; - let popup_y = area.y + area.height.saturating_sub(popup_height) / 2; - - Rect::new(popup_x, popup_y, popup_width, popup_height) -} - -fn help_popup_inner_areas(inner: Rect) -> Option<(Rect, Rect, Rect)> { - if inner.height < 4 || inner.width < 24 { - return None; - } - - let footer_height = 2; - let content_area = Rect { - x: inner.x.saturating_add(1), - y: inner.y, - width: inner.width.saturating_sub(2), - height: inner.height.saturating_sub(footer_height), - }; - let divider_area = Rect::new( - inner.x, - content_area.y + content_area.height, - inner.width, - 1, - ); - let footer_area = Rect::new(inner.x, divider_area.y + 1, inner.width, 1); - - Some((content_area, divider_area, footer_area)) -} - -fn render_help_footer( - f: &mut Frame, - area: Rect, - scroll: usize, - visible_lines: usize, - max_scroll: usize, -) { - let footer = help_footer_line(scroll, visible_lines, max_scroll); - let footer_widget = Paragraph::new(footer) - .style(theme::surface()) - .alignment(Alignment::Center); - f.render_widget(footer_widget, area); -} - -fn help_overlay_lines(width: u16) -> Vec> { - if width >= 82 { - two_column_help_lines(width) - } else { - one_column_help_lines(width) - } -} - -fn two_column_help_lines(width: u16) -> Vec> { - let gap = 4; - let column_width = width.saturating_sub(gap) / 2; - let left = help_column_lines(LEFT_HELP_SECTIONS, column_width); - let right = help_column_lines(RIGHT_HELP_SECTIONS, column_width); - let row_count = left.len().max(right.len()); - let mut rows = Vec::with_capacity(row_count); - - for index in 0..row_count { - let mut line = left.get(index).cloned().unwrap_or_else(Line::default); - pad_line(&mut line, column_width); - line.spans.push(Span::raw(" ".repeat(gap as usize))); - if let Some(right_line) = right.get(index) { - line.spans.extend(right_line.spans.clone()); - } - rows.push(line); - } - - rows -} - -fn one_column_help_lines(width: u16) -> Vec> { - let mut lines = help_column_lines(LEFT_HELP_SECTIONS, width); - lines.push(Line::default()); - lines.extend(help_column_lines(RIGHT_HELP_SECTIONS, width)); - lines -} - -fn help_column_lines(sections: &[HelpSection], width: u16) -> Vec> { - let mut lines = Vec::new(); - - for (index, section) in sections.iter().enumerate() { - if index > 0 { - lines.push(Line::default()); - } - lines.push(section_title_line(section.title)); - for entry in section.entries { - lines.extend(help_entry_lines(entry, width)); - } - } - - lines -} - -fn section_title_line(title: &'static str) -> Line<'static> { - Line::from(Span::styled( - title, - Style::default() - .fg(theme::WARNING) - .add_modifier(Modifier::BOLD), - )) -} - -fn help_entry_lines(entry: &HelpEntry, width: u16) -> Vec> { - let prefix = help_entry_prefix(entry.keys); - let prefix_width = spans_display_width(&prefix); - let description_width = width.saturating_sub(prefix_width + HELP_ENTRY_GAP).max(1); - let mut chunks = wrap_text(entry.description, description_width); - - if chunks.is_empty() { - return vec![Line::from(prefix)]; - } - - let first = chunks.remove(0); - let mut lines = vec![line_with_right_aligned_description(prefix, first, width)]; - for chunk in chunks { - lines.push(right_aligned_description_line(chunk, width)); - } - - lines -} - -fn help_entry_prefix(keys: &str) -> Vec> { - let mut spans = vec![Span::raw(" ".repeat(HELP_ENTRY_INDENT as usize))]; - - for (index, chip) in key_chips(keys).into_iter().enumerate() { - if index > 0 { - spans.push(Span::styled("/", Style::default().fg(theme::TEXT_DISABLED))); - } - spans.extend(key_chip_spans(chip)); - } - - spans -} - -fn key_chip_spans(label: String) -> Vec> { - vec![Span::styled( - format!(" {label} "), - Style::default() - .bg(theme::SURFACE_MUTED) - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD), - )] -} - -fn key_chips(keys: &str) -> Vec { - keys.split(" / ") - .flat_map(|group| { - let group = group.trim(); - if should_split_shortcut_group(group) { - group.split_whitespace().map(str::to_string).collect() - } else { - vec![group.to_string()] - } - }) - .collect() -} - -fn spans_display_width(spans: &[Span<'_>]) -> u16 { - spans.iter().map(|span| display_width(&span.content)).sum() -} - -fn line_with_right_aligned_description( - mut spans: Vec>, - description: String, - width: u16, -) -> Line<'static> { - let prefix_width = spans_display_width(&spans); - let description_width = display_width(&description); - let gap = width.saturating_sub(prefix_width + description_width); - - spans.push(Span::raw(" ".repeat(gap as usize))); - spans.push(description_span(description)); - - Line::from(spans) -} - -fn right_aligned_description_line(description: String, width: u16) -> Line<'static> { - let description_width = display_width(&description); - let gap = width.saturating_sub(description_width); - - Line::from(vec![ - Span::raw(" ".repeat(gap as usize)), - description_span(description), - ]) -} - -fn description_span(description: String) -> Span<'static> { - Span::styled(description, Style::default().fg(theme::TEXT_SECONDARY)) -} - -fn wrap_text(text: &str, width: u16) -> Vec { - if width == 0 { - return Vec::new(); - } - - let mut lines = Vec::new(); - let mut current = String::new(); - - for word in text.split_whitespace() { - append_wrapped_word(&mut lines, &mut current, word, width); - } - - if !current.is_empty() { - lines.push(current); - } - - lines -} - -fn append_wrapped_word(lines: &mut Vec, current: &mut String, word: &str, width: u16) { - let word_width = display_width(word); - let current_width = display_width(current); - - if current.is_empty() && word_width <= width { - current.push_str(word); - } else if !current.is_empty() && current_width + 1 + word_width <= width { - current.push(' '); - current.push_str(word); - } else { - if !current.is_empty() { - lines.push(std::mem::take(current)); - } - append_word_chunks(lines, current, word, width); - } -} - -fn append_word_chunks(lines: &mut Vec, current: &mut String, word: &str, width: u16) { - if display_width(word) <= width { - current.push_str(word); - return; - } - - for chunk in split_word_to_width(word, width) { - if current.is_empty() { - current.push_str(&chunk); - } else { - lines.push(std::mem::take(current)); - current.push_str(&chunk); - } - } -} - -fn split_word_to_width(word: &str, width: u16) -> Vec { - let mut chunks = Vec::new(); - let mut current = String::new(); - let mut used = 0; - - for ch in word.chars() { - let char_width = if ch.is_ascii() { 1 } else { 2 }; - if used + char_width > width && !current.is_empty() { - chunks.push(std::mem::take(&mut current)); - used = 0; - } - current.push(ch); - used += char_width; - } - - if !current.is_empty() { - chunks.push(current); - } - - chunks -} - -fn should_split_shortcut_group(group: &str) -> bool { - let parts: Vec<&str> = group.split_whitespace().collect(); - - parts.len() > 1 - && parts.iter().all(|part| { - part.chars().count() == 1 && part.chars().all(|ch| ch.is_ascii_alphabetic()) - }) -} - -fn pad_line(line: &mut Line<'static>, width: u16) { - let line_width = line_display_width(line); - if line_width < width { - line.spans - .push(Span::raw(" ".repeat((width - line_width) as usize))); - } -} - -fn help_footer_line(scroll: usize, visible_lines: usize, max_scroll: usize) -> Line<'static> { - let total_pages = if max_scroll == 0 { - 1 - } else { - (max_scroll + visible_lines) / visible_lines - }; - let current_page = (scroll / visible_lines).saturating_add(1).min(total_pages); - - Line::from(vec![ - Span::styled("Press ESC or q to close", Style::default().fg(theme::TEXT)), - Span::styled( - " | j/k scroll | ", - Style::default().fg(theme::TEXT_SECONDARY), - ), - Span::styled( - format!("Page {current_page}/{total_pages}"), - Style::default().fg(theme::ACCENT), - ), - ]) -} - -fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { - let is_editing = matches!(app_state.input_mode, InputMode::Editing); - let sheet_names = app_state.workbook.get_sheet_names(); - let current_index = app_state.workbook.get_current_sheet_index(); - - let file_name = app_state - .file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("Untitled"); - - let brand_content = " EXCEL-CLI "; - let title_content = format!(" {file_name} "); - - let brand_width = display_width(brand_content); - let title_width = display_width(&title_content); - let max_title_width = (area.width / 3).min(title_width); - - let mut tab_widths = Vec::new(); - let mut total_width = 0; - let mut visible_tabs = Vec::new(); - - let horizontal_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(brand_width), - Constraint::Length(max_title_width), - Constraint::Min(0), - ]) - .split(area); - - let title_style = if is_editing { - Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) - } else { - Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY) - }; - let brand_style = Style::default() - .bg(Color::Black) - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD); - - let brand_widget = Paragraph::new(brand_content).style(brand_style); - let title_widget = Paragraph::new(title_content).style(title_style); - - f.render_widget(brand_widget, horizontal_layout[0]); - f.render_widget(title_widget, horizontal_layout[1]); - - let tabs_area = horizontal_layout[2]; - let rows_cols = sheet_rows_cols(app_state); - let rows_cols_plain = format!("Rows/Cols: {rows_cols}"); - let base_rows_width = display_width(&rows_cols_plain); - let total_tab_width: u16 = sheet_names.iter().map(|name| display_width(name)).sum(); - let visible_tabs_width = tabs_area.width.saturating_sub(base_rows_width); - let tabs_overflow = total_tab_width > visible_tabs_width; - let rows_cols_plain = if tabs_overflow { - format!("... {rows_cols_plain}") - } else { - rows_cols_plain - }; - let rows_cols_width = display_width(&rows_cols_plain); - let available_width = tabs_area.width as usize; - - for (i, name) in sheet_names.iter().enumerate() { - let tab_width = display_width(name) as usize; - - if total_width + tab_width <= available_width { - tab_widths.push(tab_width as u16); - total_width += tab_width; - visible_tabs.push(i); - } else { - if !visible_tabs.contains(¤t_index) { - while !visible_tabs.is_empty() && total_width + tab_width > available_width { - let removed_width = tab_widths.remove(0) as usize; - visible_tabs.remove(0); - total_width -= removed_width; - } - - if total_width + tab_width <= available_width { - tab_widths.push(tab_width as u16); - visible_tabs.push(current_index); - } - } - break; - } - } - - // Create constraints for tab layout - let mut tab_constraints = Vec::new(); - for &width in &tab_widths { - tab_constraints.push(Constraint::Length(width)); - } - tab_constraints.push(Constraint::Min(0)); // Filler space - - let tab_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints(tab_constraints) - .split(tabs_area); - - // Render each visible tab - for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() { - if layout_idx >= tab_layout.len() - 1 { - break; - } - - let name = &sheet_names[sheet_idx]; - let is_current = sheet_idx == current_index; - - let style = if is_editing { - Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) - } else if is_current { - Style::default() - .bg(Color::Black) - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD) - } else { - Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY) - }; - - let tab_widget = Paragraph::new(name.to_string()) - .style(style) - .alignment(ratatui::layout::Alignment::Center); - - f.render_widget(tab_widget, tab_layout[layout_idx]); - } - - let rows_cols_rect = Rect { - x: tabs_area.x - + tabs_area - .width - .saturating_sub(rows_cols_width.min(tabs_area.width)), - y: tabs_area.y, - width: rows_cols_width.min(tabs_area.width), - height: 1, - }; - let mut rows_cols_spans = Vec::new(); - if tabs_overflow { - rows_cols_spans.push(Span::styled( - "... ", - Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY), - )); - } - rows_cols_spans.push(Span::styled( - "Rows/Cols: ", - Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY), - )); - rows_cols_spans.push(Span::styled( - rows_cols, - Style::default().bg(Color::Black).fg(theme::ACCENT), - )); - - let rows_cols_widget = Paragraph::new(Line::from(rows_cols_spans)) - .style(Style::default().bg(Color::Black)) - .alignment(ratatui::layout::Alignment::Right); - f.render_widget(rows_cols_widget, rows_cols_rect); -} - -#[cfg(test)] -mod tests { - use ratatui::{backend::TestBackend, style::Color, Terminal}; - use std::path::PathBuf; - - use super::{theme, ui}; - use crate::app::{AppState, HelpEntry, InputMode}; - use crate::excel::{Cell, Sheet, Workbook}; - - fn app_with_sheet() -> AppState<'static> { - let mut data = vec![vec![Cell::empty(); 3]; 3]; - data[1][1] = Cell::new("Name".to_string(), false); - data[1][2] = Cell::new("Name".to_string(), false); - data[2][1] = Cell::new("Ada".to_string(), false); - data[2][2] = Cell::new("10".to_string(), false); - - let sheet = Sheet { - name: "Data".to_string(), - data, - max_rows: 2, - max_cols: 2, - is_loaded: true, - }; - let app = AppState::new( - Workbook::from_sheets_for_test(vec![sheet]), - PathBuf::from("scores.xlsx"), - ) - .unwrap(); - app - } - - fn app_with_many_sheets() -> AppState<'static> { - let make_sheet = |name: &str| Sheet { - name: name.to_string(), - data: vec![vec![Cell::empty(); 2]; 2], - max_rows: 1, - max_cols: 1, - is_loaded: true, - }; - - AppState::new( - Workbook::from_sheets_for_test(vec![ - make_sheet("Alpha"), - make_sheet("Beta"), - make_sheet("Gamma"), - make_sheet("Delta"), - make_sheet("Epsilon"), - make_sheet("Zeta"), - ]), - PathBuf::from("many.xlsx"), - ) - .unwrap() - } - - fn rendered_lines(terminal: &Terminal) -> Vec { - let buffer = terminal.backend().buffer(); - let width = buffer.area.width as usize; - - buffer - .content - .chunks(width) - .map(|row| row.iter().map(|cell| cell.symbol.as_str()).collect()) - .collect() - } - - fn text_fg_at(terminal: &Terminal, needle: &str) -> Color { - let lines = rendered_lines(terminal); - let row = line_index(&lines, needle); - let col = lines[row] - .find(needle) - .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")); - let offset = needle - .chars() - .position(|ch| !ch.is_whitespace()) - .unwrap_or(0); - let buffer = terminal.backend().buffer(); - let width = buffer.area.width as usize; - buffer.content[row * width + col + offset].fg - } - - fn fg_at(terminal: &Terminal, row: usize, col: usize) -> Color { - let buffer = terminal.backend().buffer(); - let width = buffer.area.width as usize; - buffer.content[row * width + col].fg - } - - fn bg_at(terminal: &Terminal, row: usize, col: usize) -> Color { - let buffer = terminal.backend().buffer(); - let width = buffer.area.width as usize; - buffer.content[row * width + col].bg - } - - fn symbol_at(terminal: &Terminal, row: usize, col: usize) -> String { - let buffer = terminal.backend().buffer(); - let width = buffer.area.width as usize; - buffer.content[row * width + col].symbol.clone() - } - - fn line_index(lines: &[String], needle: &str) -> usize { - lines - .iter() - .position(|line| line.contains(needle)) - .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")) - } - - fn help_overlay_text(width: u16) -> String { - super::help_overlay_lines(width) - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n") - } - - #[test] - fn renders_help_overlay_as_structured_command_reference() { - let backend = TestBackend::new(140, 40); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - app.show_help(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let rendered = rendered_lines(&terminal).join("\n"); - - assert!(matches!(app.input_mode, InputMode::Help)); - assert!(rendered.contains("COMMAND HELP")); - assert!(rendered.contains("NAVIGATION")); - assert!(rendered.contains("ACTIONS")); - assert!(rendered.contains("SEARCH")); - assert!(rendered.contains("FILE & APP")); - assert!(rendered.contains("JUMP & SHEETS")); - assert!(rendered.contains("Press ESC or q to close")); - assert!(rendered.contains("Page ")); - assert!(!rendered.contains("preview")); - assert!(!rendered.contains("findings")); - } - - #[test] - fn help_overlay_uses_solid_backdrop_to_hide_underlying_sheet() { - let backend = TestBackend::new(140, 40); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - app.show_help(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - assert_eq!(symbol_at(&terminal, 0, 0), " "); - assert_eq!(bg_at(&terminal, 0, 0), theme::BACKGROUND); - } - - #[test] - fn help_entries_render_grouped_shortcuts_as_individual_chips() { - let entry = HelpEntry { - keys: "h j k l / arrows", - description: "Move cell", - }; - - let line_text = super::help_entry_lines(&entry, 60)[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - - assert!(line_text.contains(" h ")); - assert!(line_text.contains(" j ")); - assert!(line_text.contains(" k ")); - assert!(line_text.contains(" l ")); - assert!(line_text.contains(" arrows ")); - assert!(line_text.contains(" h / j / k / l / arrows ")); - assert!(!line_text.contains(" / ")); - assert!(!line_text.contains("")); - assert!(!line_text.contains("")); - assert!(!line_text.contains("‹")); - assert!(!line_text.contains("›")); - assert!(!line_text.contains(" h j k l ")); - } - - #[test] - fn help_entry_descriptions_align_to_the_right_edge() { - let short_entry = HelpEntry { - keys: "h", - description: "Move cell", - }; - let long_entry = HelpEntry { - keys: "Ctrl+arrows", - description: "Jump to next non-empty cell", - }; - - let short_line = super::help_entry_lines(&short_entry, 42)[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - let long_line = super::help_entry_lines(&long_entry, 42)[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - - assert_eq!(super::display_width(&short_line), 42); - assert_eq!(super::display_width(&long_line), 42); - assert!(short_line.ends_with("Move cell")); - assert!(long_line.ends_with("Jump to next non-empty")); - } - - #[test] - fn help_entry_keeps_description_on_first_line_for_long_shortcut_groups() { - let entry = HelpEntry { - keys: ":noh / :nohlsearch", - description: "Disable search highlighting", - }; - - let rendered = super::help_entry_lines(&entry, 44) - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>(); - - assert!(rendered[0].contains(":noh")); - assert!(rendered[0].contains(":nohlsearch")); - assert!(rendered[0].contains("Disable search")); - assert_eq!(super::display_width(&rendered[0]), 44); - assert!(rendered[0].ends_with("Disable search")); - assert_eq!(super::display_width(&rendered[1]), 44); - assert!(rendered[1].ends_with("highlighting")); - } - - #[test] - fn help_entry_descriptions_wrap_right_aligned_inside_column_width() { - let entry = HelpEntry { - keys: ":sheet ", - description: "Switch sheet by exact name or one based index", - }; - - let lines = super::help_entry_lines(&entry, 34); - let rendered = lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>(); - - let normalized = rendered - .join(" ") - .split_whitespace() - .collect::>() - .join(" "); - - assert!(rendered.len() > 1); - assert!(rendered.iter().all(|line| super::display_width(line) <= 34)); - assert!(normalized.contains("one based index")); - assert!(rendered.iter().all(|line| { - super::display_width(line) == 34 || !line.contains(|ch: char| ch.is_alphabetic()) - })); - } - - #[test] - fn help_overlay_model_lists_complete_command_reference() { - let help_text = help_overlay_text(112); - - for required in [ - ":cw fit all", - ":dr ", - ":dc ", - ":ej ", - ":eja ", - "EDIT MODE", - "HELP CONTROLS", - ] { - assert!( - help_text.contains(required), - "expected help overlay to contain {required}" - ); - } - - assert!(!help_text.contains("preview")); - assert!(!help_text.contains("findings")); - } - - #[test] - fn renders_help_overlay_later_command_sections_when_scrolled() { - let backend = TestBackend::new(120, 24); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - app.show_help(); - app.help_scroll = 17; - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let mid_page = rendered_lines(&terminal).join("\n"); - - assert!(mid_page.contains("ROWS & COLUMNS")); - assert!(mid_page.contains(":cw fit all")); - assert!(mid_page.contains(":dr ")); - assert!(mid_page.contains(":dc ")); - } - - #[test] - fn renders_visual_refresh_shell_with_inspector_and_short_status() { - let backend = TestBackend::new(140, 32); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let rendered = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol.as_str()) - .collect::(); - - assert!(matches!(app.input_mode, InputMode::Normal)); - assert!(rendered.contains("EXCEL-CLI")); - assert!(rendered.contains("Cell A1")); - assert!(rendered.contains("NOTIFICATIONS")); - assert!(rendered.contains("NORMAL")); - assert!(rendered.contains("[:w] Save")); - assert!(!rendered.contains("INSPECTOR")); - assert!(!rendered.contains("Run Diagnostics")); - assert!(!rendered.contains("Settings")); - assert!(!rendered.contains("Execute Script")); - assert!(!rendered.contains("Findings")); - assert!(!rendered.contains("Columns")); - assert!(!rendered.contains("Preview")); - } - - #[test] - fn renders_normal_mode_status_bar_as_single_row_on_wide_layout() { - let backend = TestBackend::new(140, 32); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let lines = rendered_lines(&terminal); - let status_row = &lines[lines.len() - 1]; - let title_row = &lines[0]; - - assert!(status_row.contains(" NORMAL ")); - assert!(status_row.contains("[Enter] Edit")); - assert!(status_row.contains("[/] Search")); - assert!(status_row.contains("[:w] Save")); - assert!(status_row.trim_end().ends_with("[:w] Save")); - assert!(!status_row.contains("Rows/Cols")); - assert!(!status_row.contains("Findings")); - assert!(!status_row.contains("Columns")); - assert!(!status_row.contains("Preview")); - assert!(title_row.contains("Rows/Cols: 2 x 2")); - assert!(title_row.trim_end().ends_with("Rows/Cols: 2 x 2")); - } - - #[test] - fn renders_cell_panel_above_notifications_in_vertical_info_layout() { - let backend = TestBackend::new(140, 32); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let lines = rendered_lines(&terminal); - let cell_row = line_index(&lines, "Cell A1"); - let notifications_row = line_index(&lines, " NOTIFICATIONS "); - - assert!(cell_row < notifications_row); - } - - #[test] - fn does_not_render_analysis_tabs_or_inspector_shell() { - let backend = TestBackend::new(140, 32); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let lines = rendered_lines(&terminal); - let full_text = lines.join("\n"); - - assert!(!full_text.contains("INSPECTOR")); - assert!(!full_text.contains("Analysis Panel")); - assert!(!full_text.contains(" Details Preview Findings Columns ")); - assert!(!full_text.contains("Query Preview")); - assert!(!full_text.contains("FINDINGS")); - assert!(!full_text.contains("COLUMNS PROFILE")); - } - - #[test] - fn renders_cell_details_with_dynamic_title_and_compact_fields() { - let backend = TestBackend::new(140, 40); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - app.selected_cell = (2, 2); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let rendered = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol.as_str()) - .collect::(); - - assert!(matches!(app.input_mode, InputMode::Normal)); - assert!(rendered.contains("Cell B2 Number Len 2")); - assert!(rendered.contains("10")); - assert!(!rendered.contains("Type: Number")); - assert!(!rendered.contains("Length: 2")); - assert!(!rendered.contains("Content: 10")); - assert!(rendered.contains("NOTIFICATIONS")); - assert!(!rendered.contains("SHEET CONTEXT")); - assert!(!rendered.contains("QUALITY")); - assert!(!rendered.contains("No findings for active cell")); - } - - #[test] - fn renders_notifications_panel_when_inspector_moves_below_table() { - let backend = TestBackend::new(90, 28); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - app.add_notification("Loaded 2 findings".to_string()); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let rendered = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol.as_str()) - .collect::(); - - assert!(rendered.contains("Cell A1")); - assert!(rendered.contains("Loaded 2 findings")); - assert!(rendered.contains("NOTIFICATIONS")); - } - - #[test] - fn renders_editing_panel_with_vim_mode_in_title_and_without_status_mode() { - let backend = TestBackend::new(140, 40); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - app.start_editing(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let lines = rendered_lines(&terminal); - let full_text = lines.join("\n"); - let status_row = &lines[lines.len() - 1]; - let title_row = &lines[0]; - - assert!(full_text.contains("Editing Cell A1")); - assert!(full_text.contains("NORMAL")); - assert!(!full_text.contains("TARGET CELL")); - assert!(!full_text.contains("INPUT BUFFER [EDITING]")); - assert_eq!( - fg_at(&terminal, line_index(&lines, " Editing Cell A1 "), 0), - theme::ACCENT - ); - assert_eq!(text_fg_at(&terminal, "NORMAL"), theme::SUCCESS); - assert!(status_row.contains(" EDIT ")); - assert!(status_row.contains("[Enter] Save")); - assert!(status_row.trim_end().ends_with("[v] Visual")); - assert!(!status_row.contains("Rows/Cols")); - assert!(!status_row.contains("Mode ")); - assert!(title_row.contains("Rows/Cols: 2 x 2")); - } - - #[test] - fn removed_analysis_modes_do_not_appear_in_rendered_ui() { - let backend = TestBackend::new(140, 32); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_sheet(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let rendered = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol.as_str()) - .collect::(); - - assert!(rendered.contains("Cell A1")); - assert!(rendered.contains("NOTIFICATIONS")); - assert!(!rendered.contains("Findings")); - assert!(!rendered.contains("Preview")); - assert!(!rendered.contains("Columns")); - assert!(!rendered.contains("COLUMNS PROFILE")); - assert!(!rendered.contains("SHEET PROFILE")); - } - - #[test] - fn renders_rows_cols_in_top_right_with_overflow_hint_when_tabs_exceed_space() { - let backend = TestBackend::new(60, 20); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_many_sheets(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let lines = rendered_lines(&terminal); - let title_row = &lines[0]; - - assert!(title_row.contains("Rows/Cols: 1 x 1")); - assert!(title_row.trim_end().ends_with("... Rows/Cols: 1 x 1")); - assert!(title_row.contains("Alpha")); - assert!(!title_row.contains("Zeta")); - } -} diff --git a/src/ui/render/help_overlay.rs b/src/ui/render/help_overlay.rs new file mode 100644 index 0000000..bd4536d --- /dev/null +++ b/src/ui/render/help_overlay.rs @@ -0,0 +1,369 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::app::{AppState, HelpEntry, HelpSection, LEFT_HELP_SECTIONS, RIGHT_HELP_SECTIONS}; +use crate::ui::theme; + +use super::{display_width, line_display_width}; + +const HELP_ENTRY_INDENT: u16 = 2; +const HELP_ENTRY_GAP: u16 = 1; + +pub(super) fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) { + let popup_area = help_popup_area(area); + let block = Block::default() + .title(" COMMAND HELP ") + .title_alignment(Alignment::Center) + .title_style( + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::TEXT_SECONDARY)) + .style(theme::surface()); + let inner = block.inner(popup_area); + + f.render_widget(Clear, area); + f.render_widget(Block::default().style(theme::base()), area); + f.render_widget(Clear, popup_area); + f.render_widget(block, popup_area); + + let Some((content_area, divider_area, footer_area)) = help_popup_inner_areas(inner) else { + return; + }; + + let lines = help_overlay_lines(content_area.width); + let visible_lines = content_area.height.max(1) as usize; + app_state.help_visible_lines = visible_lines; + app_state.help_total_lines = lines.len(); + let max_scroll = lines.len().saturating_sub(visible_lines); + app_state.help_scroll = app_state.help_scroll.min(max_scroll); + + let help_paragraph = Paragraph::new(lines) + .style(theme::surface()) + .scroll((app_state.help_scroll as u16, 0)); + f.render_widget(help_paragraph, content_area); + + let divider = Paragraph::new("-".repeat(inner.width as usize)).style(theme::surface()); + f.render_widget(divider, divider_area); + render_help_footer( + f, + footer_area, + app_state.help_scroll, + visible_lines, + max_scroll, + ); +} + +fn help_popup_area(area: Rect) -> Rect { + let popup_width = area.width.saturating_sub(4).clamp(48, 112); + let popup_height = area.height.saturating_sub(2).clamp(12, 32); + let popup_x = area.x + area.width.saturating_sub(popup_width) / 2; + let popup_y = area.y + area.height.saturating_sub(popup_height) / 2; + + Rect::new(popup_x, popup_y, popup_width, popup_height) +} + +fn help_popup_inner_areas(inner: Rect) -> Option<(Rect, Rect, Rect)> { + if inner.height < 4 || inner.width < 24 { + return None; + } + + let footer_height = 2; + let content_area = Rect { + x: inner.x.saturating_add(1), + y: inner.y, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(footer_height), + }; + let divider_area = Rect::new( + inner.x, + content_area.y + content_area.height, + inner.width, + 1, + ); + let footer_area = Rect::new(inner.x, divider_area.y + 1, inner.width, 1); + + Some((content_area, divider_area, footer_area)) +} + +fn render_help_footer( + f: &mut Frame, + area: Rect, + scroll: usize, + visible_lines: usize, + max_scroll: usize, +) { + let footer = help_footer_line(scroll, visible_lines, max_scroll); + let footer_widget = Paragraph::new(footer) + .style(theme::surface()) + .alignment(Alignment::Center); + f.render_widget(footer_widget, area); +} + +pub(super) fn help_overlay_lines(width: u16) -> Vec> { + if width >= 82 { + two_column_help_lines(width) + } else { + one_column_help_lines(width) + } +} + +fn two_column_help_lines(width: u16) -> Vec> { + let gap = 4; + let column_width = width.saturating_sub(gap) / 2; + let left = help_column_lines(LEFT_HELP_SECTIONS, column_width); + let right = help_column_lines(RIGHT_HELP_SECTIONS, column_width); + let row_count = left.len().max(right.len()); + let mut rows = Vec::with_capacity(row_count); + + for index in 0..row_count { + let mut line = left.get(index).cloned().unwrap_or_else(Line::default); + pad_line(&mut line, column_width); + line.spans.push(Span::raw(" ".repeat(gap as usize))); + if let Some(right_line) = right.get(index) { + line.spans.extend(right_line.spans.clone()); + } + rows.push(line); + } + + rows +} + +fn one_column_help_lines(width: u16) -> Vec> { + let mut lines = help_column_lines(LEFT_HELP_SECTIONS, width); + lines.push(Line::default()); + lines.extend(help_column_lines(RIGHT_HELP_SECTIONS, width)); + lines +} + +fn help_column_lines(sections: &[HelpSection], width: u16) -> Vec> { + let mut lines = Vec::new(); + + for (index, section) in sections.iter().enumerate() { + if index > 0 { + lines.push(Line::default()); + } + lines.push(section_title_line(section.title)); + for entry in section.entries { + lines.extend(help_entry_lines(entry, width)); + } + } + + lines +} + +fn section_title_line(title: &'static str) -> Line<'static> { + Line::from(Span::styled( + title, + Style::default() + .fg(theme::WARNING) + .add_modifier(Modifier::BOLD), + )) +} + +pub(super) fn help_entry_lines(entry: &HelpEntry, width: u16) -> Vec> { + let prefix = help_entry_prefix(entry.keys); + let prefix_width = spans_display_width(&prefix); + let description_width = width.saturating_sub(prefix_width + HELP_ENTRY_GAP).max(1); + let mut chunks = wrap_text(entry.description, description_width); + + if chunks.is_empty() { + return vec![Line::from(prefix)]; + } + + let first = chunks.remove(0); + let mut lines = vec![line_with_right_aligned_description(prefix, first, width)]; + for chunk in chunks { + lines.push(right_aligned_description_line(chunk, width)); + } + + lines +} + +fn help_entry_prefix(keys: &str) -> Vec> { + let mut spans = vec![Span::raw(" ".repeat(HELP_ENTRY_INDENT as usize))]; + + for (index, chip) in key_chips(keys).into_iter().enumerate() { + if index > 0 { + spans.push(Span::styled("/", Style::default().fg(theme::TEXT_DISABLED))); + } + spans.extend(key_chip_spans(chip)); + } + + spans +} + +fn key_chip_spans(label: String) -> Vec> { + vec![Span::styled( + format!(" {label} "), + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + )] +} + +fn key_chips(keys: &str) -> Vec { + keys.split(" / ") + .flat_map(|group| { + let group = group.trim(); + if should_split_shortcut_group(group) { + group.split_whitespace().map(str::to_string).collect() + } else { + vec![group.to_string()] + } + }) + .collect() +} + +fn spans_display_width(spans: &[Span<'_>]) -> u16 { + spans.iter().map(|span| display_width(&span.content)).sum() +} + +fn line_with_right_aligned_description( + mut spans: Vec>, + description: String, + width: u16, +) -> Line<'static> { + let prefix_width = spans_display_width(&spans); + let description_width = display_width(&description); + let gap = width.saturating_sub(prefix_width + description_width); + + spans.push(Span::raw(" ".repeat(gap as usize))); + spans.push(description_span(description)); + + Line::from(spans) +} + +fn right_aligned_description_line(description: String, width: u16) -> Line<'static> { + let description_width = display_width(&description); + let gap = width.saturating_sub(description_width); + + Line::from(vec![ + Span::raw(" ".repeat(gap as usize)), + description_span(description), + ]) +} + +fn description_span(description: String) -> Span<'static> { + Span::styled(description, Style::default().fg(theme::TEXT_SECONDARY)) +} + +fn wrap_text(text: &str, width: u16) -> Vec { + if width == 0 { + return Vec::new(); + } + + let mut lines = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() { + append_wrapped_word(&mut lines, &mut current, word, width); + } + + if !current.is_empty() { + lines.push(current); + } + + lines +} + +fn append_wrapped_word(lines: &mut Vec, current: &mut String, word: &str, width: u16) { + let word_width = display_width(word); + let current_width = display_width(current); + + if current.is_empty() && word_width <= width { + current.push_str(word); + } else if !current.is_empty() && current_width + 1 + word_width <= width { + current.push(' '); + current.push_str(word); + } else { + if !current.is_empty() { + lines.push(std::mem::take(current)); + } + append_word_chunks(lines, current, word, width); + } +} + +fn append_word_chunks(lines: &mut Vec, current: &mut String, word: &str, width: u16) { + if display_width(word) <= width { + current.push_str(word); + return; + } + + for chunk in split_word_to_width(word, width) { + if current.is_empty() { + current.push_str(&chunk); + } else { + lines.push(std::mem::take(current)); + current.push_str(&chunk); + } + } +} + +fn split_word_to_width(word: &str, width: u16) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut used = 0; + + for ch in word.chars() { + let char_width = if ch.is_ascii() { 1 } else { 2 }; + if used + char_width > width && !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + used = 0; + } + current.push(ch); + used += char_width; + } + + if !current.is_empty() { + chunks.push(current); + } + + chunks +} + +fn should_split_shortcut_group(group: &str) -> bool { + let parts: Vec<&str> = group.split_whitespace().collect(); + + parts.len() > 1 + && parts.iter().all(|part| { + part.chars().count() == 1 && part.chars().all(|ch| ch.is_ascii_alphabetic()) + }) +} + +fn pad_line(line: &mut Line<'static>, width: u16) { + let line_width = line_display_width(line); + if line_width < width { + line.spans + .push(Span::raw(" ".repeat((width - line_width) as usize))); + } +} + +fn help_footer_line(scroll: usize, visible_lines: usize, max_scroll: usize) -> Line<'static> { + let total_pages = if max_scroll == 0 { + 1 + } else { + (max_scroll + visible_lines) / visible_lines + }; + let current_page = (scroll / visible_lines).saturating_add(1).min(total_pages); + + Line::from(vec![ + Span::styled("Press ESC or q to close", Style::default().fg(theme::TEXT)), + Span::styled( + " | j/k scroll | ", + Style::default().fg(theme::TEXT_SECONDARY), + ), + Span::styled( + format!("Page {current_page}/{total_pages}"), + Style::default().fg(theme::ACCENT), + ), + ]) +} diff --git a/src/ui/render/mod.rs b/src/ui/render/mod.rs new file mode 100644 index 0000000..8f452a6 --- /dev/null +++ b/src/ui/render/mod.rs @@ -0,0 +1,336 @@ +use anyhow::Result; +use crossterm::{ + event::{self, Event, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, Terminal, +}; +use std::{io, time::Duration}; + +mod help_overlay; +mod spreadsheet; +mod status; + +use help_overlay::draw_help_popup; +use spreadsheet::{draw_spreadsheet, draw_title_with_tabs, update_visible_area}; +use status::{draw_status_bar, status_bar_height}; + +#[cfg(test)] +use help_overlay::{help_entry_lines, help_overlay_lines}; + +use crate::app::AppState; +use crate::app::InputMode; +use crate::app::VimMode; +use crate::ui::handlers::handle_key_event; +use crate::ui::theme; +use crate::utils::cell_reference; + +pub fn run_app(mut app_state: AppState) -> Result<()> { + // Setup terminal + let mut terminal = setup_terminal()?; + + // Main event loop + while !app_state.should_quit { + terminal.draw(|f| ui(f, &mut app_state))?; + + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + handle_key_event(&mut app_state, key); + } + } + } + } + + // Restore terminal + restore_terminal(&mut terminal)?; + + Ok(()) +} + +/// Setup the terminal for the application +fn setup_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + stdout.execute(EnterAlternateScreen)?; + + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + + Ok(terminal) +} + +/// Restore the terminal to its original state +fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + terminal.backend_mut().execute(LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} + +fn ui(f: &mut Frame, app_state: &mut AppState) { + f.render_widget(Clear, f.size()); + let status_bar_height = status_bar_height(app_state, f.size().width); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(app_state.info_panel_height as u16), + Constraint::Length(status_bar_height), + ]) + .split(f.size()); + + draw_title_with_tabs(f, app_state, chunks[0]); + + update_visible_area(app_state, chunks[1]); + draw_spreadsheet(f, app_state, chunks[1]); + draw_info_panel(f, app_state, chunks[2]); + if status_bar_height > 0 { + draw_status_bar(f, app_state, chunks[3]); + } + + // If in help mode, draw the help popup over everything else + if let InputMode::Help = app_state.input_mode { + draw_help_popup(f, app_state, f.size()); + } + + // If in lazy loading mode or CommandInLazyLoading mode and the current sheet is not loaded, draw the lazy loading overlay + match app_state.input_mode { + InputMode::LazyLoading | InputMode::CommandInLazyLoading => { + let current_index = app_state.workbook.get_current_sheet_index(); + if !app_state.workbook.is_sheet_loaded(current_index) { + draw_lazy_loading_overlay(f, app_state, chunks[1]); + } else if matches!(app_state.input_mode, InputMode::LazyLoading) { + // If the sheet is loaded, switch back to Normal mode + app_state.input_mode = crate::app::InputMode::Normal; + } + } + _ => {} + } +} + +pub(super) fn display_width(text: &str) -> u16 { + text.chars() + .fold(0, |acc, ch| acc + if ch.is_ascii() { 1 } else { 2 }) +} + +pub(super) fn line_display_width(line: &Line<'_>) -> u16 { + line.spans + .iter() + .map(|span| display_width(&span.content)) + .sum() +} + +fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { + if area.height < 4 { + if matches!(app_state.input_mode, InputMode::Editing) { + draw_editing_panel(f, app_state, area); + } else { + draw_cell_details(f, app_state, area); + } + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + if matches!(app_state.input_mode, InputMode::Editing) { + draw_editing_panel(f, app_state, chunks[0]); + } else { + draw_cell_details(f, app_state, chunks[0]); + } + draw_notifications(f, app_state, chunks[1]); +} + +fn draw_cell_details(f: &mut Frame, app_state: &AppState, area: Rect) { + let content = app_state.get_cell_content(app_state.selected_cell.0, app_state.selected_cell.1); + let cell_ref = cell_reference(app_state.selected_cell); + let value_type = cell_value_type(&content); + let length = content.chars().count(); + + let title = format!(" Cell {cell_ref} {value_type} Len {length} "); + let block = panel_block(title, theme::TEXT); + let paragraph = Paragraph::new(content) + .block(block) + .style(theme::surface()) + .wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(paragraph, area); +} + +fn draw_editing_panel(f: &mut Frame, app_state: &AppState, area: Rect) { + let cell_ref = cell_reference(app_state.selected_cell); + let mode = app_state.vim_state.as_ref().map(|state| state.mode); + let input_block = panel_block_line(editing_title_line(cell_ref, mode), theme::ACCENT); + let inner_area = input_block.inner(area); + let padded_area = Rect { + x: inner_area.x.saturating_add(1), + y: inner_area.y, + width: inner_area.width.saturating_sub(2), + height: inner_area.height, + }; + + f.render_widget(input_block, area); + f.render_widget(app_state.text_area.widget(), padded_area); +} + +fn draw_notifications(f: &mut Frame, app_state: &AppState, area: Rect) { + let lines = if app_state.notification_messages.is_empty() { + vec![Line::from(Span::styled( + "No notifications", + Style::default().fg(theme::TEXT_SECONDARY), + ))] + } else { + app_state + .notification_messages + .iter() + .rev() + .take(4) + .enumerate() + .map(|(index, message)| { + let color = if index == 0 { + theme::TEXT + } else { + theme::TEXT_SECONDARY + }; + Line::from(Span::styled(message.clone(), Style::default().fg(color))) + }) + .collect() + }; + + let paragraph = Paragraph::new(lines) + .block(panel_block(" NOTIFICATIONS ".to_string(), theme::TEXT)) + .style(theme::surface()) + .wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(paragraph, area); +} + +fn panel_block(title: String, border_color: Color) -> Block<'static> { + panel_block_line( + Line::from(Span::styled( + title, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )), + border_color, + ) +} + +fn panel_block_line(title: Line<'static>, border_color: Color) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(border_color)) + .style(theme::surface()) +} + +fn editing_title_line(cell_ref: String, mode: Option) -> Line<'static> { + let mut spans = vec![ + Span::styled( + " Editing Cell ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + cell_ref, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + ]; + + if let Some(mode) = mode { + spans.push(Span::styled( + " - ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + mode.to_string(), + Style::default() + .fg(vim_mode_color(mode)) + .add_modifier(Modifier::BOLD), + )); + } + + spans.push(Span::styled( + " ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )); + + Line::from(spans) +} + +fn vim_mode_color(mode: VimMode) -> Color { + match mode { + VimMode::Normal => theme::SUCCESS, + VimMode::Insert => theme::ACCENT, + VimMode::Visual => theme::SEARCH, + VimMode::Operator(_) => theme::WARNING, + } +} + +fn cell_value_type(content: &str) -> &'static str { + if content.is_empty() { + "Blank" + } else if content.starts_with("Formula: ") { + "Formula" + } else if content.parse::().is_ok() { + "Number" + } else { + "String" + } +} + +fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) { + // Create a semi-transparent overlay + let overlay = Block::default() + .style(theme::surface()) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::ACCENT)); + + f.render_widget(Clear, area); + f.render_widget(overlay, area); + + // Calculate center position for the message + let message = "Sheet not loaded Enter load [ ] switch sheet : command"; + let width = message.len() as u16; + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + area.height / 2; + + if x < area.width && y < area.height { + let message_area = Rect { + x, + y, + width: width.min(area.width), + height: 1, + }; + + let message_widget = Paragraph::new(message).style( + Style::default() + .fg(theme::WARNING) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(message_widget, message_area); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ui/render/spreadsheet.rs b/src/ui/render/spreadsheet.rs new file mode 100644 index 0000000..5753abb --- /dev/null +++ b/src/ui/render/spreadsheet.rs @@ -0,0 +1,377 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; + +use crate::app::{AppState, InputMode}; +use crate::ui::theme; +use crate::utils::index_to_col_name; + +use super::display_width; + +/// Update the visible area of the spreadsheet based on the available space +pub(super) fn update_visible_area(app_state: &mut AppState, area: Rect) { + // Calculate visible rows based on available height (subtract header and borders) + app_state.visible_rows = (area.height as usize).saturating_sub(3); + + // Ensure the selected column is visible + app_state.ensure_column_visible(app_state.selected_cell.1); + + // Update row number width based on the maximum row number + app_state.update_row_number_width(); + + // Calculate available width for columns (subtract row numbers and borders) + let available_width = (area.width as usize).saturating_sub(app_state.row_number_width + 2); // row_number_width + 2 for borders + + // Calculate how many columns can fit in the available width + let mut visible_cols = 0; + let mut width_used = 0; + + // Iterate through columns starting from the leftmost visible column + for col_idx in app_state.start_col.. { + let col_width = app_state.get_column_width(col_idx); + + if col_idx == app_state.start_col { + // Always include the first column even if it's wider than available space + width_used += col_width; + visible_cols += 1; + + if width_used >= available_width { + break; + } + } else if width_used + col_width <= available_width { + // Add columns that fit completely + width_used += col_width; + visible_cols += 1; + } else if width_used < available_width { + // Excel-like behavior: include one partially visible column + visible_cols += 1; + break; + } else { + // No more space available + break; + } + } + + // Ensure at least one column is visible + app_state.visible_cols = visible_cols.max(1); +} + +pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { + // Calculate visible row and column ranges + let start_row = app_state.start_row; + let end_row = start_row + app_state.visible_rows - 1; + let start_col = app_state.start_col; + let end_col = start_col + app_state.visible_cols - 1; + + let mut constraints = Vec::with_capacity(app_state.visible_cols + 1); + constraints.push(Constraint::Length(app_state.row_number_width as u16)); // Dynamic row header width + + for col in start_col..=end_col { + constraints.push(Constraint::Length(app_state.get_column_width(col) as u16)); + } + + // Set table style based on current mode + let is_editing = matches!(app_state.input_mode, InputMode::Editing); + let table_block = Block::default() + .style(theme::base()) + .borders(Borders::ALL) + .border_style(if is_editing { + Style::default().fg(theme::GRID) + } else { + Style::default().fg(theme::ACCENT) + }); + let header_style = if is_editing { + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::TEXT_DISABLED) + } else { + theme::muted() + }; + let cell_style = if is_editing { + Style::default() + .bg(theme::BACKGROUND) + .fg(theme::TEXT_DISABLED) + } else { + theme::base() + }; + // Create header row + let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1); + header_cells.push(Cell::from("").style(header_style)); + + // Add column headers + for col in start_col..=end_col { + let col_name = index_to_col_name(col); + header_cells.push(Cell::from(col_name).style(header_style)); + } + + let header = Row::new(header_cells).height(1); + + // Create data rows + let rows = (start_row..=end_row).map(|row| { + let mut cells = Vec::with_capacity(app_state.visible_cols + 1); + + // Add row header + cells.push(Cell::from(row.to_string()).style(header_style)); + + // Add cells for this row + for col in start_col..=end_col { + let content = if app_state.selected_cell == (row, col) + && matches!(app_state.input_mode, InputMode::Editing) + { + // Handle editing mode content + let current_content = app_state.text_area.lines().join("\n"); + let col_width = app_state.get_column_width(col); + + // Calculate display width + let display_width = current_content + .chars() + .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }); + + if display_width > col_width.saturating_sub(2) { + // Truncate content if it's too wide + let mut result = String::with_capacity(col_width); + let mut cumulative_width = 0; + + // Process characters from the end to show the most recent input + for c in current_content.chars().rev().take(col_width * 2) { + let char_width = if c.is_ascii() { 1 } else { 2 }; + if cumulative_width + char_width <= col_width.saturating_sub(2) { + cumulative_width += char_width; + result.push(c); + } else { + break; + } + } + + // Reverse the characters to get the correct order + result.chars().rev().collect::() + } else { + current_content + } + } else { + // Handle normal cell content + let content = app_state.get_cell_content(row, col); + let col_width = app_state.get_column_width(col); + + // Calculate display width + let display_width = content + .chars() + .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }); + + if display_width > col_width { + // Truncate content if it's too wide + let mut result = String::with_capacity(col_width); + let mut current_width = 0; + + for c in content.chars() { + let char_width = if c.is_ascii() { 1 } else { 2 }; + if current_width + char_width < col_width { + result.push(c); + current_width += char_width; + } else { + break; + } + } + + if !content.is_empty() && result.len() < content.len() { + result.push('…'); + } + + result + } else { + content + } + }; + + // Determine cell style + let style = if app_state.selected_cell == (row, col) { + Style::default().bg(Color::White).fg(Color::Black) + } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col)) + { + Style::default().bg(theme::SEARCH).fg(Color::Black) + } else { + cell_style + }; + + cells.push(Cell::from(content).style(style)); + } + + Row::new(cells) + }); + + // Create table with header and rows + let table = Table::new( + // Combine header and data rows + std::iter::once(header).chain(rows), + ) + .block(table_block) + .style(cell_style) + .widths(&constraints); + + f.render_widget(table, area); +} + +pub(super) fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { + let is_editing = matches!(app_state.input_mode, InputMode::Editing); + let sheet_names = app_state.workbook.get_sheet_names(); + let current_index = app_state.workbook.get_current_sheet_index(); + + let file_name = app_state + .file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Untitled"); + + let brand_content = " EXCEL-CLI "; + let title_content = format!(" {file_name} "); + + let brand_width = display_width(brand_content); + let title_width = display_width(&title_content); + let max_title_width = (area.width / 3).min(title_width); + + let mut tab_widths = Vec::new(); + let mut total_width = 0; + let mut visible_tabs = Vec::new(); + + let horizontal_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(brand_width), + Constraint::Length(max_title_width), + Constraint::Min(0), + ]) + .split(area); + + let title_style = if is_editing { + Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) + } else { + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY) + }; + let brand_style = Style::default() + .bg(Color::Black) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD); + + let brand_widget = Paragraph::new(brand_content).style(brand_style); + let title_widget = Paragraph::new(title_content).style(title_style); + + f.render_widget(brand_widget, horizontal_layout[0]); + f.render_widget(title_widget, horizontal_layout[1]); + + let tabs_area = horizontal_layout[2]; + let rows_cols = sheet_rows_cols(app_state); + let rows_cols_plain = format!("Rows/Cols: {rows_cols}"); + let base_rows_width = display_width(&rows_cols_plain); + let total_tab_width: u16 = sheet_names.iter().map(|name| display_width(name)).sum(); + let visible_tabs_width = tabs_area.width.saturating_sub(base_rows_width); + let tabs_overflow = total_tab_width > visible_tabs_width; + let rows_cols_plain = if tabs_overflow { + format!("... {rows_cols_plain}") + } else { + rows_cols_plain + }; + let rows_cols_width = display_width(&rows_cols_plain); + let available_width = tabs_area.width as usize; + + for (i, name) in sheet_names.iter().enumerate() { + let tab_width = display_width(name) as usize; + + if total_width + tab_width <= available_width { + tab_widths.push(tab_width as u16); + total_width += tab_width; + visible_tabs.push(i); + } else { + if !visible_tabs.contains(¤t_index) { + while !visible_tabs.is_empty() && total_width + tab_width > available_width { + let removed_width = tab_widths.remove(0) as usize; + visible_tabs.remove(0); + total_width -= removed_width; + } + + if total_width + tab_width <= available_width { + tab_widths.push(tab_width as u16); + visible_tabs.push(current_index); + } + } + break; + } + } + + // Create constraints for tab layout + let mut tab_constraints = Vec::new(); + for &width in &tab_widths { + tab_constraints.push(Constraint::Length(width)); + } + tab_constraints.push(Constraint::Min(0)); // Filler space + + let tab_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(tab_constraints) + .split(tabs_area); + + // Render each visible tab + for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() { + if layout_idx >= tab_layout.len() - 1 { + break; + } + + let name = &sheet_names[sheet_idx]; + let is_current = sheet_idx == current_index; + + let style = if is_editing { + Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) + } else if is_current { + Style::default() + .bg(Color::Black) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY) + }; + + let tab_widget = Paragraph::new(name.to_string()) + .style(style) + .alignment(ratatui::layout::Alignment::Center); + + f.render_widget(tab_widget, tab_layout[layout_idx]); + } + + let rows_cols_rect = Rect { + x: tabs_area.x + + tabs_area + .width + .saturating_sub(rows_cols_width.min(tabs_area.width)), + y: tabs_area.y, + width: rows_cols_width.min(tabs_area.width), + height: 1, + }; + let mut rows_cols_spans = Vec::new(); + if tabs_overflow { + rows_cols_spans.push(Span::styled( + "... ", + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY), + )); + } + rows_cols_spans.push(Span::styled( + "Rows/Cols: ", + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY), + )); + rows_cols_spans.push(Span::styled( + rows_cols, + Style::default().bg(Color::Black).fg(theme::ACCENT), + )); + + let rows_cols_widget = Paragraph::new(Line::from(rows_cols_spans)) + .style(Style::default().bg(Color::Black)) + .alignment(ratatui::layout::Alignment::Right); + f.render_widget(rows_cols_widget, rows_cols_rect); +} + +fn sheet_rows_cols(app_state: &AppState) -> String { + let sheet = app_state.workbook.get_current_sheet(); + format!("{} x {}", sheet.max_rows, sheet.max_cols) +} diff --git a/src/ui/render/status.rs b/src/ui/render/status.rs new file mode 100644 index 0000000..6835b5e --- /dev/null +++ b/src/ui/render/status.rs @@ -0,0 +1,268 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::app::{AppState, InputMode}; +use crate::ui::theme; + +use super::line_display_width; + +pub(super) fn status_bar_height(app_state: &AppState, width: u16) -> u16 { + let _ = width; + if matches!(app_state.input_mode, InputMode::Help) { + 0 + } else { + 1 + } +} + +pub(super) fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { + match app_state.input_mode { + InputMode::Normal => { + let left = Line::from(vec![status_badge("NORMAL", theme::ACCENT)]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Edit"), + (":", "Command"), + ("/", "Search"), + (":w", "Save"), + ])); + render_status_sections(f, area, left, Some(right)); + } + + InputMode::Editing => { + let left = Line::from(vec![status_badge("EDIT", theme::SUCCESS)]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Save"), + ("Esc", "Normal"), + ("i", "Insert"), + ("v", "Visual"), + ])); + render_status_sections(f, area, left, Some(right)); + } + + InputMode::Command | InputMode::CommandInLazyLoading => { + let mut left_spans = vec![ + status_badge("COMMAND", theme::WARNING), + Span::raw(" "), + Span::styled(":", Style::default().fg(theme::TEXT)), + ]; + left_spans.extend(parse_command(&app_state.input_buffer)); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Run"), + ("Esc", "Cancel"), + ("A1", "Jump"), + ])); + render_status_sections(f, area, Line::from(left_spans), Some(right)); + } + + InputMode::SearchForward | InputMode::SearchBackward => { + let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) { + "/" + } else { + "?" + }; + let query = app_state.text_area.lines().join("\n"); + let left_spans = vec![ + status_badge("SEARCH", theme::SEARCH), + Span::raw(" "), + Span::styled(prefix.to_string(), Style::default().fg(theme::TEXT)), + Span::styled(query, Style::default().fg(theme::TEXT)), + ]; + let right = Line::from(shortcut_spans(&[ + ("Enter", "Apply"), + ("Esc", "Cancel"), + ("n/N", "Navigate"), + ])); + render_status_sections(f, area, Line::from(left_spans), Some(right)); + } + + InputMode::Help => { + // No status bar in help mode + } + + InputMode::LazyLoading => { + let left = Line::from(vec![ + status_badge("LAZY", theme::WARNING), + Span::raw(" "), + subtle_span("State "), + Span::styled("not loaded", Style::default().fg(theme::WARNING)), + ]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Load"), + ("[ ]", "Switch"), + (":", "Command"), + ])); + render_status_sections(f, area, left, Some(right)); + } + } +} + +// Parse command input and identify keywords and parameters for highlighting +fn parse_command(input: &str) -> Vec> { + if input.is_empty() { + return vec![Span::raw("")]; + } + + let known_commands = [ + "w", + "wq", + "q", + "q!", + "x", + "y", + "d", + "put", + "pu", + "nohlsearch", + "noh", + "help", + "addsheet", + "delsheet", + ]; + + let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"]; + + let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"]; + + // Check if input is a simple command without parameters + if known_commands.contains(&input) { + return vec![Span::styled(input, Style::default().fg(theme::WARNING))]; + } + + // Extract command and parameters + let parts: Vec<&str> = input.split_whitespace().collect(); + if parts.is_empty() { + return vec![Span::raw(input)]; + } + + let cmd = parts[0]; + + // Check if it's a known command with parameters + if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) { + let mut spans = Vec::new(); + + spans.push(Span::styled(cmd, Style::default().fg(theme::WARNING))); + + // Add parameters if they exist + if parts.len() > 1 { + spans.push(Span::raw(" ")); + + for i in 1..parts.len() { + // Determine style based on whether it's a special keyword + let style = if special_keywords.contains(&parts[i]) { + Style::default().fg(theme::WARNING) + } else { + Style::default().fg(theme::ACCENT) + }; + + spans.push(Span::styled(parts[i], style)); + + // Add space between parameters + if i < parts.len() - 1 { + spans.push(Span::raw(" ")); + } + } + } + + return spans; + } + + // For cell references or unknown commands, return as is + vec![Span::raw(input)] +} + +fn status_bar_style() -> Style { + Style::default().bg(Color::Black).fg(theme::TEXT) +} + +fn status_badge(label: &'static str, color: Color) -> Span<'static> { + Span::styled( + format!(" {label} "), + Style::default() + .bg(color) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) +} + +fn subtle_span(text: impl Into) -> Span<'static> { + Span::styled(text.into(), Style::default().fg(theme::TEXT_SECONDARY)) +} + +fn shortcut_key(key: &str) -> Span<'static> { + Span::styled( + format!("[{key}]"), + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) +} + +fn shortcut_spans(entries: &[(&str, &str)]) -> Vec> { + let mut spans = Vec::new(); + + for (index, (key, label)) in entries.iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + } + spans.push(shortcut_key(key)); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + (*label).to_string(), + Style::default().fg(theme::TEXT), + )); + } + + spans +} + +fn render_single_status_line<'a>( + f: &mut Frame, + area: Rect, + line: Line<'a>, + alignment: ratatui::layout::Alignment, +) { + let status_widget = Paragraph::new(line) + .style(status_bar_style()) + .alignment(alignment); + f.render_widget(status_widget, area); +} + +fn render_status_sections<'a, 'b>( + f: &mut Frame, + area: Rect, + left: Line<'a>, + right: Option>, +) { + let Some(right_line) = right else { + render_single_status_line(f, area, left, ratatui::layout::Alignment::Left); + return; + }; + + let right_width = line_display_width(&right_line).saturating_add(1); + if right_width >= area.width { + render_single_status_line(f, area, right_line, ratatui::layout::Alignment::Right); + return; + } + + let sections = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(area.width.saturating_sub(right_width)), + Constraint::Length(right_width), + ]) + .split(area); + + render_single_status_line(f, sections[0], left, ratatui::layout::Alignment::Left); + render_single_status_line( + f, + sections[1], + right_line, + ratatui::layout::Alignment::Right, + ); +} diff --git a/src/ui/render/tests.rs b/src/ui/render/tests.rs new file mode 100644 index 0000000..237b345 --- /dev/null +++ b/src/ui/render/tests.rs @@ -0,0 +1,520 @@ +use ratatui::{backend::TestBackend, style::Color, Terminal}; +use std::path::PathBuf; + +use super::{theme, ui}; +use crate::app::{AppState, HelpEntry, InputMode}; +use crate::excel::{Cell, Sheet, Workbook}; + +fn app_with_sheet() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 3]; 3]; + data[1][1] = Cell::new("Name".to_string(), false); + data[1][2] = Cell::new("Name".to_string(), false); + data[2][1] = Cell::new("Ada".to_string(), false); + data[2][2] = Cell::new("10".to_string(), false); + + let sheet = Sheet { + name: "Data".to_string(), + data, + max_rows: 2, + max_cols: 2, + is_loaded: true, + }; + let app = AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("scores.xlsx"), + ) + .unwrap(); + app +} + +fn app_with_many_sheets() -> AppState<'static> { + let make_sheet = |name: &str| Sheet { + name: name.to_string(), + data: vec![vec![Cell::empty(); 2]; 2], + max_rows: 1, + max_cols: 1, + is_loaded: true, + }; + + AppState::new( + Workbook::from_sheets_for_test(vec![ + make_sheet("Alpha"), + make_sheet("Beta"), + make_sheet("Gamma"), + make_sheet("Delta"), + make_sheet("Epsilon"), + make_sheet("Zeta"), + ]), + PathBuf::from("many.xlsx"), + ) + .unwrap() +} + +fn rendered_lines(terminal: &Terminal) -> Vec { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + + buffer + .content + .chunks(width) + .map(|row| row.iter().map(|cell| cell.symbol.as_str()).collect()) + .collect() +} + +fn text_fg_at(terminal: &Terminal, needle: &str) -> Color { + let lines = rendered_lines(terminal); + let row = line_index(&lines, needle); + let col = lines[row] + .find(needle) + .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")); + let offset = needle + .chars() + .position(|ch| !ch.is_whitespace()) + .unwrap_or(0); + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col + offset].fg +} + +fn fg_at(terminal: &Terminal, row: usize, col: usize) -> Color { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].fg +} + +fn bg_at(terminal: &Terminal, row: usize, col: usize) -> Color { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].bg +} + +fn symbol_at(terminal: &Terminal, row: usize, col: usize) -> String { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].symbol.clone() +} + +fn line_index(lines: &[String], needle: &str) -> usize { + lines + .iter() + .position(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")) +} + +fn help_overlay_text(width: u16) -> String { + super::help_overlay_lines(width) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn renders_help_overlay_as_structured_command_reference() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.show_help(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = rendered_lines(&terminal).join("\n"); + + assert!(matches!(app.input_mode, InputMode::Help)); + assert!(rendered.contains("COMMAND HELP")); + assert!(rendered.contains("NAVIGATION")); + assert!(rendered.contains("ACTIONS")); + assert!(rendered.contains("SEARCH")); + assert!(rendered.contains("FILE & APP")); + assert!(rendered.contains("JUMP & SHEETS")); + assert!(rendered.contains("Press ESC or q to close")); + assert!(rendered.contains("Page ")); + assert!(!rendered.contains("preview")); + assert!(!rendered.contains("findings")); +} + +#[test] +fn help_overlay_uses_solid_backdrop_to_hide_underlying_sheet() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.show_help(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + assert_eq!(symbol_at(&terminal, 0, 0), " "); + assert_eq!(bg_at(&terminal, 0, 0), theme::BACKGROUND); +} + +#[test] +fn help_entries_render_grouped_shortcuts_as_individual_chips() { + let entry = HelpEntry { + keys: "h j k l / arrows", + description: "Move cell", + }; + + let line_text = super::help_entry_lines(&entry, 60)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(line_text.contains(" h ")); + assert!(line_text.contains(" j ")); + assert!(line_text.contains(" k ")); + assert!(line_text.contains(" l ")); + assert!(line_text.contains(" arrows ")); + assert!(line_text.contains(" h / j / k / l / arrows ")); + assert!(!line_text.contains(" / ")); + assert!(!line_text.contains("")); + assert!(!line_text.contains("")); + assert!(!line_text.contains("‹")); + assert!(!line_text.contains("›")); + assert!(!line_text.contains(" h j k l ")); +} + +#[test] +fn help_entry_descriptions_align_to_the_right_edge() { + let short_entry = HelpEntry { + keys: "h", + description: "Move cell", + }; + let long_entry = HelpEntry { + keys: "Ctrl+arrows", + description: "Jump to next non-empty cell", + }; + + let short_line = super::help_entry_lines(&short_entry, 42)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let long_line = super::help_entry_lines(&long_entry, 42)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(super::display_width(&short_line), 42); + assert_eq!(super::display_width(&long_line), 42); + assert!(short_line.ends_with("Move cell")); + assert!(long_line.ends_with("Jump to next non-empty")); +} + +#[test] +fn help_entry_keeps_description_on_first_line_for_long_shortcut_groups() { + let entry = HelpEntry { + keys: ":noh / :nohlsearch", + description: "Disable search highlighting", + }; + + let rendered = super::help_entry_lines(&entry, 44) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + + assert!(rendered[0].contains(":noh")); + assert!(rendered[0].contains(":nohlsearch")); + assert!(rendered[0].contains("Disable search")); + assert_eq!(super::display_width(&rendered[0]), 44); + assert!(rendered[0].ends_with("Disable search")); + assert_eq!(super::display_width(&rendered[1]), 44); + assert!(rendered[1].ends_with("highlighting")); +} + +#[test] +fn help_entry_descriptions_wrap_right_aligned_inside_column_width() { + let entry = HelpEntry { + keys: ":sheet ", + description: "Switch sheet by exact name or one based index", + }; + + let lines = super::help_entry_lines(&entry, 34); + let rendered = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + + let normalized = rendered + .join(" ") + .split_whitespace() + .collect::>() + .join(" "); + + assert!(rendered.len() > 1); + assert!(rendered.iter().all(|line| super::display_width(line) <= 34)); + assert!(normalized.contains("one based index")); + assert!(rendered.iter().all(|line| { + super::display_width(line) == 34 || !line.contains(|ch: char| ch.is_alphabetic()) + })); +} + +#[test] +fn help_overlay_model_lists_complete_command_reference() { + let help_text = help_overlay_text(112); + + for required in [ + ":cw fit all", + ":dr ", + ":dc ", + ":ej ", + ":eja ", + "EDIT MODE", + "HELP CONTROLS", + ] { + assert!( + help_text.contains(required), + "expected help overlay to contain {required}" + ); + } + + assert!(!help_text.contains("preview")); + assert!(!help_text.contains("findings")); +} + +#[test] +fn renders_help_overlay_later_command_sections_when_scrolled() { + let backend = TestBackend::new(120, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.show_help(); + app.help_scroll = 17; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let mid_page = rendered_lines(&terminal).join("\n"); + + assert!(mid_page.contains("ROWS & COLUMNS")); + assert!(mid_page.contains(":cw fit all")); + assert!(mid_page.contains(":dr ")); + assert!(mid_page.contains(":dc ")); +} + +#[test] +fn renders_visual_refresh_shell_with_inspector_and_short_status() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(rendered.contains("EXCEL-CLI")); + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(rendered.contains("NORMAL")); + assert!(rendered.contains("[:w] Save")); + assert!(!rendered.contains("INSPECTOR")); + assert!(!rendered.contains("Run Diagnostics")); + assert!(!rendered.contains("Settings")); + assert!(!rendered.contains("Execute Script")); + assert!(!rendered.contains("Findings")); + assert!(!rendered.contains("Columns")); + assert!(!rendered.contains("Preview")); +} + +#[test] +fn renders_normal_mode_status_bar_as_single_row_on_wide_layout() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let status_row = &lines[lines.len() - 1]; + let title_row = &lines[0]; + + assert!(status_row.contains(" NORMAL ")); + assert!(status_row.contains("[Enter] Edit")); + assert!(status_row.contains("[/] Search")); + assert!(status_row.contains("[:w] Save")); + assert!(status_row.trim_end().ends_with("[:w] Save")); + assert!(!status_row.contains("Rows/Cols")); + assert!(!status_row.contains("Findings")); + assert!(!status_row.contains("Columns")); + assert!(!status_row.contains("Preview")); + assert!(title_row.contains("Rows/Cols: 2 x 2")); + assert!(title_row.trim_end().ends_with("Rows/Cols: 2 x 2")); +} + +#[test] +fn renders_cell_panel_above_notifications_in_vertical_info_layout() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let cell_row = line_index(&lines, "Cell A1"); + let notifications_row = line_index(&lines, " NOTIFICATIONS "); + + assert!(cell_row < notifications_row); +} + +#[test] +fn does_not_render_analysis_tabs_or_inspector_shell() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let full_text = lines.join("\n"); + + assert!(!full_text.contains("INSPECTOR")); + assert!(!full_text.contains("Analysis Panel")); + assert!(!full_text.contains(" Details Preview Findings Columns ")); + assert!(!full_text.contains("Query Preview")); + assert!(!full_text.contains("FINDINGS")); + assert!(!full_text.contains("COLUMNS PROFILE")); +} + +#[test] +fn renders_cell_details_with_dynamic_title_and_compact_fields() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.selected_cell = (2, 2); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(rendered.contains("Cell B2 Number Len 2")); + assert!(rendered.contains("10")); + assert!(!rendered.contains("Type: Number")); + assert!(!rendered.contains("Length: 2")); + assert!(!rendered.contains("Content: 10")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(!rendered.contains("SHEET CONTEXT")); + assert!(!rendered.contains("QUALITY")); + assert!(!rendered.contains("No findings for active cell")); +} + +#[test] +fn renders_notifications_panel_when_inspector_moves_below_table() { + let backend = TestBackend::new(90, 28); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.add_notification("Loaded 2 findings".to_string()); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("Loaded 2 findings")); + assert!(rendered.contains("NOTIFICATIONS")); +} + +#[test] +fn renders_editing_panel_with_vim_mode_in_title_and_without_status_mode() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.start_editing(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let full_text = lines.join("\n"); + let status_row = &lines[lines.len() - 1]; + let title_row = &lines[0]; + + assert!(full_text.contains("Editing Cell A1")); + assert!(full_text.contains("NORMAL")); + assert!(!full_text.contains("TARGET CELL")); + assert!(!full_text.contains("INPUT BUFFER [EDITING]")); + assert_eq!( + fg_at(&terminal, line_index(&lines, " Editing Cell A1 "), 0), + theme::ACCENT + ); + assert_eq!(text_fg_at(&terminal, "NORMAL"), theme::SUCCESS); + assert!(status_row.contains(" EDIT ")); + assert!(status_row.contains("[Enter] Save")); + assert!(status_row.trim_end().ends_with("[v] Visual")); + assert!(!status_row.contains("Rows/Cols")); + assert!(!status_row.contains("Mode ")); + assert!(title_row.contains("Rows/Cols: 2 x 2")); +} + +#[test] +fn removed_analysis_modes_do_not_appear_in_rendered_ui() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(!rendered.contains("Findings")); + assert!(!rendered.contains("Preview")); + assert!(!rendered.contains("Columns")); + assert!(!rendered.contains("COLUMNS PROFILE")); + assert!(!rendered.contains("SHEET PROFILE")); +} + +#[test] +fn renders_rows_cols_in_top_right_with_overflow_hint_when_tabs_exceed_space() { + let backend = TestBackend::new(60, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_many_sheets(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let title_row = &lines[0]; + + assert!(title_row.contains("Rows/Cols: 1 x 1")); + assert!(title_row.trim_end().ends_with("... Rows/Cols: 1 x 1")); + assert!(title_row.contains("Alpha")); + assert!(!title_row.contains("Zeta")); +} From 54617f21d352f6495cd095a8ada86688862f6bc0 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Thu, 23 Apr 2026 14:17:03 +0800 Subject: [PATCH 07/13] perf: update zip dependency to disable default features and enable deflate --- Cargo.lock | 357 ----------------------------------------------------- Cargo.toml | 2 +- 2 files changed, 1 insertion(+), 358 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9be5dd4..6854c83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,17 +8,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -132,15 +121,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.17.0" @@ -153,25 +133,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "calamine" version = "0.27.0" @@ -201,8 +162,6 @@ version = "1.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" dependencies = [ - "jobserver", - "libc", "shlex", ] @@ -226,16 +185,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.5.36" @@ -291,42 +240,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" version = "1.4.2" @@ -367,31 +286,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "derive_arbitrary" version = "1.4.1" @@ -403,17 +297,6 @@ dependencies = [ "syn", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - [[package]] name = "either" version = "1.15.0" @@ -477,30 +360,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "hashbrown" version = "0.15.2" @@ -524,15 +383,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "iana-time-zone" version = "0.1.63" @@ -574,15 +424,6 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -604,16 +445,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom", - "libc", -] - [[package]] name = "js-sys" version = "0.3.77" @@ -655,27 +486,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "memchr" version = "2.7.4" @@ -703,12 +513,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-traits" version = "0.2.19" @@ -753,28 +557,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "proc-macro2" version = "1.0.95" @@ -803,12 +585,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "ratatui" version = "0.24.0" @@ -924,17 +700,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "1.3.0" @@ -1011,12 +776,6 @@ dependencies = [ "syn", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.100" @@ -1028,25 +787,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - [[package]] name = "tui-textarea" version = "0.4.0" @@ -1058,12 +798,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - [[package]] name = "unicode-ident" version = "1.0.18" @@ -1088,27 +822,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -1387,67 +1106,19 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" - -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zip" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" dependencies = [ - "aes", "arbitrary", - "bzip2", - "constant_time_eq", "crc32fast", "crossbeam-utils", - "deflate64", "flate2", - "getrandom", - "hmac", "indexmap", - "lzma-rs", "memchr", - "pbkdf2", - "sha1", - "time", - "xz2", - "zeroize", "zopfli", - "zstd", ] [[package]] @@ -1461,31 +1132,3 @@ dependencies = [ "log", "simd-adler32", ] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml index b34cfa3..590b840 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ chrono = "0.4" indexmap = { version = "2.0", features = ["serde"] } tui-textarea = "0.4.0" quick-xml = "0.37.5" -zip = "2.5.0" +zip = { version = "2.5.0", default-features = false, features = ["deflate"] } regex = "1" [profile.release] From d98373e1ed3ed059996a17f272df87702d67cca1 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Fri, 24 Apr 2026 11:03:41 +0800 Subject: [PATCH 08/13] build: update dependencies --- Cargo.lock | 493 ++++++++++++--------------- Cargo.toml | 8 +- src/excel/workbook.rs | 13 +- src/excel/workbook/formula_lookup.rs | 6 +- 4 files changed, 241 insertions(+), 279 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6854c83..2e58acb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" @@ -23,12 +23,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -40,9 +34,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -55,77 +49,71 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "arbitrary" -version = "1.4.1" +name = "atoi_simd" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "8ad17c7c205c2c28b527b9845eeb91cf1b4d008b438f98ce0e628227a822758e" dependencies = [ - "derive_arbitrary", + "debug_unsafe", ] -[[package]] -name = "atoi_simd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4790f9e8961209112beb783d85449b508673cf4a6a419c8449b210743ac4dbe9" - [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -135,9 +123,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "calamine" -version = "0.27.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d80f81ba5c68206b9027e62346d49dc26fb32ffc4fe6ef7022a8ae21d348ccb" +checksum = "20ae05a4e39297eecf9a994210d27501318c37a9318201f8e11050add82bb6f0" dependencies = [ "atoi_simd", "byteorder", @@ -158,26 +146,26 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.2.19" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -187,9 +175,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.36" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -197,9 +185,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.36" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -209,9 +197,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -221,9 +209,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codepage" @@ -236,9 +224,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "core-foundation-sys" @@ -248,19 +236,13 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - [[package]] name = "crossterm" version = "0.27.0" @@ -287,15 +269,10 @@ dependencies = [ ] [[package]] -name = "derive_arbitrary" -version = "1.4.1" +name = "debug_unsafe" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" [[package]] name = "either" @@ -318,6 +295,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "excel-cli" version = "1.3.1" @@ -344,14 +331,20 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -362,15 +355,21 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.4.1" @@ -385,9 +384,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -409,26 +408,30 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", "serde", + "serde_core", ] [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -441,15 +444,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "once_cell", "wasm-bindgen", @@ -457,25 +460,24 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.172" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" @@ -483,22 +485,23 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -524,15 +527,21 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -540,15 +549,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -559,18 +568,18 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "encoding_rs", "memchr", @@ -578,9 +587,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -605,9 +614,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.11" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -643,24 +652,18 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rust_xlsxwriter" -version = "0.86.0" +version = "0.94.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce466a17c071a45249477993a1f1b6ceb447af42cbe9cdc65e30f38bab850688" +checksum = "efc4a0f1f7b425669996977016152b2939be9be44d40df252a5051c9c6b3b859" dependencies = [ "zip", ] [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "scopeguard" @@ -670,18 +673,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -690,14 +703,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -708,9 +722,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", "signal-hook-registry", @@ -718,9 +732,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", @@ -729,24 +743,25 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "strsim" @@ -778,9 +793,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.100" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -798,17 +813,23 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -824,41 +845,28 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -866,22 +874,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -910,9 +918,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.61.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", @@ -923,9 +931,9 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -934,9 +942,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -945,24 +953,24 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] @@ -973,16 +981,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.48.5", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -991,29 +999,13 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -1022,110 +1014,73 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "zip" -version = "2.5.0" +version = "7.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", "flate2", "indexmap", "memchr", + "typed-path", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zopfli" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ "bumpalo", "crc32fast", diff --git a/Cargo.toml b/Cargo.toml index 590b840..10ecf04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,17 +12,17 @@ exclude = ["/.tests", "/.github", "AGENTS.md", "CHANGELOG.md", ".gitignore"] [dependencies] ratatui = "0.24.0" crossterm = "0.27.0" -calamine = "0.27.0" +calamine = "0.34.0" anyhow = "1.0.79" clap = { version = "4.5.0", features = ["derive"] } -rust_xlsxwriter = "0.86.0" +rust_xlsxwriter = "0.94.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = "0.4" indexmap = { version = "2.0", features = ["serde"] } tui-textarea = "0.4.0" -quick-xml = "0.37.5" -zip = { version = "2.5.0", default-features = false, features = ["deflate"] } +quick-xml = "0.39.2" +zip = { version = "7.2.0", default-features = false, features = ["deflate"] } regex = "1" [profile.release] diff --git a/src/excel/workbook.rs b/src/excel/workbook.rs index d84f30f..06c341a 100644 --- a/src/excel/workbook.rs +++ b/src/excel/workbook.rs @@ -18,7 +18,7 @@ use sheet_parse::create_sheet_from_range; pub enum CalamineWorkbook { Xlsx(Box>>), - Xls(Xls>), + Xls(Box>>), None, } @@ -125,7 +125,7 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res if let Ok(file) = File::open(&path) { let reader = BufReader::new(file); if let Ok(xls_workbook) = Xls::new(reader) { - calamine_workbook = CalamineWorkbook::Xls(xls_workbook); + calamine_workbook = CalamineWorkbook::Xls(Box::new(xls_workbook)); } } } @@ -133,9 +133,12 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res } else { // For formats that don't support lazy loading or if lazy loading is disabled, for name in &sheet_names { - let range = workbook - .worksheet_range(name) - .with_context(|| format!("Unable to read worksheet: {}", name))?; + let range = workbook.worksheet_range(name).with_context(|| { + format!( + "Unable to parse Excel file: {} (unable to read worksheet: {})", + path_str, name + ) + })?; let formula_range = workbook.worksheet_formula(name).ok(); let mut sheet = create_sheet_from_range(name, range, formula_range); diff --git a/src/excel/workbook/formula_lookup.rs b/src/excel/workbook/formula_lookup.rs index 0b35226..5ce8f58 100644 --- a/src/excel/workbook/formula_lookup.rs +++ b/src/excel/workbook/formula_lookup.rs @@ -43,7 +43,11 @@ pub(super) fn lookup_formula_in_xlsx( let mut inner_buf = Vec::new(); loop { match reader.read_event_into(&mut inner_buf).ok()? { - Event::Text(text) => formula.push_str(&text.unescape().ok()?), + Event::Text(text) => { + let decoded = text.decode().ok()?; + let unescaped = quick_xml::escape::unescape(decoded.as_ref()).ok()?; + formula.push_str(&unescaped); + } Event::End(end_event) if end_event.name().as_ref() == end_tag.as_slice() => { From 45e639c71ac4f5e4dd70c9b9205cf3e80ab92cd0 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Fri, 24 Apr 2026 11:53:57 +0800 Subject: [PATCH 09/13] fix(tui): upgrade ratatui dependencies and restore autofit column behavior --- Cargo.lock | 216 ++++++++++++++++++++++++++++------- Cargo.toml | 6 +- src/ui/render/mod.rs | 11 +- src/ui/render/spreadsheet.rs | 104 ++++++++++++----- src/ui/render/tests.rs | 124 +++++++++++++++++++- 5 files changed, 373 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e58acb..8d06196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -201,7 +210,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn", @@ -228,6 +237,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -245,15 +268,15 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "libc", "mio", "parking_lot", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -268,6 +291,40 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "debug_unsafe" version = "0.1.4" @@ -370,12 +427,6 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -406,6 +457,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.14.0" @@ -427,6 +484,19 @@ dependencies = [ "rustversion", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -435,9 +505,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -464,6 +534,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "lock_api" version = "0.4.14" @@ -506,14 +582,14 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.11" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -596,20 +672,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", + "compact_str", "crossterm", "indoc", + "instability", "itertools", "lru", "paste", "strum", "unicode-segmentation", - "unicode-width", + "unicode-truncate", + "unicode-width 0.2.0", ] [[package]] @@ -659,12 +738,31 @@ dependencies = [ "zip", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -763,6 +861,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -771,20 +875,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.25.0" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.25.3" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.4.1", + "heck", "proc-macro2", "quote", "rustversion", @@ -804,13 +908,13 @@ dependencies = [ [[package]] name = "tui-textarea" -version = "0.4.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ "crossterm", "ratatui", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -831,12 +935,29 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "utf8parse" version = "0.2.2" @@ -977,9 +1098,9 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.48.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] @@ -995,13 +1116,14 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", + "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", @@ -1010,45 +1132,51 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.48.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.48.5" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.48.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.48.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.48.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "zip" diff --git a/Cargo.toml b/Cargo.toml index 10ecf04..a313e50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ categories = ["command-line-interface", "command-line-utilities", "development-t exclude = ["/.tests", "/.github", "AGENTS.md", "CHANGELOG.md", ".gitignore"] [dependencies] -ratatui = "0.24.0" -crossterm = "0.27.0" +ratatui = "0.29.0" +crossterm = "0.28.1" calamine = "0.34.0" anyhow = "1.0.79" clap = { version = "4.5.0", features = ["derive"] } @@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = "0.4" indexmap = { version = "2.0", features = ["serde"] } -tui-textarea = "0.4.0" +tui-textarea = "0.7.0" quick-xml = "0.39.2" zip = { version = "7.2.0", default-features = false, features = ["deflate"] } regex = "1" diff --git a/src/ui/render/mod.rs b/src/ui/render/mod.rs index 8f452a6..c95888b 100644 --- a/src/ui/render/mod.rs +++ b/src/ui/render/mod.rs @@ -77,8 +77,9 @@ fn restore_terminal(terminal: &mut Terminal>) -> Re } fn ui(f: &mut Frame, app_state: &mut AppState) { - f.render_widget(Clear, f.size()); - let status_bar_height = status_bar_height(app_state, f.size().width); + let area = f.area(); + f.render_widget(Clear, area); + let status_bar_height = status_bar_height(app_state, area.width); let chunks = Layout::default() .direction(Direction::Vertical) @@ -88,7 +89,7 @@ fn ui(f: &mut Frame, app_state: &mut AppState) { Constraint::Length(app_state.info_panel_height as u16), Constraint::Length(status_bar_height), ]) - .split(f.size()); + .split(area); draw_title_with_tabs(f, app_state, chunks[0]); @@ -101,7 +102,7 @@ fn ui(f: &mut Frame, app_state: &mut AppState) { // If in help mode, draw the help popup over everything else if let InputMode::Help = app_state.input_mode { - draw_help_popup(f, app_state, f.size()); + draw_help_popup(f, app_state, area); } // If in lazy loading mode or CommandInLazyLoading mode and the current sheet is not loaded, draw the lazy loading overlay @@ -182,7 +183,7 @@ fn draw_editing_panel(f: &mut Frame, app_state: &AppState, area: Rect) { }; f.render_widget(input_block, area); - f.render_widget(app_state.text_area.widget(), padded_area); + f.render_widget(&app_state.text_area, padded_area); } fn draw_notifications(f: &mut Frame, app_state: &AppState, area: Rect) { diff --git a/src/ui/render/spreadsheet.rs b/src/ui/render/spreadsheet.rs index 5753abb..21bc98d 100644 --- a/src/ui/render/spreadsheet.rs +++ b/src/ui/render/spreadsheet.rs @@ -12,6 +12,8 @@ use crate::utils::index_to_col_name; use super::display_width; +const TABLE_COLUMN_SPACING: usize = 1; + /// Update the visible area of the spreadsheet based on the available space pub(super) fn update_visible_area(app_state: &mut AppState, area: Rect) { // Calculate visible rows based on available height (subtract header and borders) @@ -24,40 +26,78 @@ pub(super) fn update_visible_area(app_state: &mut AppState, area: Rect) { app_state.update_row_number_width(); // Calculate available width for columns (subtract row numbers and borders) - let available_width = (area.width as usize).saturating_sub(app_state.row_number_width + 2); // row_number_width + 2 for borders + let available_width = data_columns_available_width(app_state, area); + ensure_selected_column_fully_visible(app_state, available_width); + let visible_cols = visible_data_column_widths(app_state, available_width).len(); + + // Ensure at least one column is visible + app_state.visible_cols = visible_cols.max(1); +} + +fn data_columns_available_width(app_state: &AppState, area: Rect) -> usize { + (area.width as usize).saturating_sub(app_state.row_number_width + 2 + TABLE_COLUMN_SPACING) +} + +fn ensure_selected_column_fully_visible(app_state: &mut AppState, available_width: usize) { + let selected_col = app_state.selected_cell.1; + + if selected_col < app_state.start_col { + app_state.start_col = selected_col; + } + + while app_state.start_col < selected_col + && columns_width(app_state, app_state.start_col, selected_col) > available_width + { + app_state.start_col += 1; + } +} + +fn columns_width(app_state: &AppState, start_col: usize, end_col: usize) -> usize { + let col_count = end_col.saturating_sub(start_col) + 1; + let content_width = (start_col..=end_col) + .map(|col| app_state.get_column_width(col)) + .sum::(); + + content_width + TABLE_COLUMN_SPACING * col_count.saturating_sub(1) +} - // Calculate how many columns can fit in the available width - let mut visible_cols = 0; +fn visible_data_column_widths(app_state: &AppState, available_width: usize) -> Vec { + let sheet = app_state.workbook.get_current_sheet(); + let max_col = sheet.max_cols.max(app_state.start_col); + let mut widths = Vec::new(); let mut width_used = 0; - // Iterate through columns starting from the leftmost visible column - for col_idx in app_state.start_col.. { + for col_idx in app_state.start_col..=max_col { let col_width = app_state.get_column_width(col_idx); + let spacing = if widths.is_empty() { + 0 + } else { + TABLE_COLUMN_SPACING + }; - if col_idx == app_state.start_col { - // Always include the first column even if it's wider than available space - width_used += col_width; - visible_cols += 1; - - if width_used >= available_width { - break; - } - } else if width_used + col_width <= available_width { - // Add columns that fit completely - width_used += col_width; - visible_cols += 1; - } else if width_used < available_width { - // Excel-like behavior: include one partially visible column - visible_cols += 1; + if width_used + spacing >= available_width { break; - } else { - // No more space available + } + + let remaining_width = available_width - width_used - spacing; + let render_width = col_width.min(remaining_width); + widths.push(render_width); + width_used += spacing + render_width; + + if render_width < col_width { break; } } - // Ensure at least one column is visible - app_state.visible_cols = visible_cols.max(1); + if widths.is_empty() { + widths.push( + app_state + .get_column_width(app_state.start_col) + .min(available_width), + ); + } + + widths } pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { @@ -65,13 +105,16 @@ pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) let start_row = app_state.start_row; let end_row = start_row + app_state.visible_rows - 1; let start_col = app_state.start_col; - let end_col = start_col + app_state.visible_cols - 1; + let data_column_widths = + visible_data_column_widths(app_state, data_columns_available_width(app_state, area)); + let visible_cols = data_column_widths.len().max(1); + let end_col = start_col + visible_cols - 1; - let mut constraints = Vec::with_capacity(app_state.visible_cols + 1); + let mut constraints = Vec::with_capacity(visible_cols + 1); constraints.push(Constraint::Length(app_state.row_number_width as u16)); // Dynamic row header width - for col in start_col..=end_col { - constraints.push(Constraint::Length(app_state.get_column_width(col) as u16)); + for width in data_column_widths { + constraints.push(Constraint::Length(width as u16)); } // Set table style based on current mode @@ -207,10 +250,11 @@ pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) let table = Table::new( // Combine header and data rows std::iter::once(header).chain(rows), + constraints, ) .block(table_block) - .style(cell_style) - .widths(&constraints); + .column_spacing(TABLE_COLUMN_SPACING as u16) + .style(cell_style); f.render_widget(table, area); } diff --git a/src/ui/render/tests.rs b/src/ui/render/tests.rs index 237b345..ff86b75 100644 --- a/src/ui/render/tests.rs +++ b/src/ui/render/tests.rs @@ -50,6 +50,51 @@ fn app_with_many_sheets() -> AppState<'static> { .unwrap() } +fn app_with_long_c22_cell() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 5]; 24]; + data[20][1] = Cell::new("分类甲".to_string(), false); + data[20][2] = Cell::new("示例能源服务股份有限公司".to_string(), false); + data[20][3] = Cell::new("Example Energy Services Company Limited".to_string(), false); + data[20][4] = Cell::new( + "示例省示例市示例区示例路100号示例大厦A座10层、20层、30层".to_string(), + false, + ); + data[21][1] = Cell::new("分类甲".to_string(), false); + data[21][2] = Cell::new("示例一致服务集团股份有限公司".to_string(), false); + data[21][3] = Cell::new( + "Example Unified Services Corporation Limited".to_string(), + false, + ); + data[21][4] = Cell::new( + "示例省示例市示例区样例四路15号示例服务大厦".to_string(), + false, + ); + data[22][1] = Cell::new("分类甲".to_string(), false); + data[22][2] = Cell::new("示例跨区域资产服务集团股份有限公司".to_string(), false); + data[22][3] = Cell::new( + "Example International Research Operations and Holdings Company Limited".to_string(), + false, + ); + data[22][4] = Cell::new( + "示例省示例市示例区样例南路示例广场45-48楼".to_string(), + false, + ); + + let sheet = Sheet { + name: "示例表".to_string(), + data, + max_rows: 23, + max_cols: 4, + is_loaded: true, + }; + + AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("sample.xlsx"), + ) + .unwrap() +} + fn rendered_lines(terminal: &Terminal) -> Vec { let buffer = terminal.backend().buffer(); let width = buffer.area.width as usize; @@ -57,7 +102,7 @@ fn rendered_lines(terminal: &Terminal) -> Vec { buffer .content .chunks(width) - .map(|row| row.iter().map(|cell| cell.symbol.as_str()).collect()) + .map(|row| row.iter().map(|cell| cell.symbol()).collect()) .collect() } @@ -91,7 +136,74 @@ fn bg_at(terminal: &Terminal, row: usize, col: usize) -> Color { fn symbol_at(terminal: &Terminal, row: usize, col: usize) -> String { let buffer = terminal.backend().buffer(); let width = buffer.area.width as usize; - buffer.content[row * width + col].symbol.clone() + buffer.content[row * width + col].symbol().to_string() +} + +#[test] +fn auto_fit_all_renders_full_long_cell_content() { + let backend = TestBackend::new(100, 36); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_long_c22_cell(); + let expected = "Example International Research Operations and Holdings Company Limited"; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + app.input_buffer = "cw fit all".to_string(); + app.execute_command(); + app.input_buffer = "C22".to_string(); + app.execute_command(); + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let row = lines + .iter() + .find(|line| line.contains("│22")) + .unwrap_or_else(|| panic!("expected row 22 to render:\n{}", lines.join("\n"))); + + assert!(row.contains(expected), "{row}"); +} + +#[test] +fn auto_fit_all_does_not_shrink_visible_fitted_columns() { + let backend = TestBackend::new(148, 59); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_long_c22_cell(); + let expected = "Example International Research Operations and Holdings Company Limited"; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + app.input_buffer = "cw fit all".to_string(); + app.execute_command(); + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let row = lines + .iter() + .find(|line| line.contains("│22")) + .unwrap_or_else(|| panic!("expected row 22 to render:\n{}", lines.join("\n"))); + + assert!(row.contains(expected), "{row}"); +} + +#[test] +fn auto_fit_all_shows_partial_next_column_without_shrinking_fitted_columns() { + let backend = TestBackend::new(148, 59); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_long_c22_cell(); + let full_c_cell = "Example International Research Operations and Holdings Company Limited"; + let partial_d_cell = "示 例 省 示 例 市"; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + app.input_buffer = "cw fit all".to_string(); + app.execute_command(); + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let row = lines + .iter() + .find(|line| line.contains("│22")) + .unwrap_or_else(|| panic!("expected row 22 to render:\n{}", lines.join("\n"))); + + assert!(row.contains(full_c_cell), "{row}"); + assert!(row.contains(partial_d_cell), "{row}"); } fn line_index(lines: &[String], needle: &str) -> usize { @@ -318,7 +430,7 @@ fn renders_visual_refresh_shell_with_inspector_and_short_status() { .buffer() .content .iter() - .map(|cell| cell.symbol.as_str()) + .map(|cell| cell.symbol()) .collect::(); assert!(matches!(app.input_mode, InputMode::Normal)); @@ -409,7 +521,7 @@ fn renders_cell_details_with_dynamic_title_and_compact_fields() { .buffer() .content .iter() - .map(|cell| cell.symbol.as_str()) + .map(|cell| cell.symbol()) .collect::(); assert!(matches!(app.input_mode, InputMode::Normal)); @@ -438,7 +550,7 @@ fn renders_notifications_panel_when_inspector_moves_below_table() { .buffer() .content .iter() - .map(|cell| cell.symbol.as_str()) + .map(|cell| cell.symbol()) .collect::(); assert!(rendered.contains("Cell A1")); @@ -490,7 +602,7 @@ fn removed_analysis_modes_do_not_appear_in_rendered_ui() { .buffer() .content .iter() - .map(|cell| cell.symbol.as_str()) + .map(|cell| cell.symbol()) .collect::(); assert!(rendered.contains("Cell A1")); From 9b998bb4332ff4197fcd3542661f5d5c67b60c03 Mon Sep 17 00:00:00 2001 From: Han FU Date: Fri, 24 Apr 2026 12:38:54 +0800 Subject: [PATCH 10/13] refactor(cli): share formatting helpers, improve check scans, and tighten CI --- .github/workflows/build-and-test.yml | 9 +- .github/workflows/release.yml | 7 +- src/cli/check.rs | 218 +++++++++++++++------------ src/cli/common.rs | 62 ++++++++ src/cli/error.rs | 7 +- src/cli/inspect.rs | 52 ++----- src/cli/mod.rs | 1 + src/cli/output.rs | 74 +++------ src/cli/read.rs | 36 +---- tests/headless_inspect_test.rs | 31 ++++ tests/malformed_xlsx_test.rs | 28 ++++ 11 files changed, 306 insertions(+), 219 deletions(-) create mode 100644 src/cli/common.rs diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c1debf5..c2428f9 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -23,14 +23,17 @@ jobs: rustup component add rustfmt rustup component add clippy + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2 + - name: Check formatting run: cargo fmt --check - name: Build - run: cargo build --verbose + run: cargo build --locked --all-targets --verbose - name: Run tests - run: cargo test --verbose + run: cargo test --locked --all-targets --verbose - name: Run clippy - run: cargo clippy -- -D warnings + run: cargo clippy --locked --all-targets -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d84a6ec..062be3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,8 +41,13 @@ jobs: toolchain: stable targets: ${{ matrix.target }} + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - name: Build for target - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --release --locked --target ${{ matrix.target }} - name: Rename binary (Linux/macOS) if: runner.os != 'Windows' diff --git a/src/cli/check.rs b/src/cli/check.rs index 41dea79..da4b06f 100644 --- a/src/cli/check.rs +++ b/src/cli/check.rs @@ -2,9 +2,10 @@ use serde::Serialize; use serde_json::{json, Value}; use std::cmp::Ordering; use std::collections::{BTreeMap, HashMap}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use crate::cli::args::SeverityThreshold; +use crate::cli::common::{file_format, format_range}; use crate::cli::envelope; use crate::cli::error::{AppError, EXIT_CHECK_FINDINGS, EXIT_SUCCESS}; use crate::cli::sheet_query::{cell_at, cell_has_formula, cell_is_present, header_value}; @@ -114,13 +115,6 @@ pub(crate) fn run_check_report( }) } -fn file_format(path: &Path) -> String { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_else(|| "unknown".to_string()) -} - fn parse_rules(value: Option<&str>) -> Result, AppError> { let Some(value) = value else { return Ok(RULES.to_vec()); @@ -209,6 +203,91 @@ struct SheetCheckContext<'a> { used_range: String, data_start_row: usize, data_row_count: usize, + facts: SheetFacts, +} + +struct SheetFacts { + row_has_present: Vec, + column_has_present: Vec, + data_column_has_present: Vec, + data_column_null_rows: Vec>, + data_column_type_counts: Vec>, + data_column_cells_by_type: Vec>>, + formula_cells: Vec, + formula_bounds: Option<(usize, usize, usize, usize)>, +} + +struct FormulaFact { + cell: String, + formula: String, +} + +impl SheetFacts { + fn new(sheet: &Sheet, data_start_row: usize, data_row_count: usize) -> Self { + let mut facts = Self { + row_has_present: vec![false; sheet.max_rows + 1], + column_has_present: vec![false; sheet.max_cols + 1], + data_column_has_present: vec![false; sheet.max_cols + 1], + data_column_null_rows: vec![Vec::new(); sheet.max_cols + 1], + data_column_type_counts: vec![BTreeMap::new(); sheet.max_cols + 1], + data_column_cells_by_type: vec![BTreeMap::new(); sheet.max_cols + 1], + formula_cells: Vec::new(), + formula_bounds: None, + }; + + for row in 1..=sheet.max_rows { + for col in 1..=sheet.max_cols { + let cell = cell_at(sheet, row, col); + let present = cell_is_present(cell); + + facts.row_has_present[row] |= present; + facts.column_has_present[col] |= present; + + if data_row_count == 0 || row < data_start_row { + continue; + } + + facts.data_column_has_present[col] |= present; + if !present { + facts.data_column_null_rows[col].push(row); + } + + let Some(cell) = cell else { + continue; + }; + + if let Some(kind) = cell_kind(cell) { + *facts.data_column_type_counts[col].entry(kind).or_default() += 1; + facts.data_column_cells_by_type[col] + .entry(kind) + .or_default() + .push(cell_reference((row, col))); + } + + if cell_has_formula(cell) { + facts.add_formula(row, col, cell); + } + } + } + + facts + } + + fn add_formula(&mut self, row: usize, col: usize, cell: &Cell) { + self.formula_bounds = Some(match self.formula_bounds { + Some((min_row, min_col, max_row, max_col)) => ( + min_row.min(row), + min_col.min(col), + max_row.max(row), + max_col.max(col), + ), + None => (row, col, row, col), + }); + self.formula_cells.push(FormulaFact { + cell: cell_reference((row, col)), + formula: cell.formula.clone().unwrap_or_else(|| cell.value.clone()), + }); + } } impl<'a> SheetCheckContext<'a> { @@ -231,6 +310,7 @@ impl<'a> SheetCheckContext<'a> { } else { 0 }; + let facts = SheetFacts::new(sheet, data_start_row, data_row_count); Ok(Self { sheet, @@ -238,6 +318,7 @@ impl<'a> SheetCheckContext<'a> { used_range, data_start_row, data_row_count, + facts, }) } @@ -254,12 +335,11 @@ impl<'a> SheetCheckContext<'a> { if self.data_row_count == 0 { None } else { - Some(format!( - "{}{}:{}{}", - index_to_col_name(col), + Some(format_range( self.data_start_row, - index_to_col_name(col), - self.sheet.max_rows + col, + self.sheet.max_rows, + col, )) } } @@ -359,12 +439,9 @@ fn find_blank_rows(context: &SheetCheckContext<'_>) -> Vec { } (1..=context.sheet.max_rows) - .filter(|row| { - (1..=context.sheet.max_cols).all(|col| is_blank_cell(cell_at(context.sheet, *row, col))) - }) + .filter(|row| !context.facts.row_has_present[*row]) .map(|row| { - let end_col = index_to_col_name(context.sheet.max_cols); - let range = format!("A{row}:{end_col}{row}"); + let range = format_range(row, 1, row, context.sheet.max_cols); CheckFinding { rule_id: CheckRuleId::BlankRows, severity: Severity::Warning, @@ -389,12 +466,10 @@ fn find_blank_columns(context: &SheetCheckContext<'_>) -> Vec { } (1..=context.sheet.max_cols) - .filter(|col| { - (1..=context.sheet.max_rows).all(|row| is_blank_cell(cell_at(context.sheet, row, *col))) - }) + .filter(|col| !context.facts.column_has_present[*col]) .map(|col| { let column_label = index_to_col_name(col); - let range = format!("{column_label}1:{column_label}{}", context.sheet.max_rows); + let range = format_range(1, col, context.sheet.max_rows, col); CheckFinding { rule_id: CheckRuleId::BlankColumns, severity: Severity::Warning, @@ -424,10 +499,7 @@ fn check_null_ratio(context: &SheetCheckContext<'_>) -> Vec { let mut findings = Vec::new(); for col in 1..=context.sheet.max_cols { - let null_rows: Vec = (context.data_start_row..=context.sheet.max_rows) - .filter(|row| !cell_is_present(cell_at(context.sheet, *row, col))) - .collect(); - + let null_rows = &context.facts.data_column_null_rows[col]; if null_rows.is_empty() { continue; } @@ -538,14 +610,14 @@ fn default_duplicate_candidate(context: &SheetCheckContext<'_>) -> Option<(usize let has_header = cell_at(context.sheet, header_row, col) .map(|cell| !cell.value.trim().is_empty()) .unwrap_or(false); - if has_header && data_column_has_value(context, col) { + if has_header && context.facts.data_column_has_present[col] { return Some((col, "first non-empty header data column")); } } } (1..=context.sheet.max_cols) - .find(|col| data_column_has_value(context, *col)) + .find(|col| context.facts.data_column_has_present[*col]) .map(|col| (col, "first data column with values")) } @@ -556,30 +628,14 @@ fn check_type_drift(context: &SheetCheckContext<'_>) -> Vec { let mut findings = Vec::new(); for col in 1..=context.sheet.max_cols { - let mut type_counts: BTreeMap<&'static str, usize> = BTreeMap::new(); - let mut cells_by_type: BTreeMap<&'static str, Vec> = BTreeMap::new(); - - for row in context.data_start_row..=context.sheet.max_rows { - let Some(cell) = cell_at(context.sheet, row, col) else { - continue; - }; - let Some(kind) = cell_kind(cell) else { - continue; - }; - - *type_counts.entry(kind).or_default() += 1; - cells_by_type - .entry(kind) - .or_default() - .push(cell_reference((row, col))); - } - + let type_counts = &context.facts.data_column_type_counts[col]; + let cells_by_type = &context.facts.data_column_cells_by_type[col]; if type_counts.len() < 2 { continue; } - let dominant_type = dominant_type(&type_counts); - let Some((drift_type, drift_count)) = first_drift_type(&type_counts, dominant_type) else { + let dominant_type = dominant_type(type_counts); + let Some((drift_type, drift_count)) = first_drift_type(type_counts, dominant_type) else { continue; }; let Some(first_drift_cell) = cells_by_type @@ -589,7 +645,8 @@ fn check_type_drift(context: &SheetCheckContext<'_>) -> Vec { else { continue; }; - let Some((first_drift_row, _)) = parse_cell_for_row(&first_drift_cell) else { + let Some((first_drift_row, _)) = crate::utils::parse_cell_reference(&first_drift_cell) + else { continue; }; let column_name = context.column_name(col); @@ -629,39 +686,27 @@ fn check_formula_presence(context: &SheetCheckContext<'_>) -> Vec return Vec::new(); } - let mut formulas = Vec::new(); - let mut min_row = usize::MAX; - let mut min_col = usize::MAX; - let mut max_row = 0; - let mut max_col = 0; - - for row in context.data_start_row..=context.sheet.max_rows { - for col in 1..=context.sheet.max_cols { - let Some(cell) = cell_at(context.sheet, row, col) else { - continue; - }; - if !cell_has_formula(cell) { - continue; - } - - min_row = min_row.min(row); - min_col = min_col.min(col); - max_row = max_row.max(row); - max_col = max_col.max(col); - formulas.push(json!({ - "cell": cell_reference((row, col)), - "formula": cell.formula.clone().unwrap_or_else(|| cell.value.clone()) - })); - } - } - - if formulas.is_empty() { + if context.facts.formula_cells.is_empty() { return Vec::new(); } - let formula_count = formulas.len(); + let formula_count = context.facts.formula_cells.len(); let formula_ratio = rounded_ratio(formula_count, context.data_row_count); - formulas.truncate(5); + let formulas: Vec = context + .facts + .formula_cells + .iter() + .take(5) + .map(|formula| { + json!({ + "cell": formula.cell.clone(), + "formula": formula.formula.clone(), + }) + }) + .collect(); + let Some((min_row, min_col, max_row, max_col)) = context.facts.formula_bounds else { + return Vec::new(); + }; vec![CheckFinding { rule_id: CheckRuleId::FormulaPresence, @@ -669,13 +714,7 @@ fn check_formula_presence(context: &SheetCheckContext<'_>) -> Vec sheet: context.sheet.name.clone(), row: Some(min_row), column: Some(min_col), - range: Some(format!( - "{}{}:{}{}", - index_to_col_name(min_col), - min_row, - index_to_col_name(max_col), - max_row - )), + range: Some(format_range(min_row, min_col, max_row, max_col)), message: format!( "Sheet '{}' contains {} formula cells.", context.sheet.name, formula_count @@ -694,11 +733,6 @@ fn is_blank_cell(cell: Option<&Cell>) -> bool { .unwrap_or(true) } -fn data_column_has_value(context: &SheetCheckContext<'_>, col: usize) -> bool { - (context.data_start_row..=context.sheet.max_rows) - .any(|row| cell_is_present(cell_at(context.sheet, row, col))) -} - fn cell_kind(cell: &Cell) -> Option<&'static str> { if !cell_is_present(Some(cell)) { return None; @@ -740,10 +774,6 @@ fn first_drift_type( .map(|(kind, count)| (*kind, *count)) } -fn parse_cell_for_row(cell: &str) -> Option<(usize, usize)> { - crate::utils::parse_cell_reference(cell) -} - fn rounded_ratio(numerator: usize, denominator: usize) -> f64 { if denominator == 0 { 0.0 diff --git a/src/cli/common.rs b/src/cli/common.rs new file mode 100644 index 0000000..052ee20 --- /dev/null +++ b/src/cli/common.rs @@ -0,0 +1,62 @@ +use serde_json::Value; +use std::path::Path; + +use crate::cli::error::AppError; +use crate::cli::sheet_query::SheetBounds; +use crate::excel::{Sheet, Workbook}; +use crate::utils::index_to_col_name; + +pub(crate) fn file_format(path: &Path) -> String { + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_lowercase) + .unwrap_or_else(|| "unknown".to_string()) +} + +pub(crate) fn format_range( + start_row: usize, + start_col: usize, + end_row: usize, + end_col: usize, +) -> String { + format!( + "{}{}:{}{}", + index_to_col_name(start_col), + start_row, + index_to_col_name(end_col), + end_row + ) +} + +pub(crate) fn format_bounds(bounds: SheetBounds) -> String { + format_range( + bounds.start_row, + bounds.start_col, + bounds.end_row, + bounds.end_col, + ) +} + +pub(crate) fn value_text(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(value) => value.clone(), + other => other.to_string(), + } +} + +pub(crate) fn tab_separated_values(values: &[Value]) -> String { + values.iter().map(value_text).collect::>().join("\t") +} + +pub(crate) fn sheet_by_index<'a>( + workbook: &'a Workbook, + sheet_index: usize, + sheet_name: &str, +) -> Result<&'a Sheet, AppError> { + workbook + .get_sheet_by_index(sheet_index) + .ok_or_else(|| AppError::TargetNotFound { + message: format!("Sheet '{}' not found", sheet_name), + }) +} diff --git a/src/cli/error.rs b/src/cli/error.rs index 12d59a3..6517b66 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -87,7 +87,12 @@ pub fn anyhow_to_app_error(err: anyhow::Error) -> AppError { let msg = err.to_string(); let lower = msg.to_lowercase(); - if lower.contains("unable to parse excel file") + if err + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + AppError::FileError { message: msg } + } else if lower.contains("unable to parse excel file") || lower.contains("parser panic: malformed workbook data") || lower.contains("no worksheets found") { diff --git a/src/cli/inspect.rs b/src/cli/inspect.rs index dfe658e..a8f7b17 100644 --- a/src/cli/inspect.rs +++ b/src/cli/inspect.rs @@ -1,8 +1,8 @@ -use anyhow::Context; use serde_json::{json, Value}; use std::collections::HashMap; use crate::cli::args::InspectCommands; +use crate::cli::common::{file_format, format_range, sheet_by_index}; use crate::cli::envelope; use crate::cli::error::AppError; use crate::cli::sheet_query::{ @@ -43,13 +43,6 @@ pub fn handle(cmd: InspectCommands) -> Result { } } -fn file_format(path: &std::path::Path) -> String { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_else(|| "unknown".to_string()) -} - fn inspect_workbook(file: std::path::PathBuf) -> Result { let format_str = file_format(&file); let path_str = file.to_string_lossy().to_string(); @@ -108,10 +101,7 @@ fn inspect_sheet( .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(resolved_sheet.index) - .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let used_range = workbook .get_used_range(resolved_sheet.index) @@ -171,10 +161,7 @@ fn inspect_sample( .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(resolved_sheet.index) - .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let bounds = resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, range.as_deref())?; // Apply row limit @@ -190,12 +177,11 @@ fn inspect_sample( "rows" }; - let range_str = format!( - "{}{}:{}{}", - index_to_col_name(bounds.start_col), + let range_str = format_range( bounds.start_row, - index_to_col_name(bounds.end_col), - sample_end_row + bounds.start_col, + sample_end_row, + bounds.end_col, ); let data = if let Some(header_row_idx) = resolved_header { @@ -297,16 +283,10 @@ fn inspect_columns( .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(resolved_sheet.index) - .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let resolved_header = resolve_header_row(&workbook, sheet_obj, resolved_sheet.index, &header_row)?; - let sheet_obj = workbook - .get_sheet_by_index(resolved_sheet.index) - .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let header_names = column_header_names(sheet_obj, resolved_header); let duplicate_flags = duplicate_header_flags(&header_names); @@ -375,10 +355,7 @@ fn inspect_tables(file: std::path::PathBuf, sheet: String) -> Result Vec { candidates .into_iter() .map(|candidate| { - let range = format!( - "{}{}:{}{}", - index_to_col_name(candidate.start_col), + let range = format_range( candidate.start_row, - index_to_col_name(candidate.end_col), - candidate.end_row + candidate.start_col, + candidate.end_row, + candidate.end_col, ); json!({ "range": range, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 15d0645..8b9c1f1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,5 +1,6 @@ pub mod args; pub mod check; +pub mod common; pub mod dispatch; pub mod envelope; pub mod error; diff --git a/src/cli/output.rs b/src/cli/output.rs index 6a37417..e6285f4 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -1,6 +1,7 @@ use serde_json::Value; use crate::cli::args::OutputFormat; +use crate::cli::common::tab_separated_values; use crate::cli::error::AppError; /// Write a success value to stdout. @@ -109,26 +110,9 @@ fn write_text_sheet(value: &Value) -> Result<(), AppError> { fn write_text_sample(value: &Value) -> Result<(), AppError> { let data = &value["data"]; if let Some(rows) = data["rows"].as_array() { - for row in rows { - if let Some(cells) = row.as_array() { - let line: Vec = cells - .iter() - .map(|c| match c { - Value::Null => String::new(), - Value::String(s) => s.clone(), - _ => c.to_string(), - }) - .collect(); - println!("{}", line.join("\t")); - } - } + write_row_arrays(rows); } else if let Some(records) = data["records"].as_array() { - for record in records { - if let Some(obj) = record.as_object() { - let parts: Vec = obj.iter().map(|(k, v)| format!("{}={}", k, v)).collect(); - println!("{}", parts.join("\t")); - } - } + write_record_objects(records); } Ok(()) } @@ -199,19 +183,7 @@ fn write_text_cell(value: &Value) -> Result<(), AppError> { fn write_text_range(value: &Value) -> Result<(), AppError> { let data = &value["data"]; if let Some(rows) = data["rows"].as_array() { - for row in rows { - if let Some(cells) = row.as_array() { - let line: Vec = cells - .iter() - .map(|c| match c { - Value::Null => String::new(), - Value::String(s) => s.clone(), - _ => c.to_string(), - }) - .collect(); - println!("{}", line.join("\t")); - } - } + write_row_arrays(rows); } Ok(()) } @@ -219,26 +191,26 @@ fn write_text_range(value: &Value) -> Result<(), AppError> { fn write_text_rows(value: &Value) -> Result<(), AppError> { let data = &value["data"]; if let Some(rows) = data["rows"].as_array() { - for row in rows { - if let Some(cells) = row.as_array() { - let line: Vec = cells - .iter() - .map(|c| match c { - Value::Null => String::new(), - Value::String(s) => s.clone(), - _ => c.to_string(), - }) - .collect(); - println!("{}", line.join("\t")); - } - } + write_row_arrays(rows); } else if let Some(records) = data["records"].as_array() { - for record in records { - if let Some(obj) = record.as_object() { - let parts: Vec = obj.iter().map(|(k, v)| format!("{}={}", k, v)).collect(); - println!("{}", parts.join("\t")); - } - } + write_record_objects(records); } Ok(()) } + +fn write_row_arrays(rows: &[Value]) { + for row in rows { + if let Some(cells) = row.as_array() { + println!("{}", tab_separated_values(cells)); + } + } +} + +fn write_record_objects(records: &[Value]) { + for record in records { + if let Some(obj) = record.as_object() { + let parts: Vec = obj.iter().map(|(k, v)| format!("{}={}", k, v)).collect(); + println!("{}", parts.join("\t")); + } + } +} diff --git a/src/cli/read.rs b/src/cli/read.rs index c510bfe..f1a0e9c 100644 --- a/src/cli/read.rs +++ b/src/cli/read.rs @@ -1,14 +1,14 @@ -use anyhow::Context; use regex::Regex; use serde_json::{json, Value}; use std::path::PathBuf; use crate::cli::args::{OutputFormat, OutputShape, ReadCommands}; +use crate::cli::common::{file_format, format_bounds, sheet_by_index}; use crate::cli::envelope; use crate::cli::error::AppError; use crate::cli::sheet_query::{ load_target_sheet, read_header_values, resolve_bounds, resolve_optional_header_row, - stable_record_keys, SheetBounds, + stable_record_keys, }; use crate::excel::{open_workbook, CellType, Sheet}; use crate::utils::{index_to_col_name, parse_cell_reference}; @@ -94,13 +94,6 @@ pub fn handle(cmd: ReadCommands) -> Result { } } -fn file_format(path: &std::path::Path) -> String { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_else(|| "unknown".to_string()) -} - fn read_cell( file: std::path::PathBuf, sheet: Option, @@ -123,10 +116,7 @@ fn read_cell( .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(resolved_sheet.index) - .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let cell_ref = cell.to_ascii_uppercase(); let in_bounds = row < sheet_obj.data.len() && col < sheet_obj.data[row].len(); @@ -244,16 +234,6 @@ fn invalid_query(message: impl Into) -> AppError { } } -fn format_bounds(bounds: SheetBounds) -> String { - format!( - "{}{}:{}{}", - index_to_col_name(bounds.start_col), - bounds.start_row, - index_to_col_name(bounds.end_col), - bounds.end_row - ) -} - fn sheet_row_values(sheet: &Sheet, row: usize, bounds: RowBounds) -> Option> { if row >= sheet.data.len() { return None; @@ -531,10 +511,7 @@ fn read_range( .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(resolved_sheet.index) - .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let bounds = resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, Some(&range))?; let mut rows = Vec::new(); @@ -607,10 +584,7 @@ fn read_rows( .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(resolved_sheet.index) - .with_context(|| format!("Sheet '{}' not found", resolved_sheet.name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let requested_bounds = resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, range.as_deref())?; let resolved_header = diff --git a/tests/headless_inspect_test.rs b/tests/headless_inspect_test.rs index 110ea80..2faf8b3 100644 --- a/tests/headless_inspect_test.rs +++ b/tests/headless_inspect_test.rs @@ -699,6 +699,37 @@ fn test_read_range_preserves_typed_values() { assert!(rows[0][5].is_null()); } +#[test] +fn test_read_range_text_preserves_cell_rendering_contract() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_range_typed_text.xlsx"); + create_read_contract_workbook(&file_path); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("range") + .arg(&file_path) + .arg("--sheet") + .arg("TypedCells") + .arg("--range") + .arg("A2:F2") + .arg("--format") + .arg("text") + .output() + .expect("Failed to execute excel-cli"); + + assert!(output.status.success()); + assert!( + output.stderr.is_empty(), + "Expected empty stderr on success. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "hello\t42.5\t2024-02-03\ttrue\t85\t\n" + ); +} + #[test] fn test_read_rows_json() { let temp_dir = std::env::temp_dir(); diff --git a/tests/malformed_xlsx_test.rs b/tests/malformed_xlsx_test.rs index 2454d0a..ba2bbe4 100644 --- a/tests/malformed_xlsx_test.rs +++ b/tests/malformed_xlsx_test.rs @@ -87,3 +87,31 @@ fn malformed_xlsx_read_cell_returns_controlled_error() { stderr ); } + +#[test] +fn missing_workbook_returns_file_error() { + let missing_path = std::env::temp_dir().join("excel_cli_missing_workbook_file_error.xlsx"); + let _ = std::fs::remove_file(&missing_path); + + let output = Command::new(excel_cli_bin()) + .arg("inspect") + .arg("workbook") + .arg(&missing_path) + .output() + .expect("Failed to execute excel-cli"); + + assert!( + !output.status.success(), + "Expected missing workbook failure" + ); + assert_eq!(output.status.code(), Some(3)); + assert!( + output.stdout.is_empty(), + "stdout should be empty: {}", + String::from_utf8_lossy(&output.stdout) + ); + + let stderr: serde_json::Value = + serde_json::from_slice(&output.stderr).expect("stderr should be valid JSON"); + assert_eq!(stderr["error"]["code"], "file_error"); +} From 783b097e0c98401f2605ffce06375fc732897f18 Mon Sep 17 00:00:00 2001 From: Han FU Date: Fri, 24 Apr 2026 12:44:14 +0800 Subject: [PATCH 11/13] test: replace test xlsx file --- tests/fixtures/invalid_shared_strings.xlsx | Bin 11952 -> 11311 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/fixtures/invalid_shared_strings.xlsx b/tests/fixtures/invalid_shared_strings.xlsx index 233e87c8000acec183a35402be0030227e623b47..dbc2b6f04828b2de64b2312c9ad7a7ccfbfc3d84 100644 GIT binary patch literal 11311 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 literal 11952 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 O Date: Fri, 24 Apr 2026 14:16:42 +0800 Subject: [PATCH 12/13] docs: update docs --- CHANGELOG.md | 20 ++++++++++++++++---- README.md | 5 +---- README_zh.md | 5 +---- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c160324..ddf6634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.1] - 2026-04-24 + +### Added + +- TUI `:help` command — opens a scrollable, structured key-reference overlay with all navigation, editing, and command-mode shortcuts. + +### Changed + +- TUI visual refresh: cleaner title bar with branding and inline row/column stats, single-line status bar, and a consistent dark theme across panels. +- `check` scans are more efficient and report findings with A1-style cell ranges. + ## [1.3.0] - 2026-04-22 ### Added @@ -201,10 +212,11 @@ This is the initial release of excel-cli, a lightweight terminal-based Excel vie - Copy, cut, and paste functionality with `y`, `d`, and `p` keys - Support for pipe operator when exporting to JSON -[Unreleased]: https://github.com/fuhan666/excel-cli/compare/v1.3.0...HEAD -[1.3.0]: https://github.com/fuhan666/excel-cli/compare/v1.2.0...v1.3.0 -[1.2.0]: https://github.com/fuhan666/excel-cli/compare/v1.1.0...v1.2.0 -[1.1.0]: https://github.com/fuhan666/excel-cli/compare/v1.0.0...v1.1.0 +[Unreleased]: https://github.com/fuhan666/excel-cli/compare/v1.3.1...HEAD +[1.3.1]: https://github.com/fuhan666/excel-cli/releases/tag/v1.3.1 +[1.3.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.3.0 +[1.2.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.2.0 +[1.1.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.1.0 [1.0.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.0.0 [0.5.2]: https://github.com/fuhan666/excel-cli/releases/tag/v0.5.2 [0.5.1]: https://github.com/fuhan666/excel-cli/releases/tag/v0.5.1 diff --git a/README.md b/README.md index 45b637b..5e89f14 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,6 @@ An Excel CLI for AI, scripting, and terminal users. Inspect and read headlessly - Run workbook and sheet quality checks with stable JSON findings - Delete rows and columns - Search functionality with highlighting -- Read-only query preview in the TUI -- Review quality-check findings inside the TUI - Command mode for advanced operations ## Installation & Uninstallation @@ -404,8 +402,7 @@ The JSON files are saved in the same directory as the original Excel file. ### Other Commands - `:nohlsearch` or `:noh` - Disable search highlighting -- `:help` - Show available commands -- `:preview` or `:pv` - Show a read-only preview of the current sheet target and sample rows +- `:help` - Show all keyboard-shortcut reference ## File Saving Logic diff --git a/README_zh.md b/README_zh.md index b9f2c64..b74e2fc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -12,8 +12,6 @@ - 对工作簿或单个工作表做质量检查,并以稳定的 JSON 格式输出结果 - 删除行和列 - 支持搜索并高亮匹配项 -- 在 TUI 内以只读方式预览查询结果 -- 在 TUI 内查看质量检查结果 - 命令模式支持高级操作 ## 安装与卸载 @@ -405,8 +403,7 @@ JSON 文件保存在原始 Excel 文件所在目录。 ### 其他命令 - `:nohlsearch` 或 `:noh` — 关闭搜索高亮 -- `:help` — 显示可用命令 -- `:preview` 或 `:pv` — 显示当前工作表目标和样本行的只读预览 +- `:help` — 显示所有快捷键 ## 文件保存逻辑 From 8a1703670f59c77675e34382baf5181e3bcc02d3 Mon Sep 17 00:00:00 2001 From: Han FU Date: Fri, 24 Apr 2026 14:18:43 +0800 Subject: [PATCH 13/13] test: restore malformed shared string references --- tests/fixtures/invalid_shared_strings.xlsx | Bin 11311 -> 9993 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/fixtures/invalid_shared_strings.xlsx b/tests/fixtures/invalid_shared_strings.xlsx index dbc2b6f04828b2de64b2312c9ad7a7ccfbfc3d84..099b63efc3b7c2f033bb0afefc62a140be7972c0 100644 GIT binary patch delta 8822 zcmZ9S1yo!;*QjUk;_gypkip&EU0NK9w1d0*;97KWr$~!yi(8?^rKNbGxWfR0Q{dT-C*vZaODR7T<0eB@3s8M0Pp zxTL9mCFnm!XB^FxPxxJ&brCZqF}hVaVgwAZsa&=iarkgLC^|}6)AtqKty1sS4z+w< zWLtJPCmX333csXnyGZKO)k;LC!hKUr=qbN=$mi^<%0T{Z(UZl=2t-6r&WTzsW+8GJ zlUDyi@xV#Y=rGh*weRMQ+^buBP-&!K?@E%N`GD$kR}%=y8()}$}(tbfVTt7JYb5IEZ2(P>juM#>;TqNMdur6Eq zPRt`)4R;HnI`E@p3*w|N!*8kXpsVi-etahr+{ZCzAaAxTN}Ok2?78q;pu0p_$W-}F zlGz!1>Ik_hAs`S9~=@eD@Ugz!FB;x3V{-NoO4jOXuq`|QWCX(l1O_kUW}OZ%Bx_Ux$*r zO4rlD;E3}JNMTW0%sS{zR>RnaRn|l99Y4kQnO?e(lc7T>jPCm*`6R**Q=M=?UE{n9 zg`XtbzE7i5vXpw9e4N-4?H$=LSBm+|PFbIbBeZt22#S<3Cc{?ASD{Byeb@_uz&kb> zY1H92;bx!(`xhP=YX<5KGlWOgNYKttGIf|+RefCGhJ7WJmt-FUkKV2zu#Md(~^GivM_oa?B^E|t!l`&lp-^IIb1SR|1oR$ z)Sve;%n+x`VNE}x^%e5Wvp>)te)e2n3mR92_X{zU6G6B7ut1zu4;&{a%ePO?n@;AN z>;G#KsE~dxWrCG3Pyth5-3(x8yRU>)2Y>bDh}~5bJ=j)#n1zC;*wKzk z3Tx1_vu%uYecw=5S8q^&6{Ee2`yL>aIbE@pl|R&>$ha}+-*WM@%~FAZ>Pq4+RPH+| zFU3Rxhp8K5dQ@ry_LEgxg|47f+nwEX1p%Q>g2LI`>Yzx6S=Pi(cSdGt7nC5{qey^< z(>fp>T*6_cHs|eUKOPoU+A9s#+;Pbz>8t(&Y7Nui_QCA*+rQ5A9#xwUX3be5j1Nlv zfitwqUlp8(kws>lya~l0pqb2Aq~AI$_U!QE79x)HG6EA?F(QEOkA_(0uE>XDJR*4$ z3Aa4%xP}jaf7S*}0Bnzu7zV_nLQ{>E-xtDwagk6!A6%D(C?6j}AMnCEsJ^TdCeYnE zY3G@8hC&!tMO@$wrqcCWLgdd{Rp}Vk%Y6yrv+b~ z)5j$yGjV_5pvhbN#fOuo)oh%;nmA!m&%#n@Pb{EeYn5OA&PgimYmoortn?)29XZVR zI#MSBijzwE7E$=~8{)^O>{S>OhgLJH>Y^}$LtAbX7tyKPIIJqdF2Wk8Jl&M9OQ=-o zACk!hQLDU9`^! zMax)@BLY3B^u@6APs@?7IOVlr=%0do8O~mA{kF=bCr-jzw|WSLOZEU zA3oP|HP0d^?fOe8txZ!`Z3nKj3AoiU_v;HYFG|kD^x_tR*^N_rJDb?u@Mz`f4-dY= zKmGPfw{P3k$O0!$Y~2qdXoi0^h*f#{OkIgfjtvc*LAlS}Eg4ZA33mWXCr4Gvt=SnK<;UG5R*;N< zU0FeJPLP@@DcNT>6c`h)LhQ*sf$Wj*1xBfo-+E?py1un`F_)q_oBW*K%?p9+m3PzE zz}4=%dG)V-Ok4fzK`ivmb~)LJ5Zd2S7_wyJ&4ir`9*sCOH>ypBa(ls0e>|hJIn*t< zbC{E?Q*hRIK8V)k+epr-OFrMX~ z6t&m~IWX`FyE1YC^0s>*{yKf|Ok8Rej<{r`=!%pcFGok?cx;m~IbrxRb`Y{+lK6@y zzbMMxD~p+_{4XyNrRFU({5+i|%|Y?jswdc}idyK2%(E z4gTv;-ruqBT&T-ezAevf@u{rSfUr$oU+;=Z3<*>s{Tfv6#(0mB9=WQMkv<@4q^+ig zJD2Lwl1$&8D)<@{*1D~*bMiXP0(eQ_B=K5WtsgA{=ZhIMzXS|ri@9g5kcp^G^8ivh zZ49r|{@(&**U*ermox3rP?nAbqOMAl!M(Axl|7#wa5HZ|_E@DD=fIF*TZ(5qE)b)Y zsXnk}b3T0eIibm`df={lJ)(Oq^Y@GN5xiARg>Pf%%pbT(lp(i6hqY+Gd5RDv?{do}cVdy(x zjW)&?A{Scv%HOo$xZ9z9XxMoF+{}0nAgQHVcKF+>*70VDeBBxA@xk^+r3cGm1(xMH z1aG72eS7}I^9LLG=4%I+Ip7$i=|jKZl~XktoC#Hi@)wt-oEg$`)r}z6LOVM;Bh|6E zVrG-1;b)5-g7c4JGtRFOY5{xl!CUeIRDVB4yhY$>M;ZM*^`ZDCpjKV_gSoHq(LSHN zzCt2k5)+*`AIDp!23d{-V~J^r{iR;$!VHkAsR8qBIi0x4iDd5}R0rMbb@7Z1%^RRO zNi4M0l;xd2c+jO;YxrFMw}DqQeQ5r*7l2EOCa_*6g?2;Pc&^G z5V1-In+jEAg+K;ah047qqrwB#egN=UC4<9Gd3>HcgGGf(+*tc%3fMeFC|oDu^=){I z;)k#>utl*@fa`GD&Y>O(mDvxQw$d4Vp*HA;S|ze|jSE8xBvXXx7SJi3xVG%@y_!Mg z-%yi~zfENcAQJoxaviEF%bZ=N0x3+L((slXrcZcOgVP-kIC&2kp`+-<+XGXT@9-TO zWD{=UBL8YvW3M}wodfMei6hUvC}M)Fnccut#X?HbHaW$}4%4!xJO&3Zdg(JTWe%aO zYX?>YG4?9We}AlfbO|%Cw{j%%2ff0lsT>EGXhv|T%56%|(IJ)V$zz3gcna!OS~+g| z(EKoRLl$f+f!xP;G$k38#|kk*u@LE<@>h&5DGGV)Fpi|=-i6Bjk3*;jsIUzj!}N=)5KL z@R6@Y zC>=Nq&oq6Tqx=@pYe-fH^%o3dDfC`1uv9oA`qR@U(dh2x?k{@AX@~m6U;Sj*9ry@1 z{SN&D`XMkC@1&*u(U@;^2Vtis^z<-EcU%3``!+&?+j@?@KCw0f+Ui1;_fxh!WS^_T z=hB#x*W`)&&+QZN-zN_Zn#}2!FnYX~P}AqV1#t*n!;{QjaE5EsX0cZ4%}*E2r4Op& z7iUHvKy&G=DPDTLILDizz<~}cZse;kF;=EwqzmfTj!oVRV#QFa^ev+@&C8|^nS@y& zVd~#WQM#$bbkE1H1=%0oj-y(sPE{6Fi(;@yVW|*a)5Ac!JY~YJg0VOu7;=S!^tPn)|pz88SZ>f*sU6*gAJws@r=}f(E9Q zOwblf3>oVS1od=B!+-gp*CvHc$r6A@yl~7BOB0WaE?FrrO0#NC>*guFp_EvDaUP$K zz*|Jd8IHWaZGy@G{%*2qC?o#yENkp$bgUGf;a95UBjeakZ>Q%H*eq%^A*89NqNKMJNbGZV3%LHVzha>cUlH%VPf$E1WGdmhxrpI@x;^2)xE_EPtv)R! zk%>Jf7fC-owALTVR3bx*f)Hy{%6NrA_unj}S6lmhMc;eh5X3dg7@of9n6&;ZoB)*A zsb_X42J$k!)KkH7V`W74gW&#(ULL4GxOmK>IrSwpFQd&5#7;3a0=ShMffAPQPk3Ec zCb!2kdkTyRml6>&~}3!HM; zy|nm6{Qb;?D-3pcv9loMcO1uZSt>Ofe60kpON4?v-H48P9bQTI4KeGHpy=eOY2XOI ziOh*y7$vu^V!W1B5=x8lJfXrR(^C{1OfRttjcOP%+xCn&fy_!BSxFZo78E>GD}=VB zcq$mtotBbcYSlvXRSEYOlE1w!XDxQrFj3`cmTPK^M`p@c?@?_C`rOo0BKA>Iwkd<6 zH3P-QMsji=#jR=YT&Xun_5AD3LyvP&zprQ`>6Alr-Pi62&HzC37NvpyS&w`#eEMk! z*Ia)r4?YHA_qn9lw~#(^_AigH#lnVtkYHX4M&u3>b0)d+!bnb4Xdr@|xBcbz6dgDceC4rjyn3%B0Q5 zTDQ!fUl%3Il@OhJlyhs#@!O=?nW%W>aw~s$vrL>&7xm5H!Jos76I}I(|L%{PUu}5m zsBp2!@WyNCaFL?#W@?>BMf-6yByeT!JA-q|^v?;Vl5#5);G2R~x0+7#z)w^Llm)fI z1dhJl!qbD)B}Dq3n%b3T^v9w^-oV$%?o}6YHLgfFu)@u;sM05OTh;ayWPDxVSXTVQ zkb-p*Yn*=Fp;fW!NbTz0uhlmmD@)6}qP~K|etiAVo|8ONYx7`DHz7-Y#XBBM zt?BH^8*iPYbFs_uFd%`){zRx7XS+IgT}!nh8>K~11_)Ci(bE0H-b7dE?v%RNl9We9 zb*G0j7j;ajW_|}Zsfm3q3A)P1riAWx`8RK8Tbj>ny6Zai10UJW?$E=PoX50k?LQiL z`#7D-$Q9=}8s60EQcTQC;21Q|&7Nf3+8s+{I|Z3S9%al8ZC_)yq8$Il`uFx))p+DD#IB&Zoh1?lwz= z`7GzufW?q{Shg1^!3f)vyh>?_Yr(*MiZS%n# zqOzzb&r0tXU;3v_!J%0ut z8~J!tuqsW{Y2*7wp=(lnB4wB2oNpl8bxLQK0(!tFUp}8*Fqnw`Fw1 zkZOM`cI;8J*@a;BrUbnzO^VrWs2&vm0TsL+*u#4`-Q342&`y5%IMw=dA#V{;T>3OG z-t8yEGF5e#Y3qOHq%=k7=R-ENnJtc7Bh_8UX*!?gO=2>uBvkNbCZl$LI2WyUpMlCV z1p072pYH0odOKXI9*Di$owCoXK$`=Xdkx`=IU_3yJ7XKwy5gZNaMmrE;oP9%L}_l| zFf;lY1$sknMAF;WcMn0MI$cwe&&5Ky4Gea>9`Wv)qNgDzAxoZB$&dW zEN9!wA*Qb@_2=;Ao}OuY>b559{OGT>6i`-YR4GOGAoZL#Mg|Zpyv?((*{yuhq3^!b zj~B}XQG&*epM7GWYxTSHEE|$4)xR$_7R3Br$2$KHY>6sDfTNEm8Be zzSZb!0SNfL3u+B^hrZHuSQLr5$Xe89->-24gQ&1dJ4*)*YZCMd8=nerO)Cj!n;>&z ze1suClR*?}>xX^g&2z)B+`MJ8DE-zCkXGhA_UOU)oyY{0jpu$9c3=ksG?oYZs%5++xno*c{eaTSHI;IpPun(3uA7DeRnZF=l&Mmm zc2<5QH^j5f1@R`;U~ng9MssaJWERhoW@K>$f z-J?&(y^DW9pmB>jcY!WL?^?_^2Ax=GgZqXQhEOWzc%xDTh|_@cV$5ikDMr){eB1GC zgQX>Y*f-#b!96UIavAn{CvKF|5=-o|WmJ;5sLqA=cI->ZCZ~o9+QOtD|fby zzgNbFIc(vp+~Q9;U%NwZ}Z z^@QGJ+K?>!X?ond*_r;1Q?d8uu@hD90}nMEV5XY}%j1nEVz32gAwP69qkLc#h^RZzu;(DS~NU?pY8dcm|| z^Vb|O-YgO;M@lIFeZ{?)ebs?1Y(pUbuZ73{Na?D9Y zr*YR?jXju_cz=YAUyA!4DC!S?b4SvRk+-KNS#;;17q72NNrY~qe87#F5dk-C`pNmc zF{h^+dZ9q$U-3EIhx*mhZp1-{ zMVTbc_)Ui+?to}@N#I%g{+)bqaBE#wb%$hffL-p%d4+EfUkJ4{4V2#FwqW}B>EBxj z+BfPiK(JgO9aMYZC2`ar&A)g!a8AzZM0)3}Pm3^hjr<=sN^5nD?CF4|%RgfWbDAp& z$!?PmC+j+szEz1*{bGyzm=M)?hUqRMJzybA_4V~?F0614 ziN(p#OF}bZl_gylaLd`M&U>0aq1Ie4aA^j;ZAlR8c2FuO@;^;zJgPHD?57$$Q>V1# zd2zA0D*Y^Fw++V__|)=Pm@U2{>U=@x0S%+()Z`ibAJam3+y+ZA*EN8n{qc*O=y!{ z?T&q^tfg6fs8!)albo}mS^6c655WA(Fmbe1`F!U$p^#ocZ{3|P zR}a)Y80mRDr6QBTbl6c~GANR$k#%vJSgNFL&-Yv#w8iu<4F{S+8H1lg+Z zu{5a{VR|+ycIr#vgqzS-A{sg@;f_w>>mBjkt+G5@(NJUZWycIzN_>pSjgI44`iSIv z8WVX-^U@`oX|;E51f(wKV+j|ld%EQFSXdHri`cXUR7CGNr7;&5`ie4oy_SZ*-_BAu zoIpDY6Vwo@nA<#zTbPIuG0q=1aS`yYcRoQ2)*aPBD0DyV64i|tXWNbs?e zv~bpi?j$JIXQM9ruB0~VmR?JhoIwrwj}ey4etcAKd%uO^wrdI-8xV>3Khf3y?+(Os zthM7g?n?PA|4Bf=%YOvS4MYPoVI>9{!CYBMkQjilXjWSypdFbg{clT!chp!Ppj z|22NzpgpqE11DfF*+`HskYU1XU~;DCn+7Y-8SfwLIsKPJpuzmv#E}#*U|-ni$c3H{ z5i8mN0M37r|HC+9z>e7%fiGbMU=pNpd>AVjME(Dc8~_0Mzi1j#SON+qECNhS4toBa z#B+rS{slPD!s@}2NIi706EHP7$1@}T%;5iv*`R|luuC9G(8Da*xslB2VX5p) k$#W+97aB(odqN_C39wVc9@%k`zA?aXIjGQ%82*9&2l_>282|tP delta 10202 zcmZ8{1z227((d3+kii`Wx8NEaf(LhZceg=9aL5F=1OfzyKyY_=cb6c8yIj8g|Gj(n z9;tr1`fabSQ{AVclq+>0Z4|_iU=Tc#g|oSeo3jgKg8~S{3Av_V0^?&daxsQ}5kk`Q zB;#^k>QKbF$dNp%Z*Zl$L#lWpK@ja8f&Z&v(Adm=>+0_Gm9S&@5qx$_jkZG7L;?$jx7gtZz|&43GMjhN`BR1=Xi~ zHkv1A92l=pO}H(p+@DvJ63xDLYY?2-lTv<+V8A434(`E@ljQG8{y}`!#H8_EG`L7| zBZq2}F3a*3mc>vI{+PMHG$T2mk<(0g=jEV&p~&D6Eh`atg3Q_j_Q( zI>M{upnoQBrldq>$WRU`vKDY%!mU1n$5u9P^`%vJelDu*E-lNw-T$<8_u_lz4sMoq zd9g~;E&~jWUhzns1p75pt8~=671^~~L?#dTgp}il5@1U7Tqneywc&dQeUgPRUY$fU z7EA9!44KVo&B1%sm0IOXtX!~D3@d?u8-n6RKnM>!oA1UQyyA(J6r5T#imj-@G~CaQ ziC{!rVYc6}V1GFNlUJ65WV9l!O`B~+$rXh`y;rKQy$G)GRU)_J{x$6XJ%-^GQA(JQEGkOyr%&PU zv3j9&L6L>GQ=;(RR2?!Vd>rK5uDiK=t>eEMdZr3v9_$u81 zHSg=@nZmd!v5VyPdc^GX(X+z(CG&9L zZF!R}fNk=N~wESET2o+6?wPqgsk;Hv1b{hrbuePiy*x0pqD36INcEA%05He{;lZZ>>l-?64`BqdPrRDkJ@=6>el4RM?A42L zM01#o2@>%qqve>ZGtkwAo>a0I|B!Z**^VVYrNd;rK=<{LAvgT+^l({~`}8a0vO+{e zd2}^EusDAC!Ph$=Fze+g*EmR5iGJV9Jz(wl$o!iBxmEBJIPhVcf}G9s^7%m6Vq?z`@9RUtQfXr#S}mQI|8|7rIgZiOw;i+67{ zIAtrkT)mNHkP1;0DP6jFUYUy=&-CI7^g9qd)93Q{^H~$etG(OMT%2%4M#0^QpmHJp@(P8T*;^^Wv+8R>mz_CN`(%&w|Oi^Tgca%_c z-+hcU6NL6EJ|MzYhOx~SjNs%JpLyk4U87?YZ~xF>xX0sjB>-6~bhFw9yM;DG+$a10 z2z2I;Hy%4OS&OjIHwXz#i&TuFkOz*H)fZ=pg(cW!uH61H4D+YpporI5_%ayhH27s> z?V|z4M{cYV8Z8UiE2uesNLtT5NlEkY{mcraWl^JuQWYVr<+Z-{^Cqtz12S5jC}qq z*gA24MuxG@xOqZ3WBAp%GeFx1d;eo{GOIMWr~r)|e_8G7u(1}oDo8vZSSot}tzhhU zmhKTN<_T$Urpo~X^dO{72oN0tF0%s-vomkpOL!GCfwW;jG64+|?sImJ0v$JPN?0TM zdv|P(G`rpnD4ms+nwdb00$nPbXU|zl&%Zev!&r^fhz7KXV7}^)Y>cpPQCWB=#u#}A zX|W5h!z8qT2RS9l9pc7O_9sV?>jB#Odu}ms1+A_6F@e-oq?*E_Lb^e;$Xzec5?c4b zQn8Lbe*pb#W#x6n7#WaeCK&ilU>{An&m(_GmqNEO)>m@lCxhjR9|oKJH~0n^RW}@P zW$N6=5OXbTentZ#T*VL6<28Jfy11$9*qB&fOmMSOO!ZL`LCuvY1A_@jxjuL3QlL2P*0w1oCudf4PLg983N#~iv_Cg#j}XG{ zLvfjlt}RQbU8Z>&l7yuGLa~^w&_ilON_drz6k@xFw+roEY9)J|Mv5z6O0mpkCCvHu zW>Wi;yVKk{icIf%v`5AYNDS9$QKAOCzImJS4?0>6I@sSvKmUKRdd?+b1v81XR;jISz3SJBW$* zB*AiNjNyv5El7%bN;p_?0Xz7?FGgHsEt|U(^I*O=aPf`q3B>G0yAAr<^G8GTg?u|aqJN%^H)57NA zjEu4Oz!^x3nNfGdq#`N)(x@pF7h5d9s_~V642+V_8<`lV(X+yoC(}ntXCZ&t1Hd=sU z-rqw0(7IJA`jC-qfFmY#7-_y8;)~Jf3W|>HM#Y+)JJzD+U(7F{#ERjmd)b~#EkTkI3f1>-9`L}I8ut^1fh*S!n>RQbf zt#qr&s%$G9Jxgk|1x00#^7j!rR%0ig*E^N&)XTVUOB>P1<%&LF-Te0EPTxN{jBya*cd8n)Qm>&~(XwATWiU|-$a)kakgH^D2OEKM+`9=QW$%~i13LJIL8 zVx4~SX50t!`z1IEvWJYVs*?H>l8wmRK8rNfxP;8>djA|Th|BV+Fvpv!j+~5}k>x)i z7wb3o+|5>#J73t3R$lBrkogO0E8ChK^XHuLb+vA_mW*GOv|bmhW;e>pSgn3uF7{uw zxzOTke$|}q_o`vvwES684ejX2Bq$oSzn)jRY!b?$_xB+~H1cbN`xzIAQt(J-^sTdf zYqPh$DI(RqB6n?rtQgg(=b**tFY(sWJFNMoHV<%kLd^?pS<&nN7@Kd~LWmhM0fHh~ z%EU5c9g7mI|)! z$+MkQ2wC%ESVEKyHkvZt=9SKby+VQ&};GHP!2D zP#)AWm?2TYfLp!RlZ*(yC+AAnHTz#+v`X>D*9YRD_XiR{))vIwyFTJJBWFj?BYdMU zw*YsH^^();zpQU44%$Nx3`0lm`sSF~6mTL+K9aIevnoYx9qPUUP(xDJ&@LK9VYg6E zVFIoX37{et4U+pHFn$J)pwezAHJFAAO_ZwEnEp-{8@qq6LOJ(&CN>9|f6$`8J%?%m zTcNVj`04JUmkW9wQ&g0YlPmJDdv(4&{ZVwkjinpzn@r(mDYX6Warskgdrsi(U+|!| zQ_K7F_SdUL=k}mO#4Zs%PQUx>Y0(_f!28jC@XJ{b%=)&CL)S-8XIFF7rxqcWkUxhV z3nS-g?diP?GCjY=IPSa}PKOs@*Rp8-cAc0D`P}l$enInbyy@5yjJ;RH=^x?S>VYNa zkhvxDM%?(EG5qDsCPn6S@P4FcRUCY`Ul3B|mgUAJ%?JH#if!3AkI{{4`sBQ%us`1h7ZqCFA~4o+ZKoyw#u z5=x5GT8c?uGnz(_dg{1jPg>M*wJo2F++G)^9C7ACLN8Dr=v$xe(V39eDmmjl04L8^ z_(uD~+n7d_YMz%I{7*#p_wA-rihC*^U95k2UGddnrz+OSvu`gTvF zFQ!(HAMU9r!{i5U1!waGydyB{-&TosZJSqc3@ z!9X@7&>Ja{+kwFS-31Ufq--qc>Pt3kcK6GRQ_*CTI=(ipKm93uz=tp&T^Y(_ED$BN zDTrg#2fspmT{T_bHLdH`)Acl`TUtzF#YPT0xa;#rHNHDeeDA=@-WLpETD7lBYCC5` zW?1H@+~1)oE;3vOkV>kmco{tspbC)wk%wZKI+>op4~0*idWPJ#k$qV9Sj_h z)0qLc@&soOd~I56E5OUc-}J6D7@~Cux%Er2u2+~djSIN!xEDD*`fQ(^DJ%;!N$#U(&{^8?~jC!p;?c%&)CW{y1SY%oH^_@AzO4REG0X&K8R`qYQmZd_+Y z>nF@m9Q#^3reG@Uv*@Cfk6fTLt>3`kme1wfg%Vz)^Px?jFI=T+2=T~zftREe1uZ}G z+mkuox8d1;4ko5zB;Mp#!tH)w?M(G{eKU#OCH7<>XTl&ZIzeXMo$DL|Pe!u}iJy z)g4^FvWw&hOV$k<45RcHX|kp}HzEW@7s|C*(UtJ`<;iTHyCd$=;7CLP=Len5D@4iE z1jram&q)7$8YA~b@{sxMuwnHR0DuAcjEn<*bY7Ohd3g?gq(Cn8wt4r5R^&>ar6|e4 z2yMp=W~-ZQ0*J_}%Bvc!!N~MS{|emh?0uhBpbgAS13JQ_NV)P?I$K$LM+*A~wqw?Q zGHSWbcPSw$3N*azlCHh#UM_ygW!|?avtRDPNOi)BSknNX)jb?tw!iE;WD3KtMGAq( z6}SzkSCp@tL5nTN*L5*M&L>Wv^5Y1$QZ<2z3msXMvgR44DS8Tt{){pX(%Jf6Zog%BX@jC#(f;zt-pt^8o$T`%d9$@l!2VG5 z{Bq4#N&b9uesnya5N+Ixr`)rAH2(yKT{i^h-*OIqA?&l!`6{WPYiwUxaelvzVx1fW zVH-^d^e$&)cgQR_V!AF>IsI;hbVZzeXYI!UkuSkR0-c!fd+C)JvtJ+HFEXK`&1c{T z4992XI)bM~8evqd-}|%VQAYo{yRj+}QqFyKl|(aT@~Rys$2i!omT zFUZSAqyluUJ4-^t_5-zx;pWX*Q<^a*S&l{KegySg)_D2{lAYvuoU}`jNM^?Mr}9Ay zPGa;vICHb5zMrNqha2Zs9Yz?J@}Akirugqi3xx6g+fj~0O6Cl^F0~P`*&sD&3Cx6q9XR8dkjK%_H8s51ZrPjzP92OSHc1NqF%*Nv!f~L_E zZRzHGIJuKt9#|~}aZ)}`=EgF8-2C(ec6g+S5S?sat<8$}&~Y8vf-*edK9Rk#UKwuo z-OKivF?es3GuVKaTa1x2s%r8IcJag&?BuCkA7L0!67zjd{=a7$uW}q)**emaKfzSP3f5`yi!~hhz8VV-p$-YhF9YOhk|IaNqe9a(jobbmUumrA{HsAl zZaXm1{Z7JS-(9v~qML5Yr>cp|m|&2lKain_E0Li{8a_-IZzdpmK~B zZefU9oQMb(QQvK*0sR4G&H7R)mBo6f%WgTL4bx{6Oi4M;D^a1)RS!-FQVH41!peDQ zd#)#H*~T6yXK-(9v!p9XboJoD#fLo^$Fi1V@X=BKy8PiN>(!HrDm11?)?oY6DPh9L zDrf$v`%Nr+^lQ(m4PhYS>8Zm8mLhRB2I-ble$6Zx0m4G!JYhJYs)D1Qai9vd%Cp!T zR$5$S`Vt;LYp8yjIYzUBYw&k6iHm^Dp2Kbo5j$KSNH8zKm1QQDXYmc?b9ET;#bpdQ9LY#EsR4cc9xpBsio@Wq z4lBnflJ|CgkDFZN&OcdnPhPz~j72Z+1Jqpe58NdI%cQ=Ta|qNmovWZl;~|*YU&*-k z&4g>I)(JU((r(|K;zy4#%{%wk(xvApppkWerF#h{#OCO~gDzK`%I&LQE*E`$)OuYa|0z6wMno{g<>vNKtX#q3&24ETEv zhto~6(OZO}Q|+8AT1u3Vfq+ZZ85vmM3-S)m9}{f*y}WkFC{S)zh{}nZkpsCRaxVn$ zTgr&s9UbKl)uxkjikD;7KLTvY#l6~rsDchYOaPgP@Kp8SL9yy@WBG+W_lyOxz?Hd@ z4MqoD7swAY#t+K7EDeJ?;tElrB)#(DjA7Ek@CC?10BJJK*hag{=Tpr zayM!sq9L(-rrnACXXyy}l&NDdbiy&!{p)$~%y%qkWFkY)qckc$ATo5zUsxCQUHO!o z{$+e;sQk(=gF4b2#@Yo0HhcJyo$W_X1TuUC;K~b;v;ZlVG*G0F)6`;X*K6B<0)fOM zhhbra9m*F%(j*5IwJF;fjQD2Cp@Qhv$Au*5MwAT9tY^xZ^gFQ=y#a%Vd5}P%oa{St3n}NJo=Jg51B-x}@VKePOCjg;8g8E@PMO3% zzyKSGk!ssv{t#EZZ_rk7TCzuDET{ap9DfH>W{IfCRuUp>I9ZQG0(C1I8UF;lh9OuPVdTIccmUDfBCOmYB?WX7fnp!itvo*!> zsqcC5uBWc0bI{^_Ge6P7Gn^oN<kZ;=dT}LF$sJdF3@G{I_wr zK_+5<3=05MkpKWd00bL^4~|<@aGn+;jM)5u^V)flv$TI_M0*HZY>(fu2AZZTF;86x zxqxy=$o`@nZFUfB%({%rE}9-)9rl%Nix>{b=yNQ|6jthL$i*x=cg+lX@OiCJg7_Zx zONuI*FBFP)Wv!j8wJnZZek~g(+jsOcNZDUz$-M1%Io;1Z6o@@?29p;KHyY1TuqP}& z1)w~eihQohiX8EM`;GO&T{sxj6hF-SCz)>k{OX!`?KMcsFzJ-{(zagL>$d3mcTh`^ zY}=>QBKGO4Z^BtdDMz#pzb)$rgUTOjIME~L-vymU9*HuGqCbm}7Hwj&4dNK3fX0XC zKRu0SpqANoWbn4h2!M6Ws|o@3VqPJ~u!{)4Ni6sVoDzpUTPVT|=o*{u{P8ILsm=sV zP7w}@0M9^5e-yW5lS0}7&Cpi8(DW1&BfKfo%bznwG?{Hnr4gU_J>mYjet!G$p&Ua(!D7Ck@nU?f&{Df zg(ggq1dnAS&!6*!*J>_&#)q~)FeluynDFkN=Op+0Ymq~BeOs(|zgvf3A_xKxRL)zO zOhi#UkOLStB;Nvb#;_a=LGA_fj&#_uZ{l7ivM`b*fW;6h&EOzZmp~Zn62S8}lhcko zSbV%H@Gp6n6F?=Zk0xG2B;54B!g>u8=eGxNi`t;w(F%L2iBUneVKn>3At^*H#!MV*>+|9eGH$>RUXr-ymlZ@sv1_|P&Wu3QFGGts7jlT`jwe5VKjN&d=Qufq^n%KO zOS&wZBs68EN2QwkU7OR5a5TYjw6%=)wENXglVA8<$yt?%31Ve6K7U9M`JPxUttfLz zJ&eDCY-tK&=>`*4*lNVRs}LcswGD@#p-y^QV50b;$0NV()ba%d3N{X>TBXNOj7cLe zkY`XxQ#%xF;oj@$np~$yE<9Z2W)9YF|L6pV_xwjF!m#D)@vG;M{Cpro`VvlbKvGhq zIh1I&O(}dhrmX*5^h+m_1V+-L_>MgkV|egfWHAwIs9Q}HhnJHAYv23N#5He~2zpG- zVoTaPlKtG`Ai&#zK{_eeAsevp;McbiT7Od@Xm6pho27$0JKI0B{Oh=U&wH#sXebnG zuT9At4|~yro%&eGA}}~j@pXa@_p9znTdTM#%S;neLi+t)=6ZldGs!BY5l3NsOQ@%6 zsF?GBSDWftfRD~HV(Gq)Gi+!eLp#aN4}1R}g1^&rXW32#>L>+XZW>_rJY*AcL?32)k!_geOV3 z^x$#3?)T_@(6H99$w1esI1ZU?qrUI+<^BM3X0VMJl7fC_eeS>y-R~Z$2I#rr?NIvR z<<&;$%sVb;vIa|FlvLH5T5pRa@6Hj8i8#FifERU%yzcV1h~ zHUD6)=RKZsoFyYG#VH+#3Jm&y`gmst5b<{vzla-0`WoPU?pHQ)FHE~CzZ=asXA$ZW z2>j|N7^9Egn}J@G7C%FIG2+UY9o^bGJr$TT&PZ001rkVnTxl zF8?;H49fxhd`s8X{ZpSy-NiK4)MV4L7_? z)QT>{Y#&W4HM2HbW_+y!qwH6(T4qH7NxjIyOAA-;@fMc8?MxYwR{N8M>$UX-3V2R=tD zsseArg&S$0nDXb6cFp*OtLfqtF=8|YY>kP!GO^?k5EUJUXwIvJ0>!4=9b0}K_`z%o z7E+(e^^Q12cTe$d$i+bl{yK*`TlQG7A4{CU0q~V)Hs|K_7imoEbau2XSSO z;^2Mfc6Wg89{kyG|9#QzdDx~KC8r1V9DXybz4$hJ4?%D=cPIC6QYU& z3@q^N`~Ghysej2M00K(`gw>=?A|-|F(qNJPYli;s1ifM3E*Jpi8}*}mn=VmUHd@FJ z8VZv>#x%XdnZ$q=cMr7@;=|)_-B>0gxwJVOVN-h#(z1tRy_do(=@_ p)<8Nu*lk!y5uGT_Kk)zc)^mT8a|FbH7}#$&5