1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
use crate::data::dataframe::DataFrame;
use crate::data::swap;
use crate::types::SheetType;
use crate::ui::text_input::TextInput;
use ratatui::widgets::{ScrollbarState, TableState};
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::TempDir;
/// A single data sheet in the stack — owns its DataFrame and all view state.
pub struct Sheet {
/// Human-readable title shown in the table border
pub title: String,
/// The actual data
pub dataframe: DataFrame,
/// Stack of previous DataFrame states for Undo functionality
pub undo_stack: Vec<DataFrame>,
/// ratatui row selection state
pub table_state: TableState,
/// Currently highlighted column
pub cursor_col: usize,
/// Vertical scrollbar state
pub scroll_state: ScrollbarState,
/// The physical row index of the top-most visible row (for virtualized rendering).
pub top_row: usize,
/// The index of the left-most visible column (for horizontal scrolling).
pub left_col: usize,
// ── Sort state ────────────────────────────────────────────────────────────
pub sort_col: Option<usize>,
pub sort_desc: bool,
// ── Search state (/) ──────────────────────────────────────────────────────
pub search_input: TextInput,
pub search_pattern: Option<String>,
pub search_col: Option<usize>,
// ── Select by regex state (|) ─────────────────────────────────────────────
pub select_regex_input: TextInput,
// ── Expression state (=) ──────────────────────────────────────────────────
pub expr_input: TextInput,
// ── Cell edit state ───────────────────────────────────────────────────────
pub edit_input: TextInput,
pub edit_row: usize,
pub edit_col: usize,
// ── Z Prefix state ────────────────────────────────────────────────────────
pub rename_column_input: TextInput,
pub insert_column_input: TextInput,
/// True if this sheet represents a directory listing
pub is_dir_sheet: bool,
// ── Pivot Table ───────────────────────────────────────────────────────────
pub pivot_input: TextInput,
pub sheet_type: SheetType,
}
impl Sheet {
/// Create a new Sheet with given title and data.
pub fn new(title: String, dataframe: DataFrame) -> Self {
let row_count = dataframe.visible_row_count();
Self {
title,
dataframe,
undo_stack: Vec::new(),
table_state: TableState::default()
.with_selected(0)
.with_selected_column(0),
cursor_col: 0,
scroll_state: ScrollbarState::new(row_count.saturating_sub(1)),
top_row: 0,
left_col: 0,
sort_col: None,
sort_desc: false,
search_input: TextInput::new(),
search_pattern: None,
search_col: None,
select_regex_input: TextInput::new(),
expr_input: TextInput::new(),
edit_input: TextInput::new(),
edit_row: 0,
edit_col: 0,
rename_column_input: TextInput::new(),
insert_column_input: TextInput::new(),
is_dir_sheet: false,
pivot_input: TextInput::new(),
sheet_type: SheetType::Normal,
}
}
/// Push current DataFrame state to undo stack (max 50)
pub fn push_undo(&mut self) {
if self.undo_stack.len() >= 50 {
self.undo_stack.remove(0);
}
self.undo_stack.push(self.dataframe.clone());
}
/// Pop previous DataFrame state from undo stack
pub fn pop_undo(&mut self) -> bool {
if let Some(df) = self.undo_stack.pop() {
self.dataframe = df;
// Ensure cursor/selection bounds are valid for restored dataframe
let cols = self.dataframe.columns.len();
let rows = self.dataframe.visible_row_count();
if self.cursor_col >= cols && cols > 0 {
self.cursor_col = cols.saturating_sub(1);
}
if let Some(s) = self.table_state.selected() {
if s >= rows && rows > 0 {
self.table_state.select(Some(rows.saturating_sub(1)));
}
}
true
} else {
false
}
}
}
/// The topmost sheet is always the active one.
/// Sheets that are not the top are offloaded to disk to save memory.
pub struct SheetStack {
/// All sheets. The last element is the active (top) sheet.
sheets: Vec<Sheet>,
/// Temporary directory owning all swap files — auto-deleted on drop.
_swap_dir: TempDir,
swap_root: PathBuf,
/// Maps sheet stack index → path of its serialized DataFrame swap file.
swapped: HashMap<usize, PathBuf>,
}
impl SheetStack {
/// Create a new stack with a single root sheet.
pub fn new(root_sheet: Sheet) -> Self {
let swap_dir = TempDir::new().expect("Failed to create temp dir for sheet swap");
let swap_root = swap_dir.path().to_path_buf();
Self {
sheets: vec![root_sheet],
_swap_dir: swap_dir,
swap_root,
swapped: HashMap::new(),
}
}
/// Reference to the active (topmost) sheet.
pub fn active(&self) -> &Sheet {
self.sheets.last().expect("Sheet stack must never be empty")
}
/// Mutable reference to the active (topmost) sheet.
pub fn active_mut(&mut self) -> &mut Sheet {
self.sheets
.last_mut()
.expect("Sheet stack must never be empty")
}
/// Depth of the stack (1 = only root sheet).
pub fn depth(&self) -> usize {
self.sheets.len()
}
/// True if there is more than one sheet and we can pop.
pub fn can_pop(&self) -> bool {
self.sheets.len() > 1
}
/// Push a new sheet on top.
/// The previous top sheet's DataFrame is offloaded to disk to free memory.
pub fn push(&mut self, sheet: Sheet) {
let prev_idx = self.sheets.len() - 1;
self.swap_out(prev_idx);
self.sheets.push(sheet);
}
/// Pop and return the top sheet.
/// The new top sheet's DataFrame is restored from disk if it was swapped.
/// Panics if only the root sheet remains.
pub fn pop(&mut self) -> Sheet {
assert!(self.sheets.len() > 1, "Cannot pop the root sheet");
let popped = self.sheets.pop().unwrap();
let new_top = self.sheets.len() - 1;
self.swap_in(new_top);
popped
}
/// Read a clone of the DataFrame one level below the active sheet (parent).
/// Briefly swaps it in if it was on disk.
pub fn clone_parent_dataframe(&mut self) -> Option<DataFrame> {
let depth = self.sheets.len();
if depth < 2 {
return None;
}
let parent_idx = depth - 2;
let was_swapped = self.swapped.contains_key(&parent_idx);
if was_swapped {
self.swap_in(parent_idx);
}
let df = self.sheets[parent_idx].dataframe.clone();
if was_swapped {
self.swap_out(parent_idx);
}
Some(df)
}
// ── Disk swap internals ───────────────────────────────────────────────────
fn swap_out(&mut self, idx: usize) {
if self.swapped.contains_key(&idx) {
return; // already on disk
}
let path = self.swap_root.join(format!("sheet_{}.bin", idx));
swap::swap_out(&self.sheets[idx].dataframe, &path)
.expect("Failed to write sheet data to disk");
// Replace with an empty placeholder to free heap memory
self.sheets[idx].dataframe = DataFrame::empty();
self.swapped.insert(idx, path);
}
fn swap_in(&mut self, idx: usize) {
if let Some(path) = self.swapped.remove(&idx) {
let df = swap::swap_in(&path).expect("Failed to read sheet data from disk");
self.sheets[idx].dataframe = df;
let _ = std::fs::remove_file(&path);
}
}
}