dataframe-textual 0.1.0__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of dataframe-textual might be problematic. Click here for more details.
- dataframe_textual/__main__.py +65 -0
- dataframe_textual/common.py +340 -0
- {dataframe_viewer → dataframe_textual}/data_frame_help_panel.py +22 -4
- dataframe_textual/data_frame_table.py +2768 -0
- dataframe_textual/data_frame_viewer.py +472 -0
- dataframe_textual/table_screen.py +490 -0
- dataframe_textual/yes_no_screen.py +672 -0
- dataframe_textual-1.1.0.dist-info/METADATA +726 -0
- dataframe_textual-1.1.0.dist-info/RECORD +13 -0
- dataframe_textual-1.1.0.dist-info/entry_points.txt +2 -0
- dataframe_textual-0.1.0.dist-info/METADATA +0 -522
- dataframe_textual-0.1.0.dist-info/RECORD +0 -13
- dataframe_textual-0.1.0.dist-info/entry_points.txt +0 -2
- dataframe_viewer/__main__.py +0 -48
- dataframe_viewer/common.py +0 -204
- dataframe_viewer/data_frame_table.py +0 -1395
- dataframe_viewer/data_frame_viewer.py +0 -320
- dataframe_viewer/table_screen.py +0 -311
- dataframe_viewer/yes_no_screen.py +0 -409
- {dataframe_viewer → dataframe_textual}/__init__.py +0 -0
- {dataframe_textual-0.1.0.dist-info → dataframe_textual-1.1.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-0.1.0.dist-info → dataframe_textual-1.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,1395 +0,0 @@
|
|
|
1
|
-
"""DataFrameTable widget for displaying and interacting with Polars DataFrames."""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
from collections import deque
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from textwrap import dedent
|
|
8
|
-
|
|
9
|
-
import polars as pl
|
|
10
|
-
from rich.text import Text
|
|
11
|
-
from textual.coordinate import Coordinate
|
|
12
|
-
from textual.widgets import DataTable
|
|
13
|
-
from textual.widgets._data_table import (
|
|
14
|
-
CellDoesNotExist,
|
|
15
|
-
CellKey,
|
|
16
|
-
ColumnKey,
|
|
17
|
-
CursorType,
|
|
18
|
-
RowKey,
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
from .common import (
|
|
22
|
-
BATCH_SIZE,
|
|
23
|
-
BOOLS,
|
|
24
|
-
CURSOR_TYPES,
|
|
25
|
-
INITIAL_BATCH_SIZE,
|
|
26
|
-
SUBSCRIPT_DIGITS,
|
|
27
|
-
DtypeConfig,
|
|
28
|
-
_format_row,
|
|
29
|
-
_next,
|
|
30
|
-
_rindex,
|
|
31
|
-
)
|
|
32
|
-
from .table_screen import FrequencyScreen, RowDetailScreen
|
|
33
|
-
from .yes_no_screen import (
|
|
34
|
-
ConfirmScreen,
|
|
35
|
-
EditCellScreen,
|
|
36
|
-
FilterScreen,
|
|
37
|
-
FreezeScreen,
|
|
38
|
-
SaveFileScreen,
|
|
39
|
-
SearchScreen,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@dataclass
|
|
44
|
-
class History:
|
|
45
|
-
"""Class to track history of dataframe states for undo/redo functionality."""
|
|
46
|
-
|
|
47
|
-
description: str
|
|
48
|
-
df: pl.DataFrame
|
|
49
|
-
filename: str
|
|
50
|
-
loaded_rows: int
|
|
51
|
-
sorted_columns: dict[str, bool]
|
|
52
|
-
selected_rows: list[bool]
|
|
53
|
-
visible_rows: list[bool]
|
|
54
|
-
fixed_rows: int
|
|
55
|
-
fixed_columns: int
|
|
56
|
-
cursor_coordinate: Coordinate
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class DataFrameTable(DataTable):
|
|
60
|
-
"""Custom DataTable to highlight row/column labels based on cursor position."""
|
|
61
|
-
|
|
62
|
-
# Help text for the DataTable which will be shown in the HelpPanel
|
|
63
|
-
HELP = dedent("""
|
|
64
|
-
# 📊 DataFrame Viewer - Table Controls
|
|
65
|
-
|
|
66
|
-
## ⬆️ Navigation
|
|
67
|
-
- **↑↓←→** - 🎯 Move cursor (cell/row/column)
|
|
68
|
-
- **g** - ⬆️ Jump to first row
|
|
69
|
-
- **G** - ⬇️ Jump to last row
|
|
70
|
-
- **PgUp/PgDn** - 📜 Page up/down
|
|
71
|
-
|
|
72
|
-
## 👁️ View & Display
|
|
73
|
-
- **Enter** - 📋 Show row details in modal
|
|
74
|
-
- **F** - 📊 Show frequency distribution
|
|
75
|
-
- **C** - 🔄 Cycle cursor (cell → row → column → cell)
|
|
76
|
-
- **#** - 🏷️ Toggle row labels
|
|
77
|
-
|
|
78
|
-
## ↕️ Sorting
|
|
79
|
-
- **[** - 🔼 Sort column ascending
|
|
80
|
-
- **]** - 🔽 Sort column descending
|
|
81
|
-
- *(Multi-column sort supported)*
|
|
82
|
-
|
|
83
|
-
## 🔍 Search
|
|
84
|
-
- **|** - 🔎 Search in current column
|
|
85
|
-
- **/** - 🌐 Global search (all columns)
|
|
86
|
-
- **\\\\** - 🔍 Search using current cell value
|
|
87
|
-
|
|
88
|
-
## 🔧 Filter & Select
|
|
89
|
-
- **s** - ✓️ Select/deselect current row
|
|
90
|
-
- **t** - 💡 Toggle row selection (invert all)
|
|
91
|
-
- **"** - 📍 Filter to selected rows only
|
|
92
|
-
- **T** - 🧹 Clear all selections
|
|
93
|
-
- **v** - 🎯 Filter by selected rows or current cell value
|
|
94
|
-
- **V** - 🔧 Filter by Polars expression
|
|
95
|
-
|
|
96
|
-
## ✏️ Edit & Modify
|
|
97
|
-
- **e** - ✍️ Edit current cell
|
|
98
|
-
- **d** - 🗑️ Delete current row
|
|
99
|
-
- **-** - ❌ Delete current column
|
|
100
|
-
|
|
101
|
-
## 🎯 Reorder
|
|
102
|
-
- **Shift+↑↓** - ⬆️⬇️ Move row up/down
|
|
103
|
-
- **Shift+←→** - ⬅️➡️ Move column left/right
|
|
104
|
-
|
|
105
|
-
## 💾 Data Management
|
|
106
|
-
- **f** - 📌 Freeze rows/columns
|
|
107
|
-
- **c** - 📋 Copy cell to clipboard
|
|
108
|
-
- **Ctrl+S** - 💾 Save current tabto file
|
|
109
|
-
- **u** - ↩️ Undo last action
|
|
110
|
-
- **U** - 🔄 Reset to original data
|
|
111
|
-
|
|
112
|
-
*Use `?` to see app-level controls*
|
|
113
|
-
""").strip()
|
|
114
|
-
|
|
115
|
-
def __init__(
|
|
116
|
-
self,
|
|
117
|
-
df: pl.DataFrame,
|
|
118
|
-
filename: str = "",
|
|
119
|
-
tabname: str = "",
|
|
120
|
-
**kwargs,
|
|
121
|
-
):
|
|
122
|
-
"""Initialize the DataFrameTable with a dataframe and manage all state.
|
|
123
|
-
|
|
124
|
-
Args:
|
|
125
|
-
df: The Polars DataFrame to display
|
|
126
|
-
filename: Optional filename of the source CSV
|
|
127
|
-
kwargs: Additional keyword arguments for DataTable
|
|
128
|
-
"""
|
|
129
|
-
super().__init__(**kwargs)
|
|
130
|
-
|
|
131
|
-
# DataFrame state
|
|
132
|
-
self.dataframe = df # Original dataframe
|
|
133
|
-
self.df = df # Internal/working dataframe
|
|
134
|
-
self.filename = filename # Current filename
|
|
135
|
-
self.tabname = tabname or Path(filename).stem # Current tab name
|
|
136
|
-
|
|
137
|
-
# Pagination & Loading
|
|
138
|
-
self.loaded_rows = 0 # Track how many rows are currently loaded
|
|
139
|
-
|
|
140
|
-
# State tracking (all 0-based indexing)
|
|
141
|
-
self.sorted_columns: dict[str, bool] = {} # col_name -> descending
|
|
142
|
-
self.selected_rows: list[bool] = [False] * len(df) # Track selected rows
|
|
143
|
-
self.visible_rows: list[bool] = [True] * len(
|
|
144
|
-
df
|
|
145
|
-
) # Track visible rows (for filtering)
|
|
146
|
-
|
|
147
|
-
# Freezing
|
|
148
|
-
self.fixed_rows = 0 # Number of fixed rows
|
|
149
|
-
self.fixed_columns = 0 # Number of fixed columns
|
|
150
|
-
|
|
151
|
-
# History stack for undo/redo
|
|
152
|
-
self.histories: deque[History] = deque()
|
|
153
|
-
|
|
154
|
-
# Pending filename for save operations
|
|
155
|
-
self._pending_filename = ""
|
|
156
|
-
|
|
157
|
-
@property
|
|
158
|
-
def cursor_key(self) -> CellKey:
|
|
159
|
-
"""Get the current cursor position as a CellKey."""
|
|
160
|
-
return self.coordinate_to_cell_key(self.cursor_coordinate)
|
|
161
|
-
|
|
162
|
-
@property
|
|
163
|
-
def cursor_row_key(self) -> RowKey:
|
|
164
|
-
"""Get the current cursor row as a CellKey."""
|
|
165
|
-
return self.cursor_key.row_key
|
|
166
|
-
|
|
167
|
-
@property
|
|
168
|
-
def cursor_column_key(self) -> ColumnKey:
|
|
169
|
-
"""Get the current cursor column as a ColumnKey."""
|
|
170
|
-
return self.cursor_key.column_key
|
|
171
|
-
|
|
172
|
-
@property
|
|
173
|
-
def cursor_row_index(self) -> int:
|
|
174
|
-
"""Get the current cursor row index (0-based)."""
|
|
175
|
-
return int(self.cursor_row_key.value) - 1
|
|
176
|
-
|
|
177
|
-
def on_mount(self) -> None:
|
|
178
|
-
"""Initialize table display when widget is mounted."""
|
|
179
|
-
self._setup_table()
|
|
180
|
-
|
|
181
|
-
def _should_highlight(
|
|
182
|
-
self,
|
|
183
|
-
cursor: Coordinate,
|
|
184
|
-
target_cell: Coordinate,
|
|
185
|
-
type_of_cursor: CursorType,
|
|
186
|
-
) -> bool:
|
|
187
|
-
"""Determine if the given cell should be highlighted because of the cursor.
|
|
188
|
-
|
|
189
|
-
In "cell" mode, also highlights the row and column headers. In "row" and "column"
|
|
190
|
-
modes, highlights the entire row or column respectively.
|
|
191
|
-
|
|
192
|
-
Args:
|
|
193
|
-
cursor: The current position of the cursor.
|
|
194
|
-
target_cell: The cell we're checking for the need to highlight.
|
|
195
|
-
type_of_cursor: The type of cursor that is currently active.
|
|
196
|
-
|
|
197
|
-
Returns:
|
|
198
|
-
Whether or not the given cell should be highlighted.
|
|
199
|
-
"""
|
|
200
|
-
if type_of_cursor == "cell":
|
|
201
|
-
# Return true if the cursor is over the target cell
|
|
202
|
-
# This includes the case where the cursor is in the same row or column
|
|
203
|
-
return (
|
|
204
|
-
cursor == target_cell
|
|
205
|
-
or (target_cell.row == -1 and target_cell.column == cursor.column)
|
|
206
|
-
or (target_cell.column == -1 and target_cell.row == cursor.row)
|
|
207
|
-
)
|
|
208
|
-
elif type_of_cursor == "row":
|
|
209
|
-
cursor_row, _ = cursor
|
|
210
|
-
cell_row, _ = target_cell
|
|
211
|
-
return cursor_row == cell_row
|
|
212
|
-
elif type_of_cursor == "column":
|
|
213
|
-
_, cursor_column = cursor
|
|
214
|
-
_, cell_column = target_cell
|
|
215
|
-
return cursor_column == cell_column
|
|
216
|
-
else:
|
|
217
|
-
return False
|
|
218
|
-
|
|
219
|
-
def watch_cursor_coordinate(
|
|
220
|
-
self, old_coordinate: Coordinate, new_coordinate: Coordinate
|
|
221
|
-
) -> None:
|
|
222
|
-
"""Refresh highlighting when cursor coordinate changes.
|
|
223
|
-
|
|
224
|
-
This explicitly refreshes cells that need to change their highlight state
|
|
225
|
-
to fix the delay issue with column label highlighting. Also emits CellSelected
|
|
226
|
-
message when cursor type is "cell" for keyboard navigation only (mouse clicks
|
|
227
|
-
already trigger the parent class's CellSelected message).
|
|
228
|
-
"""
|
|
229
|
-
if old_coordinate != new_coordinate:
|
|
230
|
-
# Emit CellSelected message for cell cursor type (keyboard navigation only)
|
|
231
|
-
# Only emit if this is from keyboard navigation (flag is True when from keyboard)
|
|
232
|
-
if self.cursor_type == "cell" and getattr(self, "_from_keyboard", False):
|
|
233
|
-
self._from_keyboard = False # Reset flag
|
|
234
|
-
try:
|
|
235
|
-
self._post_selected_message()
|
|
236
|
-
except CellDoesNotExist:
|
|
237
|
-
# This could happen when after calling clear(), the old coordinate is invalid
|
|
238
|
-
pass
|
|
239
|
-
|
|
240
|
-
# For cell cursor type, refresh old and new row/column headers
|
|
241
|
-
if self.cursor_type == "cell":
|
|
242
|
-
old_row, old_col = old_coordinate
|
|
243
|
-
new_row, new_col = new_coordinate
|
|
244
|
-
|
|
245
|
-
# Refresh entire column (not just header) to ensure proper highlighting
|
|
246
|
-
self.refresh_column(old_col)
|
|
247
|
-
self.refresh_column(new_col)
|
|
248
|
-
|
|
249
|
-
# Refresh entire row (not just header) to ensure proper highlighting
|
|
250
|
-
self.refresh_row(old_row)
|
|
251
|
-
self.refresh_row(new_row)
|
|
252
|
-
elif self.cursor_type == "row":
|
|
253
|
-
self.refresh_row(old_coordinate.row)
|
|
254
|
-
self.refresh_row(new_coordinate.row)
|
|
255
|
-
elif self.cursor_type == "column":
|
|
256
|
-
self.refresh_column(old_coordinate.column)
|
|
257
|
-
self.refresh_column(new_coordinate.column)
|
|
258
|
-
|
|
259
|
-
# Handle scrolling if needed
|
|
260
|
-
if self._require_update_dimensions:
|
|
261
|
-
self.call_after_refresh(self._scroll_cursor_into_view)
|
|
262
|
-
else:
|
|
263
|
-
self._scroll_cursor_into_view()
|
|
264
|
-
|
|
265
|
-
def on_key(self, event) -> None:
|
|
266
|
-
"""Handle keyboard events for table operations and navigation."""
|
|
267
|
-
if event.key == "g":
|
|
268
|
-
# Jump to top
|
|
269
|
-
self.move_cursor(row=0)
|
|
270
|
-
elif event.key == "G":
|
|
271
|
-
# Load all remaining rows before jumping to end
|
|
272
|
-
self._load_rows()
|
|
273
|
-
self.move_cursor(row=self.row_count - 1)
|
|
274
|
-
elif event.key in ("pagedown", "down"):
|
|
275
|
-
# Let the table handle the navigation first
|
|
276
|
-
self._check_and_load_more()
|
|
277
|
-
elif event.key == "enter":
|
|
278
|
-
# Open row detail modal
|
|
279
|
-
self._view_row_detail()
|
|
280
|
-
elif event.key == "minus":
|
|
281
|
-
# Remove the current column
|
|
282
|
-
self._delete_column()
|
|
283
|
-
elif event.key == "left_square_bracket": # '['
|
|
284
|
-
# Sort by current column in ascending order
|
|
285
|
-
self._sort_by_column(descending=False)
|
|
286
|
-
elif event.key == "right_square_bracket": # ']'
|
|
287
|
-
# Sort by current column in descending order
|
|
288
|
-
self._sort_by_column(descending=True)
|
|
289
|
-
elif event.key == "ctrl+s":
|
|
290
|
-
# Save dataframe to CSV
|
|
291
|
-
self._save_to_file()
|
|
292
|
-
elif event.key == "F": # shift+f
|
|
293
|
-
# Open frequency modal for current column
|
|
294
|
-
self._show_frequency()
|
|
295
|
-
elif event.key == "v":
|
|
296
|
-
# Filter by current cell value
|
|
297
|
-
self._filter_rows()
|
|
298
|
-
elif event.key == "V": # shift+v
|
|
299
|
-
# Open filter screen for current column
|
|
300
|
-
self._open_filter_screen()
|
|
301
|
-
elif event.key == "e":
|
|
302
|
-
# Open edit modal for current cell
|
|
303
|
-
self._edit_cell()
|
|
304
|
-
elif event.key == "backslash": # '\' key
|
|
305
|
-
# Search with current cell value and highlight matched rows
|
|
306
|
-
self._search_with_cell_value()
|
|
307
|
-
elif event.key == "vertical_line": # '|' key
|
|
308
|
-
# Open search modal for current column
|
|
309
|
-
self._search_column()
|
|
310
|
-
elif event.key == "slash": # '/' key
|
|
311
|
-
# Open search modal for all columns
|
|
312
|
-
self._search_column(all_columns=True)
|
|
313
|
-
elif event.key == "s":
|
|
314
|
-
# Toggle selection for current row
|
|
315
|
-
self._toggle_selected_rows(current_row=True)
|
|
316
|
-
elif event.key == "t":
|
|
317
|
-
# Toggle selected rows highlighting
|
|
318
|
-
self._toggle_selected_rows()
|
|
319
|
-
elif event.key == "quotation_mark": # '"' key
|
|
320
|
-
# Display selected rows only
|
|
321
|
-
self._filter_selected_rows()
|
|
322
|
-
elif event.key == "d":
|
|
323
|
-
# Delete the current row
|
|
324
|
-
self._delete_row()
|
|
325
|
-
elif event.key == "u":
|
|
326
|
-
# Undo last action
|
|
327
|
-
self._undo()
|
|
328
|
-
elif event.key == "U":
|
|
329
|
-
# Undo all changes and restore original dataframe
|
|
330
|
-
self._setup_table(reset=True)
|
|
331
|
-
self.app.notify("Restored original display", title="Reset")
|
|
332
|
-
elif event.key == "shift+left": # shift + left arrow
|
|
333
|
-
# Move current column to the left
|
|
334
|
-
self._move_column("left")
|
|
335
|
-
elif event.key == "shift+right": # shift + right arrow
|
|
336
|
-
# Move current column to the right
|
|
337
|
-
self._move_column("right")
|
|
338
|
-
elif event.key == "shift+up": # shift + up arrow
|
|
339
|
-
# Move current row up
|
|
340
|
-
self._move_row("up")
|
|
341
|
-
elif event.key == "shift+down": # shift + down arrow
|
|
342
|
-
# Move current row down
|
|
343
|
-
self._move_row("down")
|
|
344
|
-
elif event.key == "T": # shift+t
|
|
345
|
-
# Clear all selected rows
|
|
346
|
-
self._clear_selected_rows()
|
|
347
|
-
elif event.key == "C": # shift+c
|
|
348
|
-
# Cycle through cursor types
|
|
349
|
-
self._cycle_cursor_type()
|
|
350
|
-
elif event.key == "f":
|
|
351
|
-
# Open pin screen to set fixed rows and columns
|
|
352
|
-
self._open_freeze_screen()
|
|
353
|
-
|
|
354
|
-
def on_mouse_scroll_down(self, event) -> None:
|
|
355
|
-
"""Load more rows when scrolling down with mouse."""
|
|
356
|
-
self._check_and_load_more()
|
|
357
|
-
|
|
358
|
-
# Setup & Loading
|
|
359
|
-
def _setup_table(self, reset: bool = False) -> None:
|
|
360
|
-
"""Setup the table for display."""
|
|
361
|
-
# Reset to original dataframe
|
|
362
|
-
if reset:
|
|
363
|
-
self.df = self.dataframe
|
|
364
|
-
self.loaded_rows = 0
|
|
365
|
-
self.sorted_columns = {}
|
|
366
|
-
self.selected_rows = [False] * len(self.df)
|
|
367
|
-
self.visible_rows = [True] * len(self.df)
|
|
368
|
-
self.fixed_rows = 0
|
|
369
|
-
self.fixed_columns = 0
|
|
370
|
-
|
|
371
|
-
# Lazy load up to INITIAL_BATCH_SIZE visible rows
|
|
372
|
-
stop, visible_count = len(self.df), 0
|
|
373
|
-
for row_idx, visible in enumerate(self.visible_rows):
|
|
374
|
-
if not visible:
|
|
375
|
-
continue
|
|
376
|
-
visible_count += 1
|
|
377
|
-
if visible_count >= INITIAL_BATCH_SIZE:
|
|
378
|
-
stop = row_idx + 1
|
|
379
|
-
break
|
|
380
|
-
|
|
381
|
-
self._setup_columns()
|
|
382
|
-
self._load_rows(stop)
|
|
383
|
-
self._highlight_rows()
|
|
384
|
-
|
|
385
|
-
# Restore cursor position
|
|
386
|
-
row_idx, col_idx = self.cursor_coordinate
|
|
387
|
-
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
388
|
-
self.move_cursor(row=row_idx, column=col_idx)
|
|
389
|
-
|
|
390
|
-
def _setup_columns(self) -> None:
|
|
391
|
-
"""Clear table and setup columns."""
|
|
392
|
-
self.loaded_rows = 0
|
|
393
|
-
self.clear(columns=True)
|
|
394
|
-
self.show_row_labels = True
|
|
395
|
-
|
|
396
|
-
# Add columns with justified headers
|
|
397
|
-
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
398
|
-
for idx, c in enumerate(self.sorted_columns, 1):
|
|
399
|
-
if c == col:
|
|
400
|
-
# Add sort indicator to column header
|
|
401
|
-
descending = self.sorted_columns[col]
|
|
402
|
-
sort_indicator = (
|
|
403
|
-
f" ▼{SUBSCRIPT_DIGITS.get(idx, '')}"
|
|
404
|
-
if descending
|
|
405
|
-
else f" ▲{SUBSCRIPT_DIGITS.get(idx, '')}"
|
|
406
|
-
)
|
|
407
|
-
header_text = col + sort_indicator
|
|
408
|
-
self.add_column(
|
|
409
|
-
Text(header_text, justify=DtypeConfig(dtype).justify), key=col
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
break
|
|
413
|
-
else: # No break occurred, so column is not sorted
|
|
414
|
-
self.add_column(Text(col, justify=DtypeConfig(dtype).justify), key=col)
|
|
415
|
-
|
|
416
|
-
def _check_and_load_more(self) -> None:
|
|
417
|
-
"""Check if we need to load more rows and load them."""
|
|
418
|
-
# If we've loaded everything, no need to check
|
|
419
|
-
if self.loaded_rows >= len(self.df):
|
|
420
|
-
return
|
|
421
|
-
|
|
422
|
-
visible_row_count = self.size.height - self.header_height
|
|
423
|
-
bottom_visible_row = self.scroll_y + visible_row_count
|
|
424
|
-
|
|
425
|
-
# If visible area is close to the end of loaded rows, load more
|
|
426
|
-
if bottom_visible_row >= self.loaded_rows - 10:
|
|
427
|
-
self._load_rows(self.loaded_rows + BATCH_SIZE)
|
|
428
|
-
|
|
429
|
-
def _load_rows(self, stop: int | None = None) -> None:
|
|
430
|
-
"""Load a batch of rows into the table.
|
|
431
|
-
|
|
432
|
-
Args:
|
|
433
|
-
stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
|
|
434
|
-
"""
|
|
435
|
-
if stop is None or stop > len(self.df):
|
|
436
|
-
stop = len(self.df)
|
|
437
|
-
|
|
438
|
-
if stop <= self.loaded_rows:
|
|
439
|
-
return
|
|
440
|
-
|
|
441
|
-
start = self.loaded_rows
|
|
442
|
-
df_slice = self.df.slice(start, stop - start)
|
|
443
|
-
|
|
444
|
-
for row_idx, row in enumerate(df_slice.rows(), start):
|
|
445
|
-
if not self.visible_rows[row_idx]:
|
|
446
|
-
continue # Skip hidden rows
|
|
447
|
-
vals, dtypes = [], []
|
|
448
|
-
for val, dtype in zip(row, self.df.dtypes):
|
|
449
|
-
vals.append(val)
|
|
450
|
-
dtypes.append(dtype)
|
|
451
|
-
formatted_row = _format_row(vals, dtypes)
|
|
452
|
-
# Always add labels so they can be shown/hidden via CSS
|
|
453
|
-
self.add_row(*formatted_row, key=str(row_idx + 1), label=str(row_idx + 1))
|
|
454
|
-
|
|
455
|
-
# Update loaded rows count
|
|
456
|
-
self.loaded_rows = stop
|
|
457
|
-
|
|
458
|
-
self.app.notify(
|
|
459
|
-
f"Loaded [$accent]{self.loaded_rows}/{len(self.df)}[/] rows from [on $primary]{self.tabname}[/]",
|
|
460
|
-
title="Load",
|
|
461
|
-
)
|
|
462
|
-
|
|
463
|
-
def _highlight_rows(self, clear: bool = False) -> None:
|
|
464
|
-
"""Update all rows, highlighting selected ones in red and restoring others to default.
|
|
465
|
-
|
|
466
|
-
Args:
|
|
467
|
-
clear: If True, clear all highlights.
|
|
468
|
-
"""
|
|
469
|
-
if True not in self.selected_rows:
|
|
470
|
-
return
|
|
471
|
-
|
|
472
|
-
if clear:
|
|
473
|
-
self.selected_rows = [False] * len(self.df)
|
|
474
|
-
|
|
475
|
-
# Ensure all highlighted rows are loaded
|
|
476
|
-
stop = _rindex(self.selected_rows, True) + 1
|
|
477
|
-
self._load_rows(stop)
|
|
478
|
-
|
|
479
|
-
# Update all rows based on selected state
|
|
480
|
-
for row in self.ordered_rows:
|
|
481
|
-
row_idx = int(row.key.value) - 1 # Convert to 0-based index
|
|
482
|
-
is_selected = self.selected_rows[row_idx]
|
|
483
|
-
|
|
484
|
-
# Update all cells in this row
|
|
485
|
-
for col_idx, col in enumerate(self.ordered_columns):
|
|
486
|
-
cell_text: Text = self.get_cell(row.key, col.key)
|
|
487
|
-
dtype = self.df.dtypes[col_idx]
|
|
488
|
-
|
|
489
|
-
# Get style config based on dtype
|
|
490
|
-
dc = DtypeConfig(dtype)
|
|
491
|
-
|
|
492
|
-
# Use red for selected rows, default style for others
|
|
493
|
-
style = "red" if is_selected else dc.style
|
|
494
|
-
cell_text.style = style
|
|
495
|
-
|
|
496
|
-
# Update the cell in the table
|
|
497
|
-
self.update_cell(row.key, col.key, cell_text)
|
|
498
|
-
|
|
499
|
-
# History & Undo
|
|
500
|
-
def _add_history(self, description: str) -> None:
|
|
501
|
-
"""Add the current state to the history stack.
|
|
502
|
-
|
|
503
|
-
Args:
|
|
504
|
-
description: Description of the action for this history entry.
|
|
505
|
-
"""
|
|
506
|
-
history = History(
|
|
507
|
-
description=description,
|
|
508
|
-
df=self.df,
|
|
509
|
-
filename=self.filename,
|
|
510
|
-
loaded_rows=self.loaded_rows,
|
|
511
|
-
sorted_columns=self.sorted_columns.copy(),
|
|
512
|
-
selected_rows=self.selected_rows.copy(),
|
|
513
|
-
visible_rows=self.visible_rows.copy(),
|
|
514
|
-
fixed_rows=self.fixed_rows,
|
|
515
|
-
fixed_columns=self.fixed_columns,
|
|
516
|
-
cursor_coordinate=self.cursor_coordinate,
|
|
517
|
-
)
|
|
518
|
-
self.histories.append(history)
|
|
519
|
-
|
|
520
|
-
def _undo(self) -> None:
|
|
521
|
-
"""Undo the last action."""
|
|
522
|
-
if not self.histories:
|
|
523
|
-
self.app.notify("No actions to undo", title="Undo", severity="warning")
|
|
524
|
-
return
|
|
525
|
-
|
|
526
|
-
history = self.histories.pop()
|
|
527
|
-
|
|
528
|
-
# Restore state
|
|
529
|
-
self.df = history.df
|
|
530
|
-
self.filename = history.filename
|
|
531
|
-
self.loaded_rows = history.loaded_rows
|
|
532
|
-
self.sorted_columns = history.sorted_columns.copy()
|
|
533
|
-
self.selected_rows = history.selected_rows.copy()
|
|
534
|
-
self.visible_rows = history.visible_rows.copy()
|
|
535
|
-
self.fixed_rows = history.fixed_rows
|
|
536
|
-
self.fixed_columns = history.fixed_columns
|
|
537
|
-
self.cursor_coordinate = history.cursor_coordinate
|
|
538
|
-
|
|
539
|
-
# Recreate the table for display
|
|
540
|
-
self._setup_table()
|
|
541
|
-
|
|
542
|
-
self.app.notify(f"Reverted: {history.description}", title="Undo")
|
|
543
|
-
|
|
544
|
-
# View
|
|
545
|
-
def _view_row_detail(self) -> None:
|
|
546
|
-
"""Open a modal screen to view the selected row's details."""
|
|
547
|
-
row_idx = self.cursor_row
|
|
548
|
-
if row_idx >= len(self.df):
|
|
549
|
-
return
|
|
550
|
-
|
|
551
|
-
# Push the modal screen
|
|
552
|
-
self.app.push_screen(RowDetailScreen(row_idx, self.df))
|
|
553
|
-
|
|
554
|
-
def _show_frequency(self) -> None:
|
|
555
|
-
"""Show frequency distribution for the current column."""
|
|
556
|
-
col_idx = self.cursor_column
|
|
557
|
-
if col_idx >= len(self.df.columns):
|
|
558
|
-
return
|
|
559
|
-
|
|
560
|
-
# Push the frequency modal screen
|
|
561
|
-
self.app.push_screen(
|
|
562
|
-
FrequencyScreen(col_idx, self.df.filter(self.visible_rows))
|
|
563
|
-
)
|
|
564
|
-
|
|
565
|
-
def _open_freeze_screen(self) -> None:
|
|
566
|
-
"""Open the freeze screen to set fixed rows and columns."""
|
|
567
|
-
self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
|
|
568
|
-
|
|
569
|
-
def _do_freeze(self, result: tuple[int, int] | None) -> None:
|
|
570
|
-
"""Handle result from FreezeScreen.
|
|
571
|
-
|
|
572
|
-
Args:
|
|
573
|
-
result: Tuple of (fixed_rows, fixed_columns) or None if cancelled.
|
|
574
|
-
"""
|
|
575
|
-
if result is None:
|
|
576
|
-
return
|
|
577
|
-
|
|
578
|
-
fixed_rows, fixed_columns = result
|
|
579
|
-
|
|
580
|
-
# Add to history
|
|
581
|
-
self._add_history(
|
|
582
|
-
f"Pinned [on $primary]{fixed_rows}[/] rows and [on $primary]{fixed_columns}[/] columns"
|
|
583
|
-
)
|
|
584
|
-
|
|
585
|
-
# Apply the pin settings to the table
|
|
586
|
-
if fixed_rows > 0:
|
|
587
|
-
self.fixed_rows = fixed_rows
|
|
588
|
-
if fixed_columns > 0:
|
|
589
|
-
self.fixed_columns = fixed_columns
|
|
590
|
-
|
|
591
|
-
self.app.notify(
|
|
592
|
-
f"Pinned [on $primary]{fixed_rows}[/] rows and [on $primary]{fixed_columns}[/] columns",
|
|
593
|
-
title="Pin",
|
|
594
|
-
)
|
|
595
|
-
|
|
596
|
-
# Delete & Move
|
|
597
|
-
def _delete_column(self) -> None:
|
|
598
|
-
"""Remove the currently selected column from the table."""
|
|
599
|
-
col_idx = self.cursor_column
|
|
600
|
-
if col_idx >= len(self.df.columns):
|
|
601
|
-
return
|
|
602
|
-
|
|
603
|
-
# Get the column name to remove
|
|
604
|
-
col_to_remove = self.df.columns[col_idx]
|
|
605
|
-
|
|
606
|
-
# Add to history
|
|
607
|
-
self._add_history(f"Removed column [on $primary]{col_to_remove}[/]")
|
|
608
|
-
|
|
609
|
-
# Remove the column from the table display using the column name as key
|
|
610
|
-
self.remove_column(col_to_remove)
|
|
611
|
-
|
|
612
|
-
# Move cursor left if we deleted the last column
|
|
613
|
-
if col_idx >= len(self.columns):
|
|
614
|
-
self.move_cursor(column=len(self.columns) - 1)
|
|
615
|
-
|
|
616
|
-
# Remove from sorted columns if present
|
|
617
|
-
if col_to_remove in self.sorted_columns:
|
|
618
|
-
del self.sorted_columns[col_to_remove]
|
|
619
|
-
|
|
620
|
-
# Remove from dataframe
|
|
621
|
-
self.df = self.df.drop(col_to_remove)
|
|
622
|
-
|
|
623
|
-
self.app.notify(
|
|
624
|
-
f"Removed column [on $primary]{col_to_remove}[/] from display",
|
|
625
|
-
title="Column",
|
|
626
|
-
)
|
|
627
|
-
|
|
628
|
-
def _delete_row(self) -> None:
|
|
629
|
-
"""Delete rows from the table and dataframe.
|
|
630
|
-
|
|
631
|
-
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
632
|
-
"""
|
|
633
|
-
old_count = len(self.df)
|
|
634
|
-
filter_expr = [True] * len(self.df)
|
|
635
|
-
|
|
636
|
-
# Delete all selected rows
|
|
637
|
-
if selected_count := self.selected_rows.count(True):
|
|
638
|
-
history_desc = f"Deleted {selected_count} selected row(s)"
|
|
639
|
-
|
|
640
|
-
for i, is_selected in enumerate(self.selected_rows):
|
|
641
|
-
if is_selected:
|
|
642
|
-
filter_expr[i] = False
|
|
643
|
-
# Delete the row at the cursor
|
|
644
|
-
else:
|
|
645
|
-
row_key = self.cursor_row_key
|
|
646
|
-
i = int(row_key.value) - 1 # Convert to 0-based index
|
|
647
|
-
|
|
648
|
-
filter_expr[i] = False
|
|
649
|
-
history_desc = f"Deleted row [on $primary]{row_key.value}[/]"
|
|
650
|
-
|
|
651
|
-
# Add to history
|
|
652
|
-
self._add_history(history_desc)
|
|
653
|
-
|
|
654
|
-
# Apply the filter to remove rows
|
|
655
|
-
df = self.df.with_row_index("__rid__").filter(filter_expr)
|
|
656
|
-
self.df = df.drop("__rid__")
|
|
657
|
-
|
|
658
|
-
# Update selected and visible rows tracking
|
|
659
|
-
old_row_indices = set(df["__rid__"].to_list())
|
|
660
|
-
self.selected_rows = [
|
|
661
|
-
selected
|
|
662
|
-
for i, selected in enumerate(self.selected_rows)
|
|
663
|
-
if i in old_row_indices
|
|
664
|
-
]
|
|
665
|
-
self.visible_rows = [
|
|
666
|
-
visible
|
|
667
|
-
for i, visible in enumerate(self.visible_rows)
|
|
668
|
-
if i in old_row_indices
|
|
669
|
-
]
|
|
670
|
-
|
|
671
|
-
# Recreate the table display
|
|
672
|
-
self._setup_table()
|
|
673
|
-
|
|
674
|
-
deleted_count = old_count - len(self.df)
|
|
675
|
-
self.app.notify(f"Deleted {deleted_count} row(s)", title="Delete")
|
|
676
|
-
|
|
677
|
-
def _move_column(self, direction: str) -> None:
|
|
678
|
-
"""Move the current column left or right.
|
|
679
|
-
|
|
680
|
-
Args:
|
|
681
|
-
direction: "left" to move left, "right" to move right.
|
|
682
|
-
"""
|
|
683
|
-
row_idx, col_idx = self.cursor_coordinate
|
|
684
|
-
col_key = self.cursor_column_key
|
|
685
|
-
|
|
686
|
-
# Validate move is possible
|
|
687
|
-
if direction == "left":
|
|
688
|
-
if col_idx <= 0:
|
|
689
|
-
self.app.notify(
|
|
690
|
-
"Cannot move column left", title="Move", severity="warning"
|
|
691
|
-
)
|
|
692
|
-
return
|
|
693
|
-
swap_idx = col_idx - 1
|
|
694
|
-
elif direction == "right":
|
|
695
|
-
if col_idx >= len(self.columns) - 1:
|
|
696
|
-
self.app.notify(
|
|
697
|
-
"Cannot move column right", title="Move", severity="warning"
|
|
698
|
-
)
|
|
699
|
-
return
|
|
700
|
-
swap_idx = col_idx + 1
|
|
701
|
-
|
|
702
|
-
# Get column names to swap
|
|
703
|
-
col_name = self.df.columns[col_idx]
|
|
704
|
-
swap_name = self.df.columns[swap_idx]
|
|
705
|
-
|
|
706
|
-
# Add to history
|
|
707
|
-
self._add_history(
|
|
708
|
-
f"Moved column [on $primary]{col_name}[/] {direction} (swapped with [on $primary]{swap_name}[/])"
|
|
709
|
-
)
|
|
710
|
-
|
|
711
|
-
# Swap columns in the table's internal column locations
|
|
712
|
-
self.check_idle()
|
|
713
|
-
swap_key = self.df.columns[swap_idx] # str as column key
|
|
714
|
-
|
|
715
|
-
(
|
|
716
|
-
self._column_locations[col_key],
|
|
717
|
-
self._column_locations[swap_key],
|
|
718
|
-
) = (
|
|
719
|
-
self._column_locations.get(swap_key),
|
|
720
|
-
self._column_locations.get(col_key),
|
|
721
|
-
)
|
|
722
|
-
|
|
723
|
-
self._update_count += 1
|
|
724
|
-
self.refresh()
|
|
725
|
-
|
|
726
|
-
# Restore cursor position on the moved column
|
|
727
|
-
self.move_cursor(row=row_idx, column=swap_idx)
|
|
728
|
-
|
|
729
|
-
# Update the dataframe column order
|
|
730
|
-
cols = list(self.df.columns)
|
|
731
|
-
cols[col_idx], cols[swap_idx] = cols[swap_idx], cols[col_idx]
|
|
732
|
-
self.df = self.df.select(cols)
|
|
733
|
-
|
|
734
|
-
self.app.notify(
|
|
735
|
-
f"Moved column [on $primary]{col_name}[/] {direction}",
|
|
736
|
-
title="Move",
|
|
737
|
-
)
|
|
738
|
-
|
|
739
|
-
def _move_row(self, direction: str) -> None:
|
|
740
|
-
"""Move the current row up or down.
|
|
741
|
-
|
|
742
|
-
Args:
|
|
743
|
-
direction: "up" to move up, "down" to move down.
|
|
744
|
-
"""
|
|
745
|
-
row_idx, col_idx = self.cursor_coordinate
|
|
746
|
-
|
|
747
|
-
# Validate move is possible
|
|
748
|
-
if direction == "up":
|
|
749
|
-
if row_idx <= 0:
|
|
750
|
-
self.app.notify("Cannot move row up", title="Move", severity="warning")
|
|
751
|
-
return
|
|
752
|
-
swap_idx = row_idx - 1
|
|
753
|
-
elif direction == "down":
|
|
754
|
-
if row_idx >= len(self.rows) - 1:
|
|
755
|
-
self.app.notify(
|
|
756
|
-
"Cannot move row down", title="Move", severity="warning"
|
|
757
|
-
)
|
|
758
|
-
return
|
|
759
|
-
swap_idx = row_idx + 1
|
|
760
|
-
else:
|
|
761
|
-
self.app.notify(
|
|
762
|
-
f"Invalid direction: {direction}", title="Move", severity="error"
|
|
763
|
-
)
|
|
764
|
-
return
|
|
765
|
-
|
|
766
|
-
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
767
|
-
swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
|
|
768
|
-
|
|
769
|
-
# Add to history
|
|
770
|
-
self._add_history(
|
|
771
|
-
f"Moved row [on $primary]{row_key.value}[/] {direction} (swapped with row [on $primary]{swap_key.value}[/])"
|
|
772
|
-
)
|
|
773
|
-
|
|
774
|
-
# Swap rows in the table's internal row locations
|
|
775
|
-
self.check_idle()
|
|
776
|
-
|
|
777
|
-
(
|
|
778
|
-
self._row_locations[row_key],
|
|
779
|
-
self._row_locations[swap_key],
|
|
780
|
-
) = (
|
|
781
|
-
self._row_locations.get(swap_key),
|
|
782
|
-
self._row_locations.get(row_key),
|
|
783
|
-
)
|
|
784
|
-
|
|
785
|
-
self._update_count += 1
|
|
786
|
-
self.refresh()
|
|
787
|
-
|
|
788
|
-
# Restore cursor position on the moved row
|
|
789
|
-
self.move_cursor(row=swap_idx, column=col_idx)
|
|
790
|
-
|
|
791
|
-
# Swap rows in the dataframe
|
|
792
|
-
rid = int(row_key.value) - 1 # 0-based
|
|
793
|
-
swap_rid = int(swap_key.value) - 1 # 0-based
|
|
794
|
-
first, second = sorted([rid, swap_rid])
|
|
795
|
-
|
|
796
|
-
self.df = pl.concat(
|
|
797
|
-
[
|
|
798
|
-
self.df.slice(0, first),
|
|
799
|
-
self.df.slice(second, 1),
|
|
800
|
-
self.df.slice(first + 1, second - first - 1),
|
|
801
|
-
self.df.slice(first, 1),
|
|
802
|
-
self.df.slice(second + 1),
|
|
803
|
-
]
|
|
804
|
-
)
|
|
805
|
-
|
|
806
|
-
self.app.notify(
|
|
807
|
-
f"Moved row [on $primary]{row_key.value}[/] {direction}", title="Move"
|
|
808
|
-
)
|
|
809
|
-
|
|
810
|
-
# Sort
|
|
811
|
-
def _sort_by_column(self, descending: bool = False) -> None:
|
|
812
|
-
"""Sort by the currently selected column.
|
|
813
|
-
|
|
814
|
-
Supports multi-column sorting:
|
|
815
|
-
- First press on a column: sort by that column only
|
|
816
|
-
- Subsequent presses on other columns: add to sort order
|
|
817
|
-
|
|
818
|
-
Args:
|
|
819
|
-
descending: If True, sort in descending order. If False, ascending order.
|
|
820
|
-
"""
|
|
821
|
-
col_idx = self.cursor_column
|
|
822
|
-
if col_idx >= len(self.df.columns):
|
|
823
|
-
return
|
|
824
|
-
|
|
825
|
-
col_to_sort = self.df.columns[col_idx]
|
|
826
|
-
|
|
827
|
-
# Check if this column is already in the sort keys
|
|
828
|
-
old_desc = self.sorted_columns.get(col_to_sort)
|
|
829
|
-
if old_desc == descending:
|
|
830
|
-
# Same direction - remove this column from sort
|
|
831
|
-
self.app.notify(
|
|
832
|
-
f"Already sorted by [on $primary]{col_to_sort}[/] ({'desc' if descending else 'asc'})",
|
|
833
|
-
title="Sort",
|
|
834
|
-
severity="warning",
|
|
835
|
-
)
|
|
836
|
-
return
|
|
837
|
-
|
|
838
|
-
# Add to history
|
|
839
|
-
self._add_history(f"Sorted on column [on $primary]{col_to_sort}[/]")
|
|
840
|
-
if old_desc is None:
|
|
841
|
-
# Add new column to sort
|
|
842
|
-
self.sorted_columns[col_to_sort] = descending
|
|
843
|
-
else:
|
|
844
|
-
# Toggle direction and move to end of sort order
|
|
845
|
-
del self.sorted_columns[col_to_sort]
|
|
846
|
-
self.sorted_columns[col_to_sort] = descending
|
|
847
|
-
|
|
848
|
-
# Apply multi-column sort
|
|
849
|
-
sort_cols = list(self.sorted_columns.keys())
|
|
850
|
-
descending_flags = list(self.sorted_columns.values())
|
|
851
|
-
df_sorted = self.df.with_row_index("__rid__").sort(
|
|
852
|
-
sort_cols, descending=descending_flags, nulls_last=True
|
|
853
|
-
)
|
|
854
|
-
|
|
855
|
-
# Updated selected_rows and visible_rows to match new order
|
|
856
|
-
old_row_indices = df_sorted["__rid__"].to_list()
|
|
857
|
-
self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
|
|
858
|
-
self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
|
|
859
|
-
|
|
860
|
-
# Update the dataframe
|
|
861
|
-
self.df = df_sorted.drop("__rid__")
|
|
862
|
-
|
|
863
|
-
# Recreate the table for display
|
|
864
|
-
self._setup_table()
|
|
865
|
-
|
|
866
|
-
# Restore cursor position on the sorted column
|
|
867
|
-
self.move_cursor(column=col_idx, row=0)
|
|
868
|
-
|
|
869
|
-
# Edit
|
|
870
|
-
def _edit_cell(self) -> None:
|
|
871
|
-
"""Open modal to edit the selected cell."""
|
|
872
|
-
row_key = self.cursor_row_key
|
|
873
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based
|
|
874
|
-
col_idx = self.cursor_column
|
|
875
|
-
|
|
876
|
-
if row_idx >= len(self.df) or col_idx >= len(self.df.columns):
|
|
877
|
-
return
|
|
878
|
-
col_name = self.df.columns[col_idx]
|
|
879
|
-
|
|
880
|
-
# Save current state to history
|
|
881
|
-
self._add_history(f"Edited cell [on $primary]({row_idx + 1}, {col_name})[/]")
|
|
882
|
-
|
|
883
|
-
# Push the edit modal screen
|
|
884
|
-
self.app.push_screen(
|
|
885
|
-
EditCellScreen(row_key, col_idx, self.df),
|
|
886
|
-
callback=self._do_edit_cell,
|
|
887
|
-
)
|
|
888
|
-
|
|
889
|
-
def _do_edit_cell(self, result) -> None:
|
|
890
|
-
"""Handle result from EditCellScreen."""
|
|
891
|
-
if result is None:
|
|
892
|
-
return
|
|
893
|
-
|
|
894
|
-
row_key, col_idx, new_value = result
|
|
895
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based
|
|
896
|
-
col_name = self.df.columns[col_idx]
|
|
897
|
-
|
|
898
|
-
# Update the cell in the dataframe
|
|
899
|
-
try:
|
|
900
|
-
self.df = self.df.with_columns(
|
|
901
|
-
pl.when(pl.arange(0, len(self.df)) == row_idx)
|
|
902
|
-
.then(pl.lit(new_value))
|
|
903
|
-
.otherwise(pl.col(col_name))
|
|
904
|
-
.alias(col_name)
|
|
905
|
-
)
|
|
906
|
-
|
|
907
|
-
# Update the display
|
|
908
|
-
cell_value = self.df.item(row_idx, col_idx)
|
|
909
|
-
if cell_value is None:
|
|
910
|
-
cell_value = "-"
|
|
911
|
-
dtype = self.df.dtypes[col_idx]
|
|
912
|
-
dc = DtypeConfig(dtype)
|
|
913
|
-
formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
|
|
914
|
-
|
|
915
|
-
row_key = str(row_idx + 1)
|
|
916
|
-
col_key = str(col_name)
|
|
917
|
-
self.update_cell(row_key, col_key, formatted_value)
|
|
918
|
-
|
|
919
|
-
self.app.notify(
|
|
920
|
-
f"Cell updated to [on $primary]{cell_value}[/]", title="Edit"
|
|
921
|
-
)
|
|
922
|
-
except Exception as e:
|
|
923
|
-
self.app.notify(
|
|
924
|
-
f"Failed to update cell: {str(e)}", title="Edit", severity="error"
|
|
925
|
-
)
|
|
926
|
-
raise e
|
|
927
|
-
|
|
928
|
-
def _copy_cell(self) -> None:
|
|
929
|
-
"""Copy the current cell to clipboard."""
|
|
930
|
-
import subprocess
|
|
931
|
-
|
|
932
|
-
row_idx = self.cursor_row
|
|
933
|
-
col_idx = self.cursor_column
|
|
934
|
-
|
|
935
|
-
try:
|
|
936
|
-
cell_str = str(self.df.item(row_idx, col_idx))
|
|
937
|
-
subprocess.run(
|
|
938
|
-
[
|
|
939
|
-
"pbcopy" if sys.platform == "darwin" else "xclip",
|
|
940
|
-
"-selection",
|
|
941
|
-
"clipboard",
|
|
942
|
-
],
|
|
943
|
-
input=cell_str,
|
|
944
|
-
text=True,
|
|
945
|
-
)
|
|
946
|
-
self.app.notify(f"Copied: {cell_str[:50]}", title="Clipboard")
|
|
947
|
-
except (FileNotFoundError, IndexError):
|
|
948
|
-
self.app.notify("Error copying cell", title="Clipboard", severity="error")
|
|
949
|
-
|
|
950
|
-
def _search_column(self, all_columns: bool = False) -> None:
|
|
951
|
-
"""Open modal to search in the selected column."""
|
|
952
|
-
row_idx, col_idx = self.cursor_coordinate
|
|
953
|
-
if col_idx >= len(self.df.columns):
|
|
954
|
-
self.app.notify("Invalid column selected", title="Search", severity="error")
|
|
955
|
-
return
|
|
956
|
-
|
|
957
|
-
col_name = None if all_columns else self.df.columns[col_idx]
|
|
958
|
-
col_dtype = self.df.dtypes[col_idx]
|
|
959
|
-
|
|
960
|
-
# Get current cell value as default search term
|
|
961
|
-
term = self.df.item(row_idx, col_idx)
|
|
962
|
-
term = "NULL" if term is None else str(term)
|
|
963
|
-
|
|
964
|
-
# Push the search modal screen
|
|
965
|
-
self.app.push_screen(
|
|
966
|
-
SearchScreen(term, col_dtype, col_name),
|
|
967
|
-
callback=self._do_search_column,
|
|
968
|
-
)
|
|
969
|
-
|
|
970
|
-
def _do_search_column(self, result) -> None:
|
|
971
|
-
"""Handle result from SearchScreen."""
|
|
972
|
-
if result is None:
|
|
973
|
-
return
|
|
974
|
-
|
|
975
|
-
term, col_dtype, col_name = result
|
|
976
|
-
if col_name:
|
|
977
|
-
# Perform search in the specified column
|
|
978
|
-
self._search_single_column(term, col_dtype, col_name)
|
|
979
|
-
else:
|
|
980
|
-
# Perform search in all columns
|
|
981
|
-
self._search_all_columns(term)
|
|
982
|
-
|
|
983
|
-
def _search_single_column(
|
|
984
|
-
self, term: str, col_dtype: pl.DataType, col_name: str
|
|
985
|
-
) -> None:
|
|
986
|
-
"""Search for a term in a single column and update selected rows.
|
|
987
|
-
|
|
988
|
-
Args:
|
|
989
|
-
term: The search term to find
|
|
990
|
-
col_dtype: The data type of the column
|
|
991
|
-
col_name: The name of the column to search in
|
|
992
|
-
"""
|
|
993
|
-
df_rid = self.df.with_row_index("__rid__")
|
|
994
|
-
if False in self.visible_rows:
|
|
995
|
-
df_rid = df_rid.filter(self.visible_rows)
|
|
996
|
-
|
|
997
|
-
# Perform type-aware search based on column dtype
|
|
998
|
-
if term.lower() == "null":
|
|
999
|
-
masks = df_rid[col_name].is_null()
|
|
1000
|
-
elif col_dtype == pl.String:
|
|
1001
|
-
masks = df_rid[col_name].str.contains(term)
|
|
1002
|
-
elif col_dtype == pl.Boolean:
|
|
1003
|
-
masks = df_rid[col_name] == BOOLS[term.lower()]
|
|
1004
|
-
elif col_dtype in (pl.Int32, pl.Int64):
|
|
1005
|
-
masks = df_rid[col_name] == int(term)
|
|
1006
|
-
elif col_dtype in (pl.Float32, pl.Float64):
|
|
1007
|
-
masks = df_rid[col_name] == float(term)
|
|
1008
|
-
else:
|
|
1009
|
-
self.app.notify(
|
|
1010
|
-
f"Search not yet supported for column type: [on $primary]{col_dtype}[/]",
|
|
1011
|
-
title="Search",
|
|
1012
|
-
severity="warning",
|
|
1013
|
-
)
|
|
1014
|
-
return
|
|
1015
|
-
|
|
1016
|
-
# Apply filter to get matched row indices
|
|
1017
|
-
matches = set(df_rid.filter(masks)["__rid__"].to_list())
|
|
1018
|
-
|
|
1019
|
-
match_count = len(matches)
|
|
1020
|
-
if match_count == 0:
|
|
1021
|
-
self.app.notify(
|
|
1022
|
-
f"No matches found for: [on $primary]{term}[/]",
|
|
1023
|
-
title="Search",
|
|
1024
|
-
severity="warning",
|
|
1025
|
-
)
|
|
1026
|
-
return
|
|
1027
|
-
|
|
1028
|
-
# Add to history
|
|
1029
|
-
self._add_history(
|
|
1030
|
-
f"Searched and highlighted [on $primary]{term}[/] in column [on $primary]{col_name}[/]"
|
|
1031
|
-
)
|
|
1032
|
-
|
|
1033
|
-
# Update selected rows to include new matches
|
|
1034
|
-
for m in matches:
|
|
1035
|
-
self.selected_rows[m] = True
|
|
1036
|
-
|
|
1037
|
-
# Highlight selected rows
|
|
1038
|
-
self._highlight_rows()
|
|
1039
|
-
|
|
1040
|
-
self.app.notify(
|
|
1041
|
-
f"Found [on $primary]{match_count}[/] matches for [on $primary]{term}[/]",
|
|
1042
|
-
title="Search",
|
|
1043
|
-
)
|
|
1044
|
-
|
|
1045
|
-
def _search_all_columns(self, term: str) -> None:
|
|
1046
|
-
"""Search for a term across all columns and highlight matching cells.
|
|
1047
|
-
|
|
1048
|
-
Args:
|
|
1049
|
-
term: The search term to find
|
|
1050
|
-
"""
|
|
1051
|
-
df_rid = self.df.with_row_index("__rid__")
|
|
1052
|
-
if False in self.visible_rows:
|
|
1053
|
-
df_rid = df_rid.filter(self.visible_rows)
|
|
1054
|
-
|
|
1055
|
-
matches: dict[int, set[int]] = {}
|
|
1056
|
-
match_count = 0
|
|
1057
|
-
if term.lower() == "null":
|
|
1058
|
-
# Search for NULL values across all columns
|
|
1059
|
-
for col_idx, col in enumerate(df_rid.columns[1:]):
|
|
1060
|
-
masks = df_rid[col].is_null()
|
|
1061
|
-
matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
|
|
1062
|
-
for rid in matched_rids:
|
|
1063
|
-
if rid not in matches:
|
|
1064
|
-
matches[rid] = set()
|
|
1065
|
-
matches[rid].add(col_idx)
|
|
1066
|
-
match_count += 1
|
|
1067
|
-
else:
|
|
1068
|
-
# Search for the term in all columns
|
|
1069
|
-
for col_idx, col in enumerate(df_rid.columns[1:]):
|
|
1070
|
-
col_series = df_rid[col].cast(pl.String)
|
|
1071
|
-
masks = col_series.str.contains(term)
|
|
1072
|
-
matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
|
|
1073
|
-
for rid in matched_rids:
|
|
1074
|
-
if rid not in matches:
|
|
1075
|
-
matches[rid] = set()
|
|
1076
|
-
matches[rid].add(col_idx)
|
|
1077
|
-
match_count += 1
|
|
1078
|
-
|
|
1079
|
-
if match_count == 0:
|
|
1080
|
-
self.app.notify(
|
|
1081
|
-
f"No matches found for: [on $primary]{term}[/] in any column",
|
|
1082
|
-
title="Global Search",
|
|
1083
|
-
severity="warning",
|
|
1084
|
-
)
|
|
1085
|
-
return
|
|
1086
|
-
|
|
1087
|
-
# Ensure all matching rows are loaded
|
|
1088
|
-
self._load_rows(max(matches.keys()) + 1)
|
|
1089
|
-
|
|
1090
|
-
# Add to history
|
|
1091
|
-
self._add_history(
|
|
1092
|
-
f"Searched and highlighted [on $primary]{term}[/] across all columns"
|
|
1093
|
-
)
|
|
1094
|
-
|
|
1095
|
-
# Highlight matching cells directly
|
|
1096
|
-
for row in self.ordered_rows:
|
|
1097
|
-
row_idx = int(row.key.value) - 1 # Convert to 0-based index
|
|
1098
|
-
if row_idx not in matches:
|
|
1099
|
-
continue
|
|
1100
|
-
|
|
1101
|
-
for col_idx in matches[row_idx]:
|
|
1102
|
-
row_key = row.key
|
|
1103
|
-
col_key = self.df.columns[col_idx]
|
|
1104
|
-
|
|
1105
|
-
cell_text: Text = self.get_cell(row_key, col_key)
|
|
1106
|
-
cell_text.style = "red"
|
|
1107
|
-
|
|
1108
|
-
# Update the cell in the table
|
|
1109
|
-
self.update_cell(row_key, col_key, cell_text)
|
|
1110
|
-
|
|
1111
|
-
self.app.notify(
|
|
1112
|
-
f"Found [on $success]{match_count}[/] matches for [on $primary]{term}[/] across all columns",
|
|
1113
|
-
title="Global Search",
|
|
1114
|
-
)
|
|
1115
|
-
|
|
1116
|
-
def _search_with_cell_value(self) -> None:
|
|
1117
|
-
"""Search in the current column using the value of the currently selected cell."""
|
|
1118
|
-
row_key = self.cursor_row_key
|
|
1119
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based index
|
|
1120
|
-
col_idx = self.cursor_column
|
|
1121
|
-
|
|
1122
|
-
# Get the value of the currently selected cell
|
|
1123
|
-
term = self.df.item(row_idx, col_idx)
|
|
1124
|
-
term = "NULL" if term is None else str(term)
|
|
1125
|
-
|
|
1126
|
-
col_dtype = self.df.dtypes[col_idx]
|
|
1127
|
-
col_name = self.df.columns[col_idx]
|
|
1128
|
-
self._do_search_column((term, col_dtype, col_name))
|
|
1129
|
-
|
|
1130
|
-
def _toggle_selected_rows(self, current_row=False) -> None:
|
|
1131
|
-
"""Toggle selected rows highlighting on/off."""
|
|
1132
|
-
# Save current state to history
|
|
1133
|
-
self._add_history("Toggled row selection")
|
|
1134
|
-
|
|
1135
|
-
# Select current row if no rows are currently selected
|
|
1136
|
-
if current_row:
|
|
1137
|
-
cursor_row_idx = int(self.cursor_row_key.value) - 1
|
|
1138
|
-
self.selected_rows[cursor_row_idx] = not self.selected_rows[cursor_row_idx]
|
|
1139
|
-
else:
|
|
1140
|
-
# Invert all selected rows
|
|
1141
|
-
self.selected_rows = [not match for match in self.selected_rows]
|
|
1142
|
-
|
|
1143
|
-
# Check if we're highlighting or un-highlighting
|
|
1144
|
-
if new_selected_count := self.selected_rows.count(True):
|
|
1145
|
-
self.app.notify(
|
|
1146
|
-
f"Toggled selection - now showing [on $primary]{new_selected_count}[/] rows",
|
|
1147
|
-
title="Toggle",
|
|
1148
|
-
)
|
|
1149
|
-
|
|
1150
|
-
# Refresh the highlighting (also restores default styles for unselected rows)
|
|
1151
|
-
self._highlight_rows()
|
|
1152
|
-
|
|
1153
|
-
def _clear_selected_rows(self) -> None:
|
|
1154
|
-
"""Clear all selected rows without removing them from the dataframe."""
|
|
1155
|
-
# Check if any rows are currently selected
|
|
1156
|
-
selected_count = self.selected_rows.count(True)
|
|
1157
|
-
if selected_count == 0:
|
|
1158
|
-
self.app.notify(
|
|
1159
|
-
"No rows selected to clear", title="Clear", severity="warning"
|
|
1160
|
-
)
|
|
1161
|
-
return
|
|
1162
|
-
|
|
1163
|
-
# Save current state to history
|
|
1164
|
-
self._add_history("Cleared all selected rows")
|
|
1165
|
-
|
|
1166
|
-
# Clear all selections and refresh highlighting
|
|
1167
|
-
self._highlight_rows(clear=True)
|
|
1168
|
-
|
|
1169
|
-
self.app.notify(
|
|
1170
|
-
f"Cleared [on $primary]{selected_count}[/] selected rows", title="Clear"
|
|
1171
|
-
)
|
|
1172
|
-
|
|
1173
|
-
def _filter_selected_rows(self) -> None:
|
|
1174
|
-
"""Display only the selected rows."""
|
|
1175
|
-
selected_count = self.selected_rows.count(True)
|
|
1176
|
-
if selected_count == 0:
|
|
1177
|
-
self.app.notify(
|
|
1178
|
-
"No rows selected to filter", title="Filter", severity="warning"
|
|
1179
|
-
)
|
|
1180
|
-
return
|
|
1181
|
-
|
|
1182
|
-
# Save current state to history
|
|
1183
|
-
self._add_history("Filtered to selected rows")
|
|
1184
|
-
|
|
1185
|
-
# Update dataframe to only include selected rows
|
|
1186
|
-
self.df = self.df.filter(self.selected_rows)
|
|
1187
|
-
self.selected_rows = [True] * len(self.df)
|
|
1188
|
-
|
|
1189
|
-
# Recreate the table for display
|
|
1190
|
-
self._setup_table()
|
|
1191
|
-
|
|
1192
|
-
self.app.notify(
|
|
1193
|
-
f"Removed unselected rows. Now showing [on $primary]{selected_count}[/] rows",
|
|
1194
|
-
title="Filter",
|
|
1195
|
-
)
|
|
1196
|
-
|
|
1197
|
-
def _open_filter_screen(self) -> None:
|
|
1198
|
-
"""Open the filter screen to enter a filter expression."""
|
|
1199
|
-
row_key = self.cursor_row_key
|
|
1200
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based index
|
|
1201
|
-
col_idx = self.cursor_column
|
|
1202
|
-
|
|
1203
|
-
cell_value = self.df.item(row_idx, col_idx)
|
|
1204
|
-
if self.df.dtypes[col_idx] == pl.String and cell_value is not None:
|
|
1205
|
-
cell_value = repr(cell_value)
|
|
1206
|
-
|
|
1207
|
-
self.app.push_screen(
|
|
1208
|
-
FilterScreen(
|
|
1209
|
-
self.df, current_col_idx=col_idx, current_cell_value=cell_value
|
|
1210
|
-
),
|
|
1211
|
-
callback=self._do_filter,
|
|
1212
|
-
)
|
|
1213
|
-
|
|
1214
|
-
def _do_filter(self, result) -> None:
|
|
1215
|
-
"""Handle result from FilterScreen.
|
|
1216
|
-
|
|
1217
|
-
Args:
|
|
1218
|
-
expression: The filter expression or None if cancelled.
|
|
1219
|
-
"""
|
|
1220
|
-
if result is None:
|
|
1221
|
-
return
|
|
1222
|
-
expr, expr_str = result
|
|
1223
|
-
|
|
1224
|
-
# Add a row index column to track original row indices
|
|
1225
|
-
df_with_rid = self.df.with_row_index("__rid__")
|
|
1226
|
-
|
|
1227
|
-
# Apply existing visibility filter first
|
|
1228
|
-
if False in self.visible_rows:
|
|
1229
|
-
df_with_rid = df_with_rid.filter(self.visible_rows)
|
|
1230
|
-
|
|
1231
|
-
# Apply the filter expression
|
|
1232
|
-
df_filtered = df_with_rid.filter(expr)
|
|
1233
|
-
|
|
1234
|
-
matched_count = len(df_filtered)
|
|
1235
|
-
if not matched_count:
|
|
1236
|
-
self.app.notify(
|
|
1237
|
-
f"No rows match the expression: [on $primary]{expr_str}[/]",
|
|
1238
|
-
title="Filter",
|
|
1239
|
-
severity="warning",
|
|
1240
|
-
)
|
|
1241
|
-
return
|
|
1242
|
-
|
|
1243
|
-
# Add to history
|
|
1244
|
-
self._add_history(f"Filtered by expression [on $primary]{expr_str}[/]")
|
|
1245
|
-
|
|
1246
|
-
# Mark unfiltered rows as invisible and unselected
|
|
1247
|
-
filtered_row_indices = set(df_filtered["__rid__"].to_list())
|
|
1248
|
-
if filtered_row_indices:
|
|
1249
|
-
for rid in range(len(self.visible_rows)):
|
|
1250
|
-
if rid not in filtered_row_indices:
|
|
1251
|
-
self.visible_rows[rid] = False
|
|
1252
|
-
self.selected_rows[rid] = False
|
|
1253
|
-
|
|
1254
|
-
# Recreate the table for display
|
|
1255
|
-
self._setup_table()
|
|
1256
|
-
|
|
1257
|
-
self.app.notify(
|
|
1258
|
-
f"Filtered to [on $primary]{matched_count}[/] matching rows",
|
|
1259
|
-
title="Filter",
|
|
1260
|
-
)
|
|
1261
|
-
|
|
1262
|
-
def _filter_rows(self) -> None:
|
|
1263
|
-
"""Filter rows.
|
|
1264
|
-
|
|
1265
|
-
If there are selected rows, filter to those rows.
|
|
1266
|
-
Otherwise, filter based on the value of the currently selected cell.
|
|
1267
|
-
"""
|
|
1268
|
-
|
|
1269
|
-
if True in self.selected_rows:
|
|
1270
|
-
expr = self.selected_rows
|
|
1271
|
-
expr_str = "selected rows"
|
|
1272
|
-
else:
|
|
1273
|
-
row_key = self.cursor_row_key
|
|
1274
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based index
|
|
1275
|
-
col_idx = self.cursor_column
|
|
1276
|
-
|
|
1277
|
-
cell_value = self.df.item(row_idx, col_idx)
|
|
1278
|
-
|
|
1279
|
-
if cell_value is None:
|
|
1280
|
-
expr = pl.col(self.df.columns[col_idx]).is_null()
|
|
1281
|
-
expr_str = "NULL"
|
|
1282
|
-
else:
|
|
1283
|
-
expr = pl.col(self.df.columns[col_idx]) == cell_value
|
|
1284
|
-
expr_str = f"$_ == {repr(cell_value)}"
|
|
1285
|
-
|
|
1286
|
-
self._do_filter((expr, expr_str))
|
|
1287
|
-
|
|
1288
|
-
def _cycle_cursor_type(self) -> None:
|
|
1289
|
-
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
1290
|
-
next_type = _next(CURSOR_TYPES, self.cursor_type)
|
|
1291
|
-
self.cursor_type = next_type
|
|
1292
|
-
|
|
1293
|
-
self.app.notify(
|
|
1294
|
-
f"Changed cursor type to [on $primary]{next_type}[/]", title="Cursor"
|
|
1295
|
-
)
|
|
1296
|
-
|
|
1297
|
-
def _toggle_row_labels(self) -> None:
|
|
1298
|
-
"""Toggle row labels visibility."""
|
|
1299
|
-
self.show_row_labels = not self.show_row_labels
|
|
1300
|
-
status = "shown" if self.show_row_labels else "hidden"
|
|
1301
|
-
self.app.notify(f"Row labels {status}", title="Labels")
|
|
1302
|
-
|
|
1303
|
-
def _save_to_file(self) -> None:
|
|
1304
|
-
"""Open save file dialog."""
|
|
1305
|
-
self.app.push_screen(
|
|
1306
|
-
SaveFileScreen(self.filename), callback=self._on_save_file_screen
|
|
1307
|
-
)
|
|
1308
|
-
|
|
1309
|
-
def _on_save_file_screen(
|
|
1310
|
-
self, filename: str | None, all_tabs: bool = False
|
|
1311
|
-
) -> None:
|
|
1312
|
-
"""Handle result from SaveFileScreen."""
|
|
1313
|
-
if filename is None:
|
|
1314
|
-
return
|
|
1315
|
-
filepath = Path(filename)
|
|
1316
|
-
ext = filepath.suffix.lower()
|
|
1317
|
-
|
|
1318
|
-
# Whether to save all tabs (for Excel files)
|
|
1319
|
-
self._all_tabs = all_tabs
|
|
1320
|
-
|
|
1321
|
-
# Check if file exists
|
|
1322
|
-
if filepath.exists():
|
|
1323
|
-
self._pending_filename = filename
|
|
1324
|
-
self.app.push_screen(
|
|
1325
|
-
ConfirmScreen("File already exists. Overwrite?"),
|
|
1326
|
-
callback=self._on_overwrite_screen,
|
|
1327
|
-
)
|
|
1328
|
-
elif ext in (".xlsx", ".xls"):
|
|
1329
|
-
self._do_save_excel(filename)
|
|
1330
|
-
else:
|
|
1331
|
-
self._do_save(filename)
|
|
1332
|
-
|
|
1333
|
-
def _on_overwrite_screen(self, should_overwrite: bool) -> None:
|
|
1334
|
-
"""Handle result from ConfirmScreen."""
|
|
1335
|
-
if should_overwrite:
|
|
1336
|
-
self._do_save(self._pending_filename)
|
|
1337
|
-
else:
|
|
1338
|
-
# Go back to SaveFileScreen to allow user to enter a different name
|
|
1339
|
-
self.app.push_screen(
|
|
1340
|
-
SaveFileScreen(self._pending_filename),
|
|
1341
|
-
callback=self._on_save_file_screen,
|
|
1342
|
-
)
|
|
1343
|
-
|
|
1344
|
-
def _do_save(self, filename: str) -> None:
|
|
1345
|
-
"""Actually save the dataframe to a file."""
|
|
1346
|
-
filepath = Path(filename)
|
|
1347
|
-
ext = filepath.suffix.lower()
|
|
1348
|
-
|
|
1349
|
-
try:
|
|
1350
|
-
if ext in (".xlsx", ".xls"):
|
|
1351
|
-
self._do_save_excel(filename)
|
|
1352
|
-
elif ext in (".tsv", ".tab"):
|
|
1353
|
-
self.df.write_csv(filename, separator="\t")
|
|
1354
|
-
elif ext == ".json":
|
|
1355
|
-
self.df.write_json(filename)
|
|
1356
|
-
elif ext == ".parquet":
|
|
1357
|
-
self.df.write_parquet(filename)
|
|
1358
|
-
else:
|
|
1359
|
-
self.df.write_csv(filename)
|
|
1360
|
-
|
|
1361
|
-
self.dataframe = self.df # Update original dataframe
|
|
1362
|
-
self.filename = filename # Update current filename
|
|
1363
|
-
if not self._all_tabs:
|
|
1364
|
-
self.app.notify(
|
|
1365
|
-
f"Saved [$accent]{len(self.df)}[/] rows to [on $primary]{filename}[/]",
|
|
1366
|
-
title="Save",
|
|
1367
|
-
)
|
|
1368
|
-
except Exception as e:
|
|
1369
|
-
self.app.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
|
|
1370
|
-
raise e
|
|
1371
|
-
|
|
1372
|
-
def _do_save_excel(self, filename: str) -> None:
|
|
1373
|
-
"""Save to an Excel file."""
|
|
1374
|
-
import xlsxwriter
|
|
1375
|
-
|
|
1376
|
-
if not self._all_tabs or len(self.app.tabs) == 1:
|
|
1377
|
-
# Single tab - save directly
|
|
1378
|
-
self.df.write_excel(filename)
|
|
1379
|
-
else:
|
|
1380
|
-
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
1381
|
-
with xlsxwriter.Workbook(filename) as wb:
|
|
1382
|
-
for table in self.app.tabs.values():
|
|
1383
|
-
table.df.write_excel(wb, worksheet=table.tabname[:31])
|
|
1384
|
-
|
|
1385
|
-
# From ConfirmScreen callback, so notify accordingly
|
|
1386
|
-
if self._all_tabs is True:
|
|
1387
|
-
self.app.notify(
|
|
1388
|
-
f"Saved all tabs to [on $primary]{filename}[/]",
|
|
1389
|
-
title="Save",
|
|
1390
|
-
)
|
|
1391
|
-
else:
|
|
1392
|
-
self.app.notify(
|
|
1393
|
-
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [on $primary]{filename}[/]",
|
|
1394
|
-
title="Save",
|
|
1395
|
-
)
|