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
|
@@ -0,0 +1,2768 @@
|
|
|
1
|
+
"""DataFrameTable widget for displaying and interacting with Polars DataFrames."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from textwrap import dedent
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import polars as pl
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual.coordinate import Coordinate
|
|
13
|
+
from textual.events import Click
|
|
14
|
+
from textual.widgets import DataTable, TabPane
|
|
15
|
+
from textual.widgets._data_table import (
|
|
16
|
+
CellDoesNotExist,
|
|
17
|
+
CellKey,
|
|
18
|
+
ColumnKey,
|
|
19
|
+
CursorType,
|
|
20
|
+
RowKey,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .common import (
|
|
24
|
+
CURSOR_TYPES,
|
|
25
|
+
NULL,
|
|
26
|
+
NULL_DISPLAY,
|
|
27
|
+
RIDX,
|
|
28
|
+
SUBSCRIPT_DIGITS,
|
|
29
|
+
DtypeConfig,
|
|
30
|
+
format_row,
|
|
31
|
+
get_next_item,
|
|
32
|
+
rindex,
|
|
33
|
+
tentative_expr,
|
|
34
|
+
validate_expr,
|
|
35
|
+
)
|
|
36
|
+
from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
|
|
37
|
+
from .yes_no_screen import (
|
|
38
|
+
AddColumnScreen,
|
|
39
|
+
ConfirmScreen,
|
|
40
|
+
EditCellScreen,
|
|
41
|
+
EditColumnScreen,
|
|
42
|
+
FilterScreen,
|
|
43
|
+
FindReplaceScreen,
|
|
44
|
+
FreezeScreen,
|
|
45
|
+
RenameColumnScreen,
|
|
46
|
+
SaveFileScreen,
|
|
47
|
+
SearchScreen,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class History:
|
|
53
|
+
"""Class to track history of dataframe states for undo/redo functionality."""
|
|
54
|
+
|
|
55
|
+
description: str
|
|
56
|
+
df: pl.DataFrame
|
|
57
|
+
filename: str
|
|
58
|
+
loaded_rows: int
|
|
59
|
+
sorted_columns: dict[str, bool]
|
|
60
|
+
hidden_columns: set[str]
|
|
61
|
+
selected_rows: list[bool]
|
|
62
|
+
visible_rows: list[bool]
|
|
63
|
+
fixed_rows: int
|
|
64
|
+
fixed_columns: int
|
|
65
|
+
cursor_coordinate: Coordinate
|
|
66
|
+
matches: dict[int, set[int]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ReplaceState:
|
|
71
|
+
"""Class to track state during interactive replace operations."""
|
|
72
|
+
|
|
73
|
+
term_find: str
|
|
74
|
+
term_replace: str
|
|
75
|
+
match_nocase: bool
|
|
76
|
+
match_whole: bool
|
|
77
|
+
cidx: int # Column index to search in, could be None for all columns
|
|
78
|
+
rows: list[int] # List of row indices
|
|
79
|
+
cols_per_row: list[list[int]] # List of list of column indices per row
|
|
80
|
+
current_rpos: int # Current row position index in rows
|
|
81
|
+
current_cpos: int # Current column position index within current row's cols
|
|
82
|
+
current_occurrence: int # Current occurrence count (for display)
|
|
83
|
+
total_occurrence: int # Total number of occurrences
|
|
84
|
+
replaced_occurrence: int # Number of occurrences already replaced
|
|
85
|
+
skipped_occurrence: int # Number of occurrences skipped
|
|
86
|
+
done: bool = False # Whether the replace operation is complete
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DataFrameTable(DataTable):
|
|
90
|
+
"""Custom DataTable to highlight row/column labels based on cursor position."""
|
|
91
|
+
|
|
92
|
+
# Help text for the DataTable which will be shown in the HelpPanel
|
|
93
|
+
HELP = dedent("""
|
|
94
|
+
# 📊 DataFrame Viewer - Table Controls
|
|
95
|
+
|
|
96
|
+
## ⬆️ Navigation
|
|
97
|
+
- **↑↓←→** - 🎯 Move cursor (cell/row/column)
|
|
98
|
+
- **g** - ⬆️ Jump to first row
|
|
99
|
+
- **G** - ⬇️ Jump to last row
|
|
100
|
+
- **PgUp/PgDn** - 📜 Page up/down
|
|
101
|
+
|
|
102
|
+
## 👁️ View & Display
|
|
103
|
+
- **Enter** - 📋 Show row details in modal
|
|
104
|
+
- **F** - 📊 Show frequency distribution
|
|
105
|
+
- **s** - 📈 Show statistics for current column
|
|
106
|
+
- **S** - 📊 Show statistics for entire dataframe
|
|
107
|
+
- **K** - 🔄 Cycle cursor (cell → row → column → cell)
|
|
108
|
+
- **~** - 🏷️ Toggle row labels
|
|
109
|
+
|
|
110
|
+
## ↕️ Sorting
|
|
111
|
+
- **[** - 🔼 Sort column ascending
|
|
112
|
+
- **]** - 🔽 Sort column descending
|
|
113
|
+
- *(Multi-column sort supported)*
|
|
114
|
+
|
|
115
|
+
## 🔍 Search & Filter
|
|
116
|
+
- **|** - 🔎 Search in current column with expression
|
|
117
|
+
- **\\\\** - 🔎 Search in current column using cursor value
|
|
118
|
+
- **/** - 🔎 Find in current column with cursor value
|
|
119
|
+
- **?** - 🔎 Find in current column with expression
|
|
120
|
+
- **f** - 🌐 Global find using cursor value
|
|
121
|
+
- **Ctrl+f** - 🌐 Global find with expression
|
|
122
|
+
- **n** - ⬇️ Go to next match
|
|
123
|
+
- **N** - ⬆️ Go to previous match
|
|
124
|
+
- **v** - 👁️ View/filter rows by cell or selected rows
|
|
125
|
+
- **V** - 🔧 View/filter rows by expression
|
|
126
|
+
- *(All search/find support case-insensitive & whole-word matching)*
|
|
127
|
+
|
|
128
|
+
## ✏️ Replace
|
|
129
|
+
- **r** - 🔄 Replace in current column (interactive or all)
|
|
130
|
+
- **R** - 🔄 Replace across all columns (interactive or all)
|
|
131
|
+
- *(Supports case-insensitive & whole-word matching)*
|
|
132
|
+
|
|
133
|
+
## ✅ Selection & Filtering
|
|
134
|
+
- **'** - ✓️ Select/deselect current row
|
|
135
|
+
- **t** - 💡 Toggle row selection (invert all)
|
|
136
|
+
- **{** - ⬆️ Go to previous selected row
|
|
137
|
+
- **}** - ⬇️ Go to next selected row
|
|
138
|
+
- **"** - 📍 Filter to show only selected rows
|
|
139
|
+
- **T** - 🧹 Clear all selections
|
|
140
|
+
|
|
141
|
+
## ✏️ Edit & Modify
|
|
142
|
+
- **Double-click** - ✍️ Edit cell or rename column header
|
|
143
|
+
- **e** - ✍️ Edit current cell
|
|
144
|
+
- **E** - 📊 Edit entire column with expression
|
|
145
|
+
- **a** - ➕ Add empty column after current
|
|
146
|
+
- **A** - ➕ Add column with name and optional expression
|
|
147
|
+
- **x** - 🗑️ Delete current row
|
|
148
|
+
- **X** - ✨ Clear current cell (set to None)
|
|
149
|
+
- **D** - 📋 Duplicate current row
|
|
150
|
+
- **-** - ❌ Delete current column
|
|
151
|
+
- **d** - 📋 Duplicate current column
|
|
152
|
+
- **h** - 👁️ Hide current column
|
|
153
|
+
- **H** - 👀 Show all hidden columns
|
|
154
|
+
|
|
155
|
+
## 🎯 Reorder
|
|
156
|
+
- **Shift+↑↓** - ⬆️⬇️ Move row up/down
|
|
157
|
+
- **Shift+←→** - ⬅️➡️ Move column left/right
|
|
158
|
+
|
|
159
|
+
## 🎨 Type Conversion
|
|
160
|
+
- **#** - 🔢 Cast column to integer
|
|
161
|
+
- **%** - 🔢 Cast column to float
|
|
162
|
+
- **!** - ✅ Cast column to boolean
|
|
163
|
+
- **$** - 📝 Cast column to string
|
|
164
|
+
|
|
165
|
+
## 🔗 URL Handling
|
|
166
|
+
- **@** - 🔗 Make URLs in current column clickable with Ctrl/Cmd
|
|
167
|
+
|
|
168
|
+
## 💾 Data Management
|
|
169
|
+
- **z** - 📌 Freeze rows and columns
|
|
170
|
+
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
171
|
+
- **c** - 📋 Copy cell to clipboard
|
|
172
|
+
- **Ctrl+c** - 📊 Copy column to clipboard
|
|
173
|
+
- **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
|
|
174
|
+
- **Ctrl+s** - 💾 Save current tab to file
|
|
175
|
+
- **u** - ↩️ Undo last action
|
|
176
|
+
- **U** - 🔄 Reset to original data
|
|
177
|
+
""").strip()
|
|
178
|
+
|
|
179
|
+
# fmt: off
|
|
180
|
+
BINDINGS = [
|
|
181
|
+
("g", "jump_top", "Jump to top"),
|
|
182
|
+
("G", "jump_bottom", "Jump to bottom"),
|
|
183
|
+
("h", "hide_column", "Hide column"),
|
|
184
|
+
("H", "show_column", "Show columns"),
|
|
185
|
+
("c", "copy_cell", "Copy cell to clipboard"),
|
|
186
|
+
("ctrl+c", "copy_column", "Copy column to clipboard"),
|
|
187
|
+
("ctrl+r", "copy_row", "Copy row to clipboard"),
|
|
188
|
+
("ctrl+s", "save_to_file", "Save to file"),
|
|
189
|
+
("enter", "view_row_detail", "View row details"),
|
|
190
|
+
# Frequency & Statistics
|
|
191
|
+
("F", "show_frequency", "Show frequency"),
|
|
192
|
+
("s", "show_statistics", "Show statistics for column"),
|
|
193
|
+
("S", "show_statistics('dataframe')", "Show statistics for dataframe"),
|
|
194
|
+
# Sorting
|
|
195
|
+
("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
|
|
196
|
+
("right_square_bracket", "sort_descending", "Sort descending"), # `]`
|
|
197
|
+
# View
|
|
198
|
+
("v", "view_rows", "View rows"),
|
|
199
|
+
("V", "view_rows_expr", "View rows by expression"),
|
|
200
|
+
# Search
|
|
201
|
+
("backslash", "search_cursor_value", "Search column with cursor value"), # `\`
|
|
202
|
+
("vertical_line", "search_expr", "Search column with expression"), # `|`
|
|
203
|
+
("right_curly_bracket", "next_selected_row", "Go to next selected row"), # `}`
|
|
204
|
+
("left_curly_bracket", "previous_selected_row", "Go to previous selected row"), # `{`
|
|
205
|
+
# Find
|
|
206
|
+
("slash", "find_cursor_value", "Find in column with cursor value"), # `/`
|
|
207
|
+
("question_mark", "find_expr", "Find in column with expression"), # `?`
|
|
208
|
+
("f", "find_cursor_value('global')", "Global find with cursor value"), # `f`
|
|
209
|
+
("ctrl+f", "find_expr('global')", "Global find with expression"), # `Ctrl+F`
|
|
210
|
+
("n", "next_match", "Go to next match"), # `n`
|
|
211
|
+
("N", "previous_match", "Go to previous match"), # `Shift+n`
|
|
212
|
+
# Replace
|
|
213
|
+
("r", "replace", "Replace in column"), # `r`
|
|
214
|
+
("R", "replace_global", "Replace global"), # `Shift+R`
|
|
215
|
+
# Selection
|
|
216
|
+
("apostrophe", "make_selections", "Toggle row selection"), # `'`
|
|
217
|
+
("t", "toggle_selections", "Toggle all row selections"),
|
|
218
|
+
("T", "clear_selections", "Clear selections"),
|
|
219
|
+
("quotation_mark", "filter_selected_rows", "Filter selected"), # `"`
|
|
220
|
+
# Edit
|
|
221
|
+
("minus", "delete_column", "Delete column"), # `-`
|
|
222
|
+
("x", "delete_row", "Delete row"),
|
|
223
|
+
("X", "clear_cell", "Clear cell"),
|
|
224
|
+
("d", "duplicate_column", "Duplicate column"),
|
|
225
|
+
("D", "duplicate_row", "Duplicate row"),
|
|
226
|
+
("e", "edit_cell", "Edit cell"),
|
|
227
|
+
("E", "edit_column", "Edit column"),
|
|
228
|
+
("a", "add_column", "Add column"),
|
|
229
|
+
("A", "add_column_expr", "Add column with expression"),
|
|
230
|
+
# Reorder
|
|
231
|
+
("shift+left", "move_column_left", "Move column left"),
|
|
232
|
+
("shift+right", "move_column_right", "Move column right"),
|
|
233
|
+
("shift+up", "move_row_up", "Move row up"),
|
|
234
|
+
("shift+down", "move_row_down", "Move row down"),
|
|
235
|
+
# Type Conversion
|
|
236
|
+
("number_sign", "cast_column_dtype('int')", "Cast column dtype to int"), # `#`
|
|
237
|
+
("percent_sign", "cast_column_dtype('float')", "Cast column dtype to float"), # `%`
|
|
238
|
+
("exclamation_mark", "cast_column_dtype('bool')", "Cast column dtype to bool"), # `!`
|
|
239
|
+
("dollar_sign", "cast_column_dtype('string')", "Cast column dtype to string"), # `$`
|
|
240
|
+
("at", "make_cell_clickable", "Make cell clickable"), # `@`
|
|
241
|
+
# Misc
|
|
242
|
+
("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
|
|
243
|
+
("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
|
|
244
|
+
("z", "freeze_row_column", "Freeze rows/columns"),
|
|
245
|
+
("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
|
|
246
|
+
# Undo/Redo
|
|
247
|
+
("u", "undo", "Undo"),
|
|
248
|
+
("U", "reset", "Reset to original"),
|
|
249
|
+
]
|
|
250
|
+
# fmt: on
|
|
251
|
+
|
|
252
|
+
def __init__(self, df: pl.DataFrame | pl.LazyFrame, filename: str = "", name: str = "", **kwargs) -> None:
|
|
253
|
+
"""Initialize the DataFrameTable with a dataframe and manage all state.
|
|
254
|
+
|
|
255
|
+
Sets up the table widget with display configuration, loads the dataframe, and
|
|
256
|
+
initializes all state tracking variables for row/column operations.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
df: The Polars DataFrame or LazyFrame to display and edit.
|
|
260
|
+
filename: Optional source filename for the data (used in save operations). Defaults to "".
|
|
261
|
+
name: Optional display name for the table tab. Defaults to "" (uses filename stem).
|
|
262
|
+
**kwargs: Additional keyword arguments passed to the parent DataTable widget.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
None
|
|
266
|
+
"""
|
|
267
|
+
super().__init__(name=(name or Path(filename).stem), **kwargs)
|
|
268
|
+
|
|
269
|
+
# DataFrame state
|
|
270
|
+
self.lazyframe = df.lazy() # Original dataframe
|
|
271
|
+
self.df = self.lazyframe.collect() # Internal/working dataframe
|
|
272
|
+
self.filename = filename # Current filename
|
|
273
|
+
|
|
274
|
+
# Pagination & Loading
|
|
275
|
+
self.INITIAL_BATCH_SIZE = (self.app.size.height // 100 + 1) * 100
|
|
276
|
+
self.BATCH_SIZE = self.INITIAL_BATCH_SIZE // 2
|
|
277
|
+
self.loaded_rows = 0 # Track how many rows are currently loaded
|
|
278
|
+
|
|
279
|
+
# State tracking (all 0-based indexing)
|
|
280
|
+
self.sorted_columns: dict[str, bool] = {} # col_name -> descending
|
|
281
|
+
self.hidden_columns: set[str] = set() # Set of hidden column names
|
|
282
|
+
self.selected_rows: list[bool] = [False] * len(self.df) # Track selected rows
|
|
283
|
+
self.visible_rows: list[bool] = [True] * len(self.df) # Track visible rows (for filtering)
|
|
284
|
+
self.matches: dict[int, set[int]] = defaultdict(set) # Track search matches: row_idx -> set of col_idx
|
|
285
|
+
|
|
286
|
+
# Freezing
|
|
287
|
+
self.fixed_rows = 0 # Number of fixed rows
|
|
288
|
+
self.fixed_columns = 0 # Number of fixed columns
|
|
289
|
+
|
|
290
|
+
# History stack for undo/redo
|
|
291
|
+
self.histories: deque[History] = deque()
|
|
292
|
+
|
|
293
|
+
# Pending filename for save operations
|
|
294
|
+
self._pending_filename = ""
|
|
295
|
+
|
|
296
|
+
# Whether to use thousand separator for numeric display
|
|
297
|
+
self.thousand_separator = False
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def cursor_key(self) -> CellKey:
|
|
301
|
+
"""Get the current cursor position as a CellKey.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
CellKey: A CellKey object representing the current cursor position.
|
|
305
|
+
"""
|
|
306
|
+
return self.coordinate_to_cell_key(self.cursor_coordinate)
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def cursor_row_key(self) -> RowKey:
|
|
310
|
+
"""Get the current cursor row as a RowKey.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
RowKey: The row key for the row containing the cursor.
|
|
314
|
+
"""
|
|
315
|
+
return self.cursor_key.row_key
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def cursor_col_key(self) -> ColumnKey:
|
|
319
|
+
"""Get the current cursor column as a ColumnKey.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
ColumnKey: The column key for the column containing the cursor.
|
|
323
|
+
"""
|
|
324
|
+
return self.cursor_key.column_key
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def cursor_row_idx(self) -> int:
|
|
328
|
+
"""Get the current cursor row index (0-based) as in dataframe.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
int: The 0-based row index of the cursor position.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
AssertionError: If the cursor row index is out of bounds.
|
|
335
|
+
"""
|
|
336
|
+
ridx = int(self.cursor_row_key.value)
|
|
337
|
+
assert 0 <= ridx < len(self.df), "Cursor row index is out of bounds"
|
|
338
|
+
return ridx
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def cursor_col_idx(self) -> int:
|
|
342
|
+
"""Get the current cursor column index (0-based) as in dataframe.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
int: The 0-based column index of the cursor position.
|
|
346
|
+
|
|
347
|
+
Raises:
|
|
348
|
+
AssertionError: If the cursor column index is out of bounds.
|
|
349
|
+
"""
|
|
350
|
+
cidx = self.df.columns.index(self.cursor_col_key.value)
|
|
351
|
+
assert 0 <= cidx < len(self.df.columns), "Cursor column index is out of bounds"
|
|
352
|
+
return cidx
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def cursor_col_name(self) -> str:
|
|
356
|
+
"""Get the current cursor column name as in dataframe.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
str: The name of the column containing the cursor.
|
|
360
|
+
"""
|
|
361
|
+
return self.cursor_col_key.value
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def cursor_value(self) -> Any:
|
|
365
|
+
"""Get the current cursor cell value.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Any: The value of the cell at the cursor position.
|
|
369
|
+
"""
|
|
370
|
+
return self.df.item(self.cursor_row_idx, self.cursor_col_idx)
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def ordered_selected_rows(self) -> list[int]:
|
|
374
|
+
"""Get the list of selected row indices in order.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
list[int]: A list of 0-based row indices that are currently selected.
|
|
378
|
+
"""
|
|
379
|
+
return [ridx for ridx, selected in enumerate(self.selected_rows) if selected]
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def ordered_matches(self) -> list[tuple[int, int]]:
|
|
383
|
+
"""Get the list of matched cell coordinates in order.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
list[tuple[int, int]]: A list of (row_idx, col_idx) tuples for matched cells.
|
|
387
|
+
"""
|
|
388
|
+
matches = []
|
|
389
|
+
for ridx in sorted(self.matches.keys()):
|
|
390
|
+
for cidx in sorted(self.matches[ridx]):
|
|
391
|
+
matches.append((ridx, cidx))
|
|
392
|
+
return matches
|
|
393
|
+
|
|
394
|
+
def on_mount(self) -> None:
|
|
395
|
+
"""Initialize table display when the widget is mounted.
|
|
396
|
+
|
|
397
|
+
Called by Textual when the widget is first added to the display tree.
|
|
398
|
+
Currently a placeholder as table setup is deferred until first use.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
None
|
|
402
|
+
"""
|
|
403
|
+
# self._setup_table()
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
|
|
407
|
+
"""Determine if the given cell should be highlighted because of the cursor.
|
|
408
|
+
|
|
409
|
+
In "cell" mode, also highlights the row and column headers. In "row" and "column"
|
|
410
|
+
modes, highlights the entire row or column respectively.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
cursor: The current position of the cursor.
|
|
414
|
+
target_cell: The cell we're checking for the need to highlight.
|
|
415
|
+
type_of_cursor: The type of cursor that is currently active ("cell", "row", or "column").
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
bool: True if the target cell should be highlighted, False otherwise.
|
|
419
|
+
"""
|
|
420
|
+
if type_of_cursor == "cell":
|
|
421
|
+
# Return true if the cursor is over the target cell
|
|
422
|
+
# This includes the case where the cursor is in the same row or column
|
|
423
|
+
return (
|
|
424
|
+
cursor == target_cell
|
|
425
|
+
or (target_cell.row == -1 and target_cell.column == cursor.column)
|
|
426
|
+
or (target_cell.column == -1 and target_cell.row == cursor.row)
|
|
427
|
+
)
|
|
428
|
+
elif type_of_cursor == "row":
|
|
429
|
+
cursor_row, _ = cursor
|
|
430
|
+
cell_row, _ = target_cell
|
|
431
|
+
return cursor_row == cell_row
|
|
432
|
+
elif type_of_cursor == "column":
|
|
433
|
+
_, cursor_column = cursor
|
|
434
|
+
_, cell_column = target_cell
|
|
435
|
+
return cursor_column == cell_column
|
|
436
|
+
else:
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
def watch_cursor_coordinate(self, old_coordinate: Coordinate, new_coordinate: Coordinate) -> None:
|
|
440
|
+
"""Handle cursor position changes and refresh highlighting.
|
|
441
|
+
|
|
442
|
+
This method is called by Textual whenever the cursor moves. It refreshes cells that need
|
|
443
|
+
to change their highlight state. Also emits CellSelected message when cursor type is "cell"
|
|
444
|
+
for keyboard navigation only (mouse clicks already trigger it).
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
old_coordinate: The previous cursor coordinate.
|
|
448
|
+
new_coordinate: The new cursor coordinate.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
None
|
|
452
|
+
"""
|
|
453
|
+
if old_coordinate != new_coordinate:
|
|
454
|
+
# Emit CellSelected message for cell cursor type (keyboard navigation only)
|
|
455
|
+
# Only emit if this is from keyboard navigation (flag is True when from keyboard)
|
|
456
|
+
if self.cursor_type == "cell" and getattr(self, "_from_keyboard", False):
|
|
457
|
+
self._from_keyboard = False # Reset flag
|
|
458
|
+
try:
|
|
459
|
+
self._post_selected_message()
|
|
460
|
+
except CellDoesNotExist:
|
|
461
|
+
# This could happen when after calling clear(), the old coordinate is invalid
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
# For cell cursor type, refresh old and new row/column headers
|
|
465
|
+
if self.cursor_type == "cell":
|
|
466
|
+
old_row, old_col = old_coordinate
|
|
467
|
+
new_row, new_col = new_coordinate
|
|
468
|
+
|
|
469
|
+
# Refresh entire column (not just header) to ensure proper highlighting
|
|
470
|
+
self.refresh_column(old_col)
|
|
471
|
+
self.refresh_column(new_col)
|
|
472
|
+
|
|
473
|
+
# Refresh entire row (not just header) to ensure proper highlighting
|
|
474
|
+
self.refresh_row(old_row)
|
|
475
|
+
self.refresh_row(new_row)
|
|
476
|
+
elif self.cursor_type == "row":
|
|
477
|
+
self.refresh_row(old_coordinate.row)
|
|
478
|
+
self._highlight_row(new_coordinate.row)
|
|
479
|
+
elif self.cursor_type == "column":
|
|
480
|
+
self.refresh_column(old_coordinate.column)
|
|
481
|
+
self._highlight_column(new_coordinate.column)
|
|
482
|
+
|
|
483
|
+
# Handle scrolling if needed
|
|
484
|
+
if self._require_update_dimensions:
|
|
485
|
+
self.call_after_refresh(self._scroll_cursor_into_view)
|
|
486
|
+
else:
|
|
487
|
+
self._scroll_cursor_into_view()
|
|
488
|
+
|
|
489
|
+
def on_key(self, event) -> None:
|
|
490
|
+
"""Handle key press events for pagination.
|
|
491
|
+
|
|
492
|
+
Currently handles "pagedown" and "down" keys to trigger lazy loading of additional rows
|
|
493
|
+
when scrolling near the end of the loaded data.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
event: The key event object.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
None
|
|
500
|
+
"""
|
|
501
|
+
if event.key in ("pagedown", "down"):
|
|
502
|
+
# Let the table handle the navigation first
|
|
503
|
+
self._check_and_load_more()
|
|
504
|
+
|
|
505
|
+
def on_click(self, event: Click) -> None:
|
|
506
|
+
"""Handle mouse click events on the table.
|
|
507
|
+
|
|
508
|
+
Supports double-click editing of cells and renaming of column headers.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
event: The click event containing row and column information.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
None
|
|
515
|
+
"""
|
|
516
|
+
if self.cursor_type == "cell" and event.chain > 1: # only on double-click or more
|
|
517
|
+
try:
|
|
518
|
+
row_idx = event.style.meta["row"]
|
|
519
|
+
# col_idx = event.style.meta["column"]
|
|
520
|
+
except (KeyError, TypeError):
|
|
521
|
+
return # Unable to get row/column info
|
|
522
|
+
|
|
523
|
+
# header row
|
|
524
|
+
if row_idx == -1:
|
|
525
|
+
self._rename_column()
|
|
526
|
+
else:
|
|
527
|
+
self._edit_cell()
|
|
528
|
+
|
|
529
|
+
# Action handlers for BINDINGS
|
|
530
|
+
def action_jump_top(self) -> None:
|
|
531
|
+
"""Jump to the top of the table."""
|
|
532
|
+
self.move_cursor(row=0)
|
|
533
|
+
|
|
534
|
+
def action_jump_bottom(self) -> None:
|
|
535
|
+
"""Jump to the bottom of the table."""
|
|
536
|
+
self._load_rows()
|
|
537
|
+
self.move_cursor(row=self.row_count - 1)
|
|
538
|
+
|
|
539
|
+
def action_view_row_detail(self) -> None:
|
|
540
|
+
"""View details of the current row."""
|
|
541
|
+
self._view_row_detail()
|
|
542
|
+
|
|
543
|
+
def action_delete_column(self) -> None:
|
|
544
|
+
"""Delete the current column."""
|
|
545
|
+
self._delete_column()
|
|
546
|
+
|
|
547
|
+
def action_hide_column(self) -> None:
|
|
548
|
+
"""Hide the current column."""
|
|
549
|
+
self._hide_column()
|
|
550
|
+
|
|
551
|
+
def action_show_column(self) -> None:
|
|
552
|
+
"""Show all hidden columns."""
|
|
553
|
+
self._show_column()
|
|
554
|
+
|
|
555
|
+
def action_sort_ascending(self) -> None:
|
|
556
|
+
"""Sort by current column in ascending order."""
|
|
557
|
+
self._sort_by_column(descending=False)
|
|
558
|
+
|
|
559
|
+
def action_sort_descending(self) -> None:
|
|
560
|
+
"""Sort by current column in descending order."""
|
|
561
|
+
self._sort_by_column(descending=True)
|
|
562
|
+
|
|
563
|
+
def action_save_to_file(self) -> None:
|
|
564
|
+
"""Save the current dataframe to a file."""
|
|
565
|
+
self._save_to_file()
|
|
566
|
+
|
|
567
|
+
def action_show_frequency(self) -> None:
|
|
568
|
+
"""Show frequency distribution for the current column."""
|
|
569
|
+
self._show_frequency()
|
|
570
|
+
|
|
571
|
+
def action_show_statistics(self, scope: str = "column") -> None:
|
|
572
|
+
"""Show statistics for the current column or entire dataframe.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
scope: Either "column" for current column stats or "dataframe" for all columns.
|
|
576
|
+
"""
|
|
577
|
+
self._show_statistics(scope)
|
|
578
|
+
|
|
579
|
+
def action_view_rows(self) -> None:
|
|
580
|
+
"""View rows by current cell value."""
|
|
581
|
+
self._view_rows()
|
|
582
|
+
|
|
583
|
+
def action_view_rows_expr(self) -> None:
|
|
584
|
+
"""Open the advanced filter screen."""
|
|
585
|
+
self._view_rows_expr()
|
|
586
|
+
|
|
587
|
+
def action_edit_cell(self) -> None:
|
|
588
|
+
"""Edit the current cell."""
|
|
589
|
+
self._edit_cell()
|
|
590
|
+
|
|
591
|
+
def action_edit_column(self) -> None:
|
|
592
|
+
"""Edit the entire current column with an expression."""
|
|
593
|
+
self._edit_column()
|
|
594
|
+
|
|
595
|
+
def action_add_column(self) -> None:
|
|
596
|
+
"""Add an empty column after the current column."""
|
|
597
|
+
self._add_column()
|
|
598
|
+
|
|
599
|
+
def action_add_column_expr(self) -> None:
|
|
600
|
+
"""Add a new column with optional expression after the current column."""
|
|
601
|
+
self._add_column_expr()
|
|
602
|
+
|
|
603
|
+
def action_rename_column(self) -> None:
|
|
604
|
+
"""Rename the current column."""
|
|
605
|
+
self._rename_column()
|
|
606
|
+
|
|
607
|
+
def action_clear_cell(self) -> None:
|
|
608
|
+
"""Clear the current cell (set to None)."""
|
|
609
|
+
self._clear_cell()
|
|
610
|
+
|
|
611
|
+
def action_search_cursor_value(self) -> None:
|
|
612
|
+
"""Search cursor value in the current column."""
|
|
613
|
+
self._search_cursor_value()
|
|
614
|
+
|
|
615
|
+
def action_search_expr(self) -> None:
|
|
616
|
+
"""Search by expression in the current column."""
|
|
617
|
+
self._search_expr()
|
|
618
|
+
|
|
619
|
+
def action_find_cursor_value(self, scope="column") -> None:
|
|
620
|
+
"""Find by cursor value.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
scope: "column" to find in current column, "global" to find across all columns.
|
|
624
|
+
"""
|
|
625
|
+
self._find_cursor_value(scope=scope)
|
|
626
|
+
|
|
627
|
+
def action_find_expr(self, scope="column") -> None:
|
|
628
|
+
"""Find by expression.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
scope: "column" to find in current column, "global" to find across all columns.
|
|
632
|
+
"""
|
|
633
|
+
self._find_expr(scope=scope)
|
|
634
|
+
|
|
635
|
+
def action_replace(self) -> None:
|
|
636
|
+
"""Replace values in current column."""
|
|
637
|
+
self._replace()
|
|
638
|
+
|
|
639
|
+
def action_replace_global(self) -> None:
|
|
640
|
+
"""Replace values across all columns."""
|
|
641
|
+
self._replace_global()
|
|
642
|
+
|
|
643
|
+
def action_make_selections(self) -> None:
|
|
644
|
+
"""Toggle selection for the current row."""
|
|
645
|
+
self._make_selections()
|
|
646
|
+
|
|
647
|
+
def action_toggle_selections(self) -> None:
|
|
648
|
+
"""Toggle all row selections."""
|
|
649
|
+
self._toggle_selections()
|
|
650
|
+
|
|
651
|
+
def action_filter_selected_rows(self) -> None:
|
|
652
|
+
"""Filter to show only selected rows."""
|
|
653
|
+
self._filter_selected_rows()
|
|
654
|
+
|
|
655
|
+
def action_delete_row(self) -> None:
|
|
656
|
+
"""Delete the current row."""
|
|
657
|
+
self._delete_row()
|
|
658
|
+
|
|
659
|
+
def action_duplicate_column(self) -> None:
|
|
660
|
+
"""Duplicate the current column."""
|
|
661
|
+
self._duplicate_column()
|
|
662
|
+
|
|
663
|
+
def action_duplicate_row(self) -> None:
|
|
664
|
+
"""Duplicate the current row."""
|
|
665
|
+
self._duplicate_row()
|
|
666
|
+
|
|
667
|
+
def action_undo(self) -> None:
|
|
668
|
+
"""Undo the last action."""
|
|
669
|
+
self._undo()
|
|
670
|
+
|
|
671
|
+
def action_reset(self) -> None:
|
|
672
|
+
"""Reset to the original data."""
|
|
673
|
+
self._setup_table(reset=True)
|
|
674
|
+
self.notify("Restored original display", title="Reset")
|
|
675
|
+
|
|
676
|
+
def action_move_column_left(self) -> None:
|
|
677
|
+
"""Move the current column to the left."""
|
|
678
|
+
self._move_column("left")
|
|
679
|
+
|
|
680
|
+
def action_move_column_right(self) -> None:
|
|
681
|
+
"""Move the current column to the right."""
|
|
682
|
+
self._move_column("right")
|
|
683
|
+
|
|
684
|
+
def action_move_row_up(self) -> None:
|
|
685
|
+
"""Move the current row up."""
|
|
686
|
+
self._move_row("up")
|
|
687
|
+
|
|
688
|
+
def action_move_row_down(self) -> None:
|
|
689
|
+
"""Move the current row down."""
|
|
690
|
+
self._move_row("down")
|
|
691
|
+
|
|
692
|
+
def action_clear_selections(self) -> None:
|
|
693
|
+
"""Clear all row selections."""
|
|
694
|
+
self._clear_selections()
|
|
695
|
+
|
|
696
|
+
def action_cycle_cursor_type(self) -> None:
|
|
697
|
+
"""Cycle through cursor types."""
|
|
698
|
+
self._cycle_cursor_type()
|
|
699
|
+
|
|
700
|
+
def action_freeze_row_column(self) -> None:
|
|
701
|
+
"""Open the freeze screen."""
|
|
702
|
+
self._freeze_row_column()
|
|
703
|
+
|
|
704
|
+
def action_toggle_row_labels(self) -> None:
|
|
705
|
+
"""Toggle row labels visibility."""
|
|
706
|
+
self.show_row_labels = not self.show_row_labels
|
|
707
|
+
# status = "shown" if self.show_row_labels else "hidden"
|
|
708
|
+
# self.notify(f"Row labels {status}", title="Labels")
|
|
709
|
+
|
|
710
|
+
def action_cast_column_dtype(self, dtype: str | pl.DataType) -> None:
|
|
711
|
+
"""Cast the current column to a different data type."""
|
|
712
|
+
self._cast_column_dtype(dtype)
|
|
713
|
+
|
|
714
|
+
def action_copy_cell(self) -> None:
|
|
715
|
+
"""Copy the current cell to clipboard."""
|
|
716
|
+
ridx = self.cursor_row_idx
|
|
717
|
+
cidx = self.cursor_col_idx
|
|
718
|
+
|
|
719
|
+
try:
|
|
720
|
+
cell_str = str(self.df.item(ridx, cidx))
|
|
721
|
+
self._copy_to_clipboard(cell_str, f"Copied: [$success]{cell_str[:50]}[/]")
|
|
722
|
+
except IndexError:
|
|
723
|
+
self.notify("Error copying cell", title="Clipboard", severity="error")
|
|
724
|
+
|
|
725
|
+
def action_copy_column(self) -> None:
|
|
726
|
+
"""Copy the current column to clipboard (one value per line)."""
|
|
727
|
+
col_name = self.cursor_col_name
|
|
728
|
+
|
|
729
|
+
try:
|
|
730
|
+
# Get all values in the column and join with newlines
|
|
731
|
+
col_values = [str(val) for val in self.df[col_name].to_list()]
|
|
732
|
+
col_str = "\n".join(col_values)
|
|
733
|
+
|
|
734
|
+
self._copy_to_clipboard(
|
|
735
|
+
col_str,
|
|
736
|
+
f"Copied [$accent]{len(col_values)}[/] values from column [$success]{col_name}[/]",
|
|
737
|
+
)
|
|
738
|
+
except (FileNotFoundError, IndexError):
|
|
739
|
+
self.notify("Error copying column", title="Clipboard", severity="error")
|
|
740
|
+
|
|
741
|
+
def action_copy_row(self) -> None:
|
|
742
|
+
"""Copy the current row to clipboard (values separated by tabs)."""
|
|
743
|
+
ridx = self.cursor_row_idx
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
# Get all values in the row and join with tabs
|
|
747
|
+
row_values = [str(val) for val in self.df.row(ridx)]
|
|
748
|
+
row_str = "\t".join(row_values)
|
|
749
|
+
|
|
750
|
+
self._copy_to_clipboard(
|
|
751
|
+
row_str,
|
|
752
|
+
f"Copied row [$accent]{ridx + 1}[/] with [$success]{len(row_values)}[/] values",
|
|
753
|
+
)
|
|
754
|
+
except (FileNotFoundError, IndexError):
|
|
755
|
+
self.notify("Error copying row", title="Clipboard", severity="error")
|
|
756
|
+
|
|
757
|
+
def action_make_cell_clickable(self) -> None:
|
|
758
|
+
"""Make cells with URLs in current column clickable."""
|
|
759
|
+
self._make_cell_clickable()
|
|
760
|
+
|
|
761
|
+
def action_show_thousand_separator(self) -> None:
|
|
762
|
+
"""Toggle thousand separator for numeric display."""
|
|
763
|
+
self.thousand_separator = not self.thousand_separator
|
|
764
|
+
self._setup_table()
|
|
765
|
+
# status = "enabled" if self.thousand_separator else "disabled"
|
|
766
|
+
# self.notify(f"Thousand separator {status}", title="Display")
|
|
767
|
+
|
|
768
|
+
def action_next_match(self) -> None:
|
|
769
|
+
"""Go to the next matched cell."""
|
|
770
|
+
self._next_match()
|
|
771
|
+
|
|
772
|
+
def action_previous_match(self) -> None:
|
|
773
|
+
"""Go to the previous matched cell."""
|
|
774
|
+
self._previous_match()
|
|
775
|
+
|
|
776
|
+
def action_next_selected_row(self) -> None:
|
|
777
|
+
"""Go to the next selected row."""
|
|
778
|
+
self._next_selected_row()
|
|
779
|
+
|
|
780
|
+
def action_previous_selected_row(self) -> None:
|
|
781
|
+
"""Go to the previous selected row."""
|
|
782
|
+
self._previous_selected_row()
|
|
783
|
+
|
|
784
|
+
def _make_cell_clickable(self) -> None:
|
|
785
|
+
"""Make cells with URLs in the current column clickable.
|
|
786
|
+
|
|
787
|
+
Scans all loaded rows in the current column for cells containing URLs
|
|
788
|
+
(starting with 'http://' or 'https://') and applies Textual link styling
|
|
789
|
+
to make them clickable. Does not modify the dataframe.
|
|
790
|
+
|
|
791
|
+
Returns:
|
|
792
|
+
None
|
|
793
|
+
"""
|
|
794
|
+
cidx = self.cursor_col_idx
|
|
795
|
+
col_key = self.cursor_col_key
|
|
796
|
+
dtype = self.df.dtypes[cidx]
|
|
797
|
+
|
|
798
|
+
# Only process string columns
|
|
799
|
+
if dtype != pl.String:
|
|
800
|
+
return
|
|
801
|
+
|
|
802
|
+
# Count how many URLs were made clickable
|
|
803
|
+
url_count = 0
|
|
804
|
+
|
|
805
|
+
# Iterate through all loaded rows and make URLs clickable
|
|
806
|
+
for row in self.ordered_rows:
|
|
807
|
+
cell_text: Text = self.get_cell(row.key, col_key)
|
|
808
|
+
if cell_text.plain.startswith(("http://", "https://")):
|
|
809
|
+
cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
|
|
810
|
+
self.update_cell(row.key, col_key, cell_text)
|
|
811
|
+
url_count += 1
|
|
812
|
+
|
|
813
|
+
if url_count:
|
|
814
|
+
self.notify(
|
|
815
|
+
f"Made [$accent]{url_count}[/] cell(s) clickable in column [$success]{col_key.value}[/]",
|
|
816
|
+
title="Make Clickable",
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
def on_mouse_scroll_down(self, event) -> None:
|
|
820
|
+
"""Load more rows when scrolling down with mouse."""
|
|
821
|
+
self._check_and_load_more()
|
|
822
|
+
|
|
823
|
+
# Setup & Loading
|
|
824
|
+
def _setup_table(self, reset: bool = False) -> None:
|
|
825
|
+
"""Setup the table for display.
|
|
826
|
+
|
|
827
|
+
Row keys are 0-based indices, which map directly to dataframe row indices.
|
|
828
|
+
Column keys are header names from the dataframe.
|
|
829
|
+
"""
|
|
830
|
+
# Reset to original dataframe
|
|
831
|
+
if reset:
|
|
832
|
+
self.df = self.lazyframe.collect()
|
|
833
|
+
self.loaded_rows = 0
|
|
834
|
+
self.sorted_columns = {}
|
|
835
|
+
self.hidden_columns = set()
|
|
836
|
+
self.selected_rows = [False] * len(self.df)
|
|
837
|
+
self.visible_rows = [True] * len(self.df)
|
|
838
|
+
self.fixed_rows = 0
|
|
839
|
+
self.fixed_columns = 0
|
|
840
|
+
self.matches = defaultdict(set)
|
|
841
|
+
|
|
842
|
+
# Lazy load up to INITIAL_BATCH_SIZE visible rows
|
|
843
|
+
stop, visible_count = len(self.df), 0
|
|
844
|
+
for row_idx, visible in enumerate(self.visible_rows):
|
|
845
|
+
if not visible:
|
|
846
|
+
continue
|
|
847
|
+
visible_count += 1
|
|
848
|
+
if visible_count >= self.INITIAL_BATCH_SIZE:
|
|
849
|
+
stop = row_idx + 1
|
|
850
|
+
break
|
|
851
|
+
|
|
852
|
+
# Save current cursor position before clearing
|
|
853
|
+
row_idx, col_idx = self.cursor_coordinate
|
|
854
|
+
|
|
855
|
+
self._setup_columns()
|
|
856
|
+
self._load_rows(stop)
|
|
857
|
+
self._do_highlight()
|
|
858
|
+
|
|
859
|
+
# Restore cursor position
|
|
860
|
+
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
861
|
+
self.move_cursor(row=row_idx, column=col_idx)
|
|
862
|
+
|
|
863
|
+
def _setup_columns(self) -> None:
|
|
864
|
+
"""Clear table and setup columns.
|
|
865
|
+
|
|
866
|
+
Column keys are header names from the dataframe.
|
|
867
|
+
Column labels contain column names from the dataframe, with sort indicators if applicable.
|
|
868
|
+
"""
|
|
869
|
+
self.loaded_rows = 0
|
|
870
|
+
self.clear(columns=True)
|
|
871
|
+
self.show_row_labels = True
|
|
872
|
+
|
|
873
|
+
# Add columns with justified headers
|
|
874
|
+
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
875
|
+
if col in self.hidden_columns:
|
|
876
|
+
continue # Skip hidden columns
|
|
877
|
+
for idx, c in enumerate(self.sorted_columns, 1):
|
|
878
|
+
if c == col:
|
|
879
|
+
# Add sort indicator to column header
|
|
880
|
+
descending = self.sorted_columns[col]
|
|
881
|
+
sort_indicator = (
|
|
882
|
+
f" ▼{SUBSCRIPT_DIGITS.get(idx, '')}" if descending else f" ▲{SUBSCRIPT_DIGITS.get(idx, '')}"
|
|
883
|
+
)
|
|
884
|
+
cell_value = col + sort_indicator
|
|
885
|
+
break
|
|
886
|
+
else: # No break occurred, so column is not sorted
|
|
887
|
+
cell_value = col
|
|
888
|
+
|
|
889
|
+
self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col)
|
|
890
|
+
|
|
891
|
+
def _load_rows(self, stop: int | None = None) -> None:
|
|
892
|
+
"""Load a batch of rows into the table.
|
|
893
|
+
|
|
894
|
+
Row keys are 0-based indices as strings, which map directly to dataframe row indices.
|
|
895
|
+
Row labels are 1-based indices as strings.
|
|
896
|
+
|
|
897
|
+
Args:
|
|
898
|
+
stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
|
|
899
|
+
"""
|
|
900
|
+
if stop is None or stop > len(self.df):
|
|
901
|
+
stop = len(self.df)
|
|
902
|
+
|
|
903
|
+
if stop <= self.loaded_rows:
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
start = self.loaded_rows
|
|
907
|
+
df_slice = self.df.slice(start, stop - start)
|
|
908
|
+
|
|
909
|
+
for row_idx, row in enumerate(df_slice.rows(), start):
|
|
910
|
+
if not self.visible_rows[row_idx]:
|
|
911
|
+
continue # Skip hidden rows
|
|
912
|
+
vals, dtypes = [], []
|
|
913
|
+
for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
|
|
914
|
+
if col in self.hidden_columns:
|
|
915
|
+
continue # Skip hidden columns
|
|
916
|
+
vals.append(val)
|
|
917
|
+
dtypes.append(dtype)
|
|
918
|
+
formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
|
|
919
|
+
# Always add labels so they can be shown/hidden via CSS
|
|
920
|
+
self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
|
|
921
|
+
|
|
922
|
+
# Update loaded rows count
|
|
923
|
+
self.loaded_rows = stop
|
|
924
|
+
|
|
925
|
+
# self.notify(f"Loaded [$accent]{stop}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
|
|
926
|
+
|
|
927
|
+
def _check_and_load_more(self) -> None:
|
|
928
|
+
"""Check if we need to load more rows and load them."""
|
|
929
|
+
# If we've loaded everything, no need to check
|
|
930
|
+
if self.loaded_rows >= len(self.df):
|
|
931
|
+
return
|
|
932
|
+
|
|
933
|
+
visible_row_count = self.size.height - self.header_height
|
|
934
|
+
bottom_visible_row = self.scroll_y + visible_row_count
|
|
935
|
+
|
|
936
|
+
# If visible area is close to the end of loaded rows, load more
|
|
937
|
+
if bottom_visible_row >= self.loaded_rows - 10:
|
|
938
|
+
self._load_rows(self.loaded_rows + self.BATCH_SIZE)
|
|
939
|
+
|
|
940
|
+
def _do_highlight(self, clear: bool = False) -> None:
|
|
941
|
+
"""Update all rows, highlighting selected ones and restoring others to default.
|
|
942
|
+
|
|
943
|
+
Args:
|
|
944
|
+
clear: If True, clear all highlights.
|
|
945
|
+
"""
|
|
946
|
+
if clear:
|
|
947
|
+
self.selected_rows = [False] * len(self.df)
|
|
948
|
+
self.matches = defaultdict(set)
|
|
949
|
+
|
|
950
|
+
# Ensure all selected rows or matches are loaded
|
|
951
|
+
stop = rindex(self.selected_rows, True) + 1
|
|
952
|
+
stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
953
|
+
|
|
954
|
+
self._load_rows(stop)
|
|
955
|
+
self._highlight_table()
|
|
956
|
+
|
|
957
|
+
def _highlight_table(self) -> None:
|
|
958
|
+
"""Highlight selected rows/cells in red."""
|
|
959
|
+
# Update all rows based on selected state
|
|
960
|
+
for row in self.ordered_rows:
|
|
961
|
+
row_idx = int(row.key.value) # 0-based index
|
|
962
|
+
is_selected = self.selected_rows[row_idx]
|
|
963
|
+
match_cols = self.matches.get(row_idx, set())
|
|
964
|
+
|
|
965
|
+
# Update all cells in this row
|
|
966
|
+
for col_idx, col in enumerate(self.ordered_columns):
|
|
967
|
+
cell_text: Text = self.get_cell(row.key, col.key)
|
|
968
|
+
|
|
969
|
+
# Get style config based on dtype
|
|
970
|
+
dtype = self.df.dtypes[col_idx]
|
|
971
|
+
dc = DtypeConfig(dtype)
|
|
972
|
+
cell_text.style = "red" if is_selected or col_idx in match_cols else dc.style
|
|
973
|
+
|
|
974
|
+
# Update the cell in the table
|
|
975
|
+
self.update_cell(row.key, col.key, cell_text)
|
|
976
|
+
|
|
977
|
+
# History & Undo
|
|
978
|
+
def _add_history(self, description: str) -> None:
|
|
979
|
+
"""Add the current state to the history stack.
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
description: Description of the action for this history entry.
|
|
983
|
+
"""
|
|
984
|
+
history = History(
|
|
985
|
+
description=description,
|
|
986
|
+
df=self.df,
|
|
987
|
+
filename=self.filename,
|
|
988
|
+
loaded_rows=self.loaded_rows,
|
|
989
|
+
sorted_columns=self.sorted_columns.copy(),
|
|
990
|
+
hidden_columns=self.hidden_columns.copy(),
|
|
991
|
+
selected_rows=self.selected_rows.copy(),
|
|
992
|
+
visible_rows=self.visible_rows.copy(),
|
|
993
|
+
fixed_rows=self.fixed_rows,
|
|
994
|
+
fixed_columns=self.fixed_columns,
|
|
995
|
+
cursor_coordinate=self.cursor_coordinate,
|
|
996
|
+
matches={k: v.copy() for k, v in self.matches.items()},
|
|
997
|
+
)
|
|
998
|
+
self.histories.append(history)
|
|
999
|
+
|
|
1000
|
+
def _undo(self) -> None:
|
|
1001
|
+
"""Undo the last action."""
|
|
1002
|
+
if not self.histories:
|
|
1003
|
+
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1004
|
+
return
|
|
1005
|
+
|
|
1006
|
+
history = self.histories.pop()
|
|
1007
|
+
|
|
1008
|
+
# Restore state
|
|
1009
|
+
self.df = history.df
|
|
1010
|
+
self.filename = history.filename
|
|
1011
|
+
self.loaded_rows = history.loaded_rows
|
|
1012
|
+
self.sorted_columns = history.sorted_columns.copy()
|
|
1013
|
+
self.hidden_columns = history.hidden_columns.copy()
|
|
1014
|
+
self.selected_rows = history.selected_rows.copy()
|
|
1015
|
+
self.visible_rows = history.visible_rows.copy()
|
|
1016
|
+
self.fixed_rows = history.fixed_rows
|
|
1017
|
+
self.fixed_columns = history.fixed_columns
|
|
1018
|
+
self.cursor_coordinate = history.cursor_coordinate
|
|
1019
|
+
self.matches = {k: v.copy() for k, v in history.matches.items()}
|
|
1020
|
+
|
|
1021
|
+
# Recreate the table for display
|
|
1022
|
+
self._setup_table()
|
|
1023
|
+
|
|
1024
|
+
# self.notify(f"Reverted: {history.description}", title="Undo")
|
|
1025
|
+
|
|
1026
|
+
# View
|
|
1027
|
+
def _view_row_detail(self) -> None:
|
|
1028
|
+
"""Open a modal screen to view the selected row's details."""
|
|
1029
|
+
ridx = self.cursor_row_idx
|
|
1030
|
+
|
|
1031
|
+
# Push the modal screen
|
|
1032
|
+
self.app.push_screen(RowDetailScreen(ridx, self))
|
|
1033
|
+
|
|
1034
|
+
def _show_frequency(self) -> None:
|
|
1035
|
+
"""Show frequency distribution for the current column."""
|
|
1036
|
+
cidx = self.cursor_col_idx
|
|
1037
|
+
|
|
1038
|
+
# Push the frequency modal screen
|
|
1039
|
+
self.app.push_screen(FrequencyScreen(cidx, self))
|
|
1040
|
+
|
|
1041
|
+
def _show_statistics(self, scope: str = "column") -> None:
|
|
1042
|
+
"""Show statistics for the current column or entire dataframe.
|
|
1043
|
+
|
|
1044
|
+
Args:
|
|
1045
|
+
scope: Either "column" for current column stats or "dataframe" for all columns.
|
|
1046
|
+
"""
|
|
1047
|
+
if scope == "dataframe":
|
|
1048
|
+
# Show statistics for entire dataframe
|
|
1049
|
+
self.app.push_screen(StatisticsScreen(self, col_idx=None))
|
|
1050
|
+
else:
|
|
1051
|
+
# Show statistics for current column
|
|
1052
|
+
cidx = self.cursor_col_idx
|
|
1053
|
+
self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
|
|
1054
|
+
|
|
1055
|
+
def _freeze_row_column(self) -> None:
|
|
1056
|
+
"""Open the freeze screen to set fixed rows and columns."""
|
|
1057
|
+
self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
|
|
1058
|
+
|
|
1059
|
+
def _do_freeze(self, result: tuple[int, int] | None) -> None:
|
|
1060
|
+
"""Handle result from PinScreen.
|
|
1061
|
+
|
|
1062
|
+
Args:
|
|
1063
|
+
result: Tuple of (fixed_rows, fixed_columns) or None if cancelled.
|
|
1064
|
+
"""
|
|
1065
|
+
if result is None:
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
fixed_rows, fixed_columns = result
|
|
1069
|
+
|
|
1070
|
+
# Add to history
|
|
1071
|
+
self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
|
|
1072
|
+
|
|
1073
|
+
# Apply the pin settings to the table
|
|
1074
|
+
if fixed_rows > 0:
|
|
1075
|
+
self.fixed_rows = fixed_rows
|
|
1076
|
+
if fixed_columns > 0:
|
|
1077
|
+
self.fixed_columns = fixed_columns
|
|
1078
|
+
|
|
1079
|
+
self.notify(
|
|
1080
|
+
f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
|
|
1081
|
+
title="Pin",
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
# Delete & Move
|
|
1085
|
+
def _delete_column(self) -> None:
|
|
1086
|
+
"""Remove the currently selected column from the table."""
|
|
1087
|
+
# Get the column to remove
|
|
1088
|
+
col_idx = self.cursor_column
|
|
1089
|
+
col_name = self.cursor_col_name
|
|
1090
|
+
col_key = self.cursor_col_key
|
|
1091
|
+
|
|
1092
|
+
# Add to history
|
|
1093
|
+
self._add_history(f"Removed column [$success]{col_name}[/]")
|
|
1094
|
+
|
|
1095
|
+
# Remove the column from the table display using the column name as key
|
|
1096
|
+
self.remove_column(col_key)
|
|
1097
|
+
|
|
1098
|
+
# Move cursor left if we deleted the last column
|
|
1099
|
+
if col_idx >= len(self.columns):
|
|
1100
|
+
self.move_cursor(column=len(self.columns) - 1)
|
|
1101
|
+
|
|
1102
|
+
# Remove from sorted columns if present
|
|
1103
|
+
if col_name in self.sorted_columns:
|
|
1104
|
+
del self.sorted_columns[col_name]
|
|
1105
|
+
|
|
1106
|
+
# Remove from matches
|
|
1107
|
+
for row_idx in list(self.matches.keys()):
|
|
1108
|
+
self.matches[row_idx].discard(col_idx)
|
|
1109
|
+
# Remove empty entries
|
|
1110
|
+
if not self.matches[row_idx]:
|
|
1111
|
+
del self.matches[row_idx]
|
|
1112
|
+
|
|
1113
|
+
# Remove from dataframe
|
|
1114
|
+
self.df = self.df.drop(col_name)
|
|
1115
|
+
|
|
1116
|
+
self.notify(f"Removed column [$success]{col_name}[/]", title="Delete")
|
|
1117
|
+
|
|
1118
|
+
def _hide_column(self) -> None:
|
|
1119
|
+
"""Hide the currently selected column from the table display."""
|
|
1120
|
+
col_key = self.cursor_col_key
|
|
1121
|
+
col_name = col_key.value
|
|
1122
|
+
col_idx = self.cursor_column
|
|
1123
|
+
|
|
1124
|
+
# Add to history
|
|
1125
|
+
self._add_history(f"Hid column [$success]{col_name}[/]")
|
|
1126
|
+
|
|
1127
|
+
# Remove the column from the table display (but keep in dataframe)
|
|
1128
|
+
self.remove_column(col_key)
|
|
1129
|
+
|
|
1130
|
+
# Track hidden columns
|
|
1131
|
+
self.hidden_columns.add(col_name)
|
|
1132
|
+
|
|
1133
|
+
# Move cursor left if we hid the last column
|
|
1134
|
+
if col_idx >= len(self.columns):
|
|
1135
|
+
self.move_cursor(column=len(self.columns) - 1)
|
|
1136
|
+
|
|
1137
|
+
# self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
|
|
1138
|
+
|
|
1139
|
+
def _show_column(self) -> None:
|
|
1140
|
+
"""Show all hidden columns by recreating the table with all dataframe columns."""
|
|
1141
|
+
# Get currently visible columns
|
|
1142
|
+
visible_cols = set(col.key for col in self.ordered_columns)
|
|
1143
|
+
|
|
1144
|
+
# Find hidden columns (in dataframe but not in table)
|
|
1145
|
+
hidden_cols = [col for col in self.df.columns if col not in visible_cols]
|
|
1146
|
+
|
|
1147
|
+
if not hidden_cols:
|
|
1148
|
+
self.notify("No hidden columns to show", title="Column", severity="warning")
|
|
1149
|
+
return
|
|
1150
|
+
|
|
1151
|
+
# Add to history
|
|
1152
|
+
self._add_history(f"Showed {len(hidden_cols)} hidden column(s)")
|
|
1153
|
+
|
|
1154
|
+
# Clear hidden columns tracking
|
|
1155
|
+
self.hidden_columns.clear()
|
|
1156
|
+
|
|
1157
|
+
# Recreate table with all columns
|
|
1158
|
+
self._setup_table()
|
|
1159
|
+
|
|
1160
|
+
self.notify(f"Showed [$accent]{len(hidden_cols)}[/] hidden column(s)", title="Column")
|
|
1161
|
+
|
|
1162
|
+
def _duplicate_column(self) -> None:
|
|
1163
|
+
"""Duplicate the currently selected column, inserting it right after the current column."""
|
|
1164
|
+
cidx = self.cursor_col_idx
|
|
1165
|
+
col_name = self.cursor_col_name
|
|
1166
|
+
|
|
1167
|
+
col_idx = self.cursor_column
|
|
1168
|
+
new_col_name = f"{col_name}_copy"
|
|
1169
|
+
|
|
1170
|
+
# Add to history
|
|
1171
|
+
self._add_history(f"Duplicated column [$success]{col_name}[/]")
|
|
1172
|
+
|
|
1173
|
+
# Create new column and reorder columns to insert after current column
|
|
1174
|
+
cols_before = self.df.columns[: cidx + 1]
|
|
1175
|
+
cols_after = self.df.columns[cidx + 1 :]
|
|
1176
|
+
|
|
1177
|
+
# Add the new column and reorder columns for insertion after current column
|
|
1178
|
+
self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
|
|
1179
|
+
list(cols_before) + [new_col_name] + list(cols_after)
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
# Recreate the table for display
|
|
1183
|
+
self._setup_table()
|
|
1184
|
+
|
|
1185
|
+
# Move cursor to the new duplicated column
|
|
1186
|
+
self.move_cursor(column=col_idx + 1)
|
|
1187
|
+
|
|
1188
|
+
self.notify(
|
|
1189
|
+
f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
|
|
1190
|
+
title="Duplicate",
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
def _delete_row(self) -> None:
|
|
1194
|
+
"""Delete rows from the table and dataframe.
|
|
1195
|
+
|
|
1196
|
+
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
1197
|
+
"""
|
|
1198
|
+
old_count = len(self.df)
|
|
1199
|
+
predicates = [True] * len(self.df)
|
|
1200
|
+
|
|
1201
|
+
# Delete all selected rows
|
|
1202
|
+
if selected_count := self.selected_rows.count(True):
|
|
1203
|
+
history_desc = f"Deleted {selected_count} selected row(s)"
|
|
1204
|
+
|
|
1205
|
+
for ridx, selected in enumerate(self.selected_rows):
|
|
1206
|
+
if selected:
|
|
1207
|
+
predicates[ridx] = False
|
|
1208
|
+
|
|
1209
|
+
# Delete the row at the cursor
|
|
1210
|
+
else:
|
|
1211
|
+
ridx = self.cursor_row_idx
|
|
1212
|
+
history_desc = f"Deleted row [$success]{ridx + 1}[/]"
|
|
1213
|
+
predicates[ridx] = False
|
|
1214
|
+
|
|
1215
|
+
# Add to history
|
|
1216
|
+
self._add_history(history_desc)
|
|
1217
|
+
|
|
1218
|
+
# Apply the filter to remove rows
|
|
1219
|
+
try:
|
|
1220
|
+
df = self.df.with_row_index(RIDX).filter(predicates)
|
|
1221
|
+
except Exception as e:
|
|
1222
|
+
self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error")
|
|
1223
|
+
self.histories.pop() # Remove last history entry
|
|
1224
|
+
return
|
|
1225
|
+
|
|
1226
|
+
self.df = df.drop(RIDX)
|
|
1227
|
+
|
|
1228
|
+
# Update selected and visible rows tracking
|
|
1229
|
+
old_row_indices = set(df[RIDX].to_list())
|
|
1230
|
+
self.selected_rows = [selected for i, selected in enumerate(self.selected_rows) if i in old_row_indices]
|
|
1231
|
+
self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
|
|
1232
|
+
|
|
1233
|
+
# Clear all matches since row indices have changed
|
|
1234
|
+
self.matches = defaultdict(set)
|
|
1235
|
+
|
|
1236
|
+
# Recreate the table display
|
|
1237
|
+
self._setup_table()
|
|
1238
|
+
|
|
1239
|
+
deleted_count = old_count - len(self.df)
|
|
1240
|
+
if deleted_count > 1:
|
|
1241
|
+
self.notify(f"Deleted {deleted_count} row(s)", title="Delete")
|
|
1242
|
+
|
|
1243
|
+
def _duplicate_row(self) -> None:
|
|
1244
|
+
"""Duplicate the currently selected row, inserting it right after the current row."""
|
|
1245
|
+
ridx = self.cursor_row_idx
|
|
1246
|
+
|
|
1247
|
+
# Get the row to duplicate
|
|
1248
|
+
row_to_duplicate = self.df.slice(ridx, 1)
|
|
1249
|
+
|
|
1250
|
+
# Add to history
|
|
1251
|
+
self._add_history(f"Duplicated row [$success]{ridx + 1}[/]")
|
|
1252
|
+
|
|
1253
|
+
# Concatenate: rows before + duplicated row + rows after
|
|
1254
|
+
df_before = self.df.slice(0, ridx + 1)
|
|
1255
|
+
df_after = self.df.slice(ridx + 1)
|
|
1256
|
+
|
|
1257
|
+
# Combine the parts
|
|
1258
|
+
self.df = pl.concat([df_before, row_to_duplicate, df_after])
|
|
1259
|
+
|
|
1260
|
+
# Update selected and visible rows tracking to account for new row
|
|
1261
|
+
new_selected_rows = self.selected_rows[: ridx + 1] + [self.selected_rows[ridx]] + self.selected_rows[ridx + 1 :]
|
|
1262
|
+
new_visible_rows = self.visible_rows[: ridx + 1] + [self.visible_rows[ridx]] + self.visible_rows[ridx + 1 :]
|
|
1263
|
+
self.selected_rows = new_selected_rows
|
|
1264
|
+
self.visible_rows = new_visible_rows
|
|
1265
|
+
|
|
1266
|
+
# Clear all matches since row indices have changed
|
|
1267
|
+
self.matches = defaultdict(set)
|
|
1268
|
+
|
|
1269
|
+
# Recreate the table display
|
|
1270
|
+
self._setup_table()
|
|
1271
|
+
|
|
1272
|
+
# Move cursor to the new duplicated row
|
|
1273
|
+
self.move_cursor(row=ridx + 1)
|
|
1274
|
+
|
|
1275
|
+
# self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
|
|
1276
|
+
|
|
1277
|
+
def _move_column(self, direction: str) -> None:
|
|
1278
|
+
"""Move the current column left or right.
|
|
1279
|
+
|
|
1280
|
+
Args:
|
|
1281
|
+
direction: "left" to move left, "right" to move right.
|
|
1282
|
+
"""
|
|
1283
|
+
row_idx, col_idx = self.cursor_coordinate
|
|
1284
|
+
col_key = self.cursor_col_key
|
|
1285
|
+
col_name = col_key.value
|
|
1286
|
+
cidx = self.cursor_col_idx
|
|
1287
|
+
|
|
1288
|
+
# Validate move is possible
|
|
1289
|
+
if direction == "left":
|
|
1290
|
+
if col_idx <= 0:
|
|
1291
|
+
self.notify("Cannot move column left", title="Move", severity="warning")
|
|
1292
|
+
return
|
|
1293
|
+
swap_idx = col_idx - 1
|
|
1294
|
+
elif direction == "right":
|
|
1295
|
+
if col_idx >= len(self.columns) - 1:
|
|
1296
|
+
self.notify("Cannot move column right", title="Move", severity="warning")
|
|
1297
|
+
return
|
|
1298
|
+
swap_idx = col_idx + 1
|
|
1299
|
+
|
|
1300
|
+
# Get column to swap
|
|
1301
|
+
_, swap_key = self.coordinate_to_cell_key(Coordinate(row_idx, swap_idx))
|
|
1302
|
+
swap_name = swap_key.value
|
|
1303
|
+
swap_cidx = self.df.columns.index(swap_name)
|
|
1304
|
+
|
|
1305
|
+
# Add to history
|
|
1306
|
+
self._add_history(f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])")
|
|
1307
|
+
|
|
1308
|
+
# Swap columns in the table's internal column locations
|
|
1309
|
+
self.check_idle()
|
|
1310
|
+
|
|
1311
|
+
(
|
|
1312
|
+
self._column_locations[col_key],
|
|
1313
|
+
self._column_locations[swap_key],
|
|
1314
|
+
) = (
|
|
1315
|
+
self._column_locations.get(swap_key),
|
|
1316
|
+
self._column_locations.get(col_key),
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
self._update_count += 1
|
|
1320
|
+
self.refresh()
|
|
1321
|
+
|
|
1322
|
+
# Restore cursor position on the moved column
|
|
1323
|
+
self.move_cursor(row=row_idx, column=swap_idx)
|
|
1324
|
+
|
|
1325
|
+
# Update the dataframe column order
|
|
1326
|
+
cols = list(self.df.columns)
|
|
1327
|
+
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
1328
|
+
self.df = self.df.select(cols)
|
|
1329
|
+
|
|
1330
|
+
# self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
|
|
1331
|
+
|
|
1332
|
+
def _move_row(self, direction: str) -> None:
|
|
1333
|
+
"""Move the current row up or down.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
direction: "up" to move up, "down" to move down.
|
|
1337
|
+
"""
|
|
1338
|
+
row_idx, col_idx = self.cursor_coordinate
|
|
1339
|
+
|
|
1340
|
+
# Validate move is possible
|
|
1341
|
+
if direction == "up":
|
|
1342
|
+
if row_idx <= 0:
|
|
1343
|
+
self.notify("Cannot move row up", title="Move", severity="warning")
|
|
1344
|
+
return
|
|
1345
|
+
swap_idx = row_idx - 1
|
|
1346
|
+
elif direction == "down":
|
|
1347
|
+
if row_idx >= len(self.rows) - 1:
|
|
1348
|
+
self.notify("Cannot move row down", title="Move", severity="warning")
|
|
1349
|
+
return
|
|
1350
|
+
swap_idx = row_idx + 1
|
|
1351
|
+
else:
|
|
1352
|
+
self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
|
|
1353
|
+
return
|
|
1354
|
+
|
|
1355
|
+
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
1356
|
+
swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
|
|
1357
|
+
|
|
1358
|
+
# Add to history
|
|
1359
|
+
self._add_history(
|
|
1360
|
+
f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])"
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
# Swap rows in the table's internal row locations
|
|
1364
|
+
self.check_idle()
|
|
1365
|
+
|
|
1366
|
+
(
|
|
1367
|
+
self._row_locations[row_key],
|
|
1368
|
+
self._row_locations[swap_key],
|
|
1369
|
+
) = (
|
|
1370
|
+
self._row_locations.get(swap_key),
|
|
1371
|
+
self._row_locations.get(row_key),
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
self._update_count += 1
|
|
1375
|
+
self.refresh()
|
|
1376
|
+
|
|
1377
|
+
# Restore cursor position on the moved row
|
|
1378
|
+
self.move_cursor(row=swap_idx, column=col_idx)
|
|
1379
|
+
|
|
1380
|
+
# Swap rows in the dataframe
|
|
1381
|
+
ridx = int(row_key.value) # 0-based
|
|
1382
|
+
swap_ridx = int(swap_key.value) # 0-based
|
|
1383
|
+
first, second = sorted([ridx, swap_ridx])
|
|
1384
|
+
|
|
1385
|
+
self.df = pl.concat(
|
|
1386
|
+
[
|
|
1387
|
+
self.df.slice(0, first),
|
|
1388
|
+
self.df.slice(second, 1),
|
|
1389
|
+
self.df.slice(first + 1, second - first - 1),
|
|
1390
|
+
self.df.slice(first, 1),
|
|
1391
|
+
self.df.slice(second + 1),
|
|
1392
|
+
]
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
# self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
|
|
1396
|
+
|
|
1397
|
+
# Sort
|
|
1398
|
+
def _sort_by_column(self, descending: bool = False) -> None:
|
|
1399
|
+
"""Sort by the currently selected column.
|
|
1400
|
+
|
|
1401
|
+
Supports multi-column sorting:
|
|
1402
|
+
- First press on a column: sort by that column only
|
|
1403
|
+
- Subsequent presses on other columns: add to sort order
|
|
1404
|
+
|
|
1405
|
+
Args:
|
|
1406
|
+
descending: If True, sort in descending order. If False, ascending order.
|
|
1407
|
+
"""
|
|
1408
|
+
col_name = self.cursor_col_name
|
|
1409
|
+
col_idx = self.cursor_column
|
|
1410
|
+
|
|
1411
|
+
# Check if this column is already in the sort keys
|
|
1412
|
+
old_desc = self.sorted_columns.get(col_name)
|
|
1413
|
+
|
|
1414
|
+
# Add to history
|
|
1415
|
+
self._add_history(f"Sorted on column [$success]{col_name}[/]")
|
|
1416
|
+
if old_desc is None:
|
|
1417
|
+
# Add new column to sort
|
|
1418
|
+
self.sorted_columns[col_name] = descending
|
|
1419
|
+
elif old_desc == descending:
|
|
1420
|
+
# Same direction - remove from sort
|
|
1421
|
+
del self.sorted_columns[col_name]
|
|
1422
|
+
else:
|
|
1423
|
+
# Move to end of sort order
|
|
1424
|
+
del self.sorted_columns[col_name]
|
|
1425
|
+
self.sorted_columns[col_name] = descending
|
|
1426
|
+
|
|
1427
|
+
# Apply multi-column sort
|
|
1428
|
+
if sort_cols := list(self.sorted_columns.keys()):
|
|
1429
|
+
descending_flags = list(self.sorted_columns.values())
|
|
1430
|
+
df_sorted = self.df.with_row_index(RIDX).sort(sort_cols, descending=descending_flags, nulls_last=True)
|
|
1431
|
+
else:
|
|
1432
|
+
# No sort columns - restore original order
|
|
1433
|
+
df_sorted = self.df.with_row_index(RIDX)
|
|
1434
|
+
|
|
1435
|
+
# Updated selected_rows and visible_rows to match new order
|
|
1436
|
+
old_row_indices = df_sorted[RIDX].to_list()
|
|
1437
|
+
self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
|
|
1438
|
+
self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
|
|
1439
|
+
|
|
1440
|
+
# Update the dataframe
|
|
1441
|
+
self.df = df_sorted.drop(RIDX)
|
|
1442
|
+
|
|
1443
|
+
# Recreate the table for display
|
|
1444
|
+
self._setup_table()
|
|
1445
|
+
|
|
1446
|
+
# Restore cursor position on the sorted column
|
|
1447
|
+
self.move_cursor(column=col_idx, row=0)
|
|
1448
|
+
|
|
1449
|
+
# Edit
|
|
1450
|
+
def _edit_cell(self, ridx: int = None, cidx: int = None) -> None:
|
|
1451
|
+
"""Open modal to edit the selected cell."""
|
|
1452
|
+
ridx = self.cursor_row_idx if ridx is None else ridx
|
|
1453
|
+
cidx = self.cursor_col_idx if cidx is None else cidx
|
|
1454
|
+
col_name = self.df.columns[cidx]
|
|
1455
|
+
|
|
1456
|
+
# Save current state to history
|
|
1457
|
+
self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
|
|
1458
|
+
|
|
1459
|
+
# Push the edit modal screen
|
|
1460
|
+
self.app.push_screen(
|
|
1461
|
+
EditCellScreen(ridx, cidx, self.df),
|
|
1462
|
+
callback=self._do_edit_cell,
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
def _do_edit_cell(self, result) -> None:
|
|
1466
|
+
"""Handle result from EditCellScreen."""
|
|
1467
|
+
if result is None:
|
|
1468
|
+
return
|
|
1469
|
+
|
|
1470
|
+
ridx, cidx, new_value = result
|
|
1471
|
+
if new_value is None:
|
|
1472
|
+
self.app.push_screen(
|
|
1473
|
+
EditCellScreen(ridx, cidx, self.df),
|
|
1474
|
+
callback=self._do_edit_cell,
|
|
1475
|
+
)
|
|
1476
|
+
return
|
|
1477
|
+
|
|
1478
|
+
col_name = self.df.columns[cidx]
|
|
1479
|
+
|
|
1480
|
+
# Update the cell in the dataframe
|
|
1481
|
+
try:
|
|
1482
|
+
self.df = self.df.with_columns(
|
|
1483
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
1484
|
+
.then(pl.lit(new_value))
|
|
1485
|
+
.otherwise(pl.col(col_name))
|
|
1486
|
+
.alias(col_name)
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
# Update the display
|
|
1490
|
+
cell_value = self.df.item(ridx, cidx)
|
|
1491
|
+
if cell_value is None:
|
|
1492
|
+
cell_value = NULL_DISPLAY
|
|
1493
|
+
dtype = self.df.dtypes[cidx]
|
|
1494
|
+
dc = DtypeConfig(dtype)
|
|
1495
|
+
formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
|
|
1496
|
+
|
|
1497
|
+
# string as keys
|
|
1498
|
+
row_key = str(ridx)
|
|
1499
|
+
col_key = col_name
|
|
1500
|
+
self.update_cell(row_key, col_key, formatted_value, update_width=True)
|
|
1501
|
+
|
|
1502
|
+
self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1503
|
+
except Exception as e:
|
|
1504
|
+
self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
|
|
1505
|
+
|
|
1506
|
+
def _edit_column(self) -> None:
|
|
1507
|
+
"""Open modal to edit the entire column with an expression."""
|
|
1508
|
+
cidx = self.cursor_col_idx
|
|
1509
|
+
|
|
1510
|
+
# Push the edit column modal screen
|
|
1511
|
+
self.app.push_screen(
|
|
1512
|
+
EditColumnScreen(cidx, self.df),
|
|
1513
|
+
callback=self._do_edit_column,
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
def _do_edit_column(self, result) -> None:
|
|
1517
|
+
"""Edit a column."""
|
|
1518
|
+
if result is None:
|
|
1519
|
+
return
|
|
1520
|
+
term, cidx = result
|
|
1521
|
+
|
|
1522
|
+
col_name = self.df.columns[cidx]
|
|
1523
|
+
|
|
1524
|
+
# Null case
|
|
1525
|
+
if term is None or term == NULL:
|
|
1526
|
+
expr = pl.lit(None)
|
|
1527
|
+
|
|
1528
|
+
# Check if term is a valid expression
|
|
1529
|
+
elif tentative_expr(term):
|
|
1530
|
+
try:
|
|
1531
|
+
expr = validate_expr(term, self.df, cidx)
|
|
1532
|
+
except Exception as e:
|
|
1533
|
+
self.notify(f"Error validating expression [$error]{term}[/]: {str(e)}", title="Edit", severity="error")
|
|
1534
|
+
return
|
|
1535
|
+
|
|
1536
|
+
# Otherwise, treat term as a literal value
|
|
1537
|
+
else:
|
|
1538
|
+
dtype = self.df.dtypes[cidx]
|
|
1539
|
+
try:
|
|
1540
|
+
value = DtypeConfig(dtype).convert(term)
|
|
1541
|
+
expr = pl.lit(value)
|
|
1542
|
+
except Exception:
|
|
1543
|
+
self.notify(
|
|
1544
|
+
f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1545
|
+
title="Edit",
|
|
1546
|
+
severity="error",
|
|
1547
|
+
)
|
|
1548
|
+
expr = pl.lit(str(term))
|
|
1549
|
+
|
|
1550
|
+
# Add to history
|
|
1551
|
+
self._add_history(f"Edited column [$accent]{col_name}[/] with expression")
|
|
1552
|
+
|
|
1553
|
+
try:
|
|
1554
|
+
# Apply the expression to the column
|
|
1555
|
+
self.df = self.df.with_columns(expr.alias(col_name))
|
|
1556
|
+
except Exception as e:
|
|
1557
|
+
self.notify(f"Failed to apply expression: [$error]{str(e)}[/]", title="Edit", severity="error")
|
|
1558
|
+
return
|
|
1559
|
+
|
|
1560
|
+
# Recreate the table for display
|
|
1561
|
+
self._setup_table()
|
|
1562
|
+
|
|
1563
|
+
self.notify(
|
|
1564
|
+
f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
|
|
1565
|
+
title="Edit",
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
def _rename_column(self) -> None:
|
|
1569
|
+
"""Open modal to rename the selected column."""
|
|
1570
|
+
col_name = self.cursor_col_name
|
|
1571
|
+
col_idx = self.cursor_column
|
|
1572
|
+
|
|
1573
|
+
# Push the rename column modal screen
|
|
1574
|
+
self.app.push_screen(
|
|
1575
|
+
RenameColumnScreen(col_idx, col_name, self.df.columns),
|
|
1576
|
+
callback=self._do_rename_column,
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
def _do_rename_column(self, result) -> None:
|
|
1580
|
+
"""Handle result from RenameColumnScreen."""
|
|
1581
|
+
if result is None:
|
|
1582
|
+
return
|
|
1583
|
+
|
|
1584
|
+
col_idx, col_name, new_name = result
|
|
1585
|
+
if new_name is None:
|
|
1586
|
+
self.app.push_screen(
|
|
1587
|
+
RenameColumnScreen(col_idx, col_name, self.df.columns),
|
|
1588
|
+
callback=self._do_rename_column,
|
|
1589
|
+
)
|
|
1590
|
+
return
|
|
1591
|
+
|
|
1592
|
+
# Add to history
|
|
1593
|
+
self._add_history(f"Renamed column [$accent]{col_name}[/] to [$success]{new_name}[/]")
|
|
1594
|
+
|
|
1595
|
+
# Rename the column in the dataframe
|
|
1596
|
+
self.df = self.df.rename({col_name: new_name})
|
|
1597
|
+
|
|
1598
|
+
# Update sorted_columns if this column was sorted
|
|
1599
|
+
if col_name in self.sorted_columns:
|
|
1600
|
+
self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
|
|
1601
|
+
|
|
1602
|
+
# Update hidden_columns if this column was hidden
|
|
1603
|
+
if col_name in self.hidden_columns:
|
|
1604
|
+
self.hidden_columns.remove(col_name)
|
|
1605
|
+
self.hidden_columns.add(new_name)
|
|
1606
|
+
|
|
1607
|
+
# Recreate the table for display
|
|
1608
|
+
self._setup_table()
|
|
1609
|
+
|
|
1610
|
+
# Move cursor to the renamed column
|
|
1611
|
+
self.move_cursor(column=col_idx)
|
|
1612
|
+
|
|
1613
|
+
self.notify(
|
|
1614
|
+
f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
|
|
1615
|
+
title="Column",
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
def _clear_cell(self) -> None:
|
|
1619
|
+
"""Clear the current cell by setting its value to None."""
|
|
1620
|
+
row_key, col_key = self.cursor_key
|
|
1621
|
+
ridx = self.cursor_row_idx
|
|
1622
|
+
cidx = self.cursor_col_idx
|
|
1623
|
+
col_name = self.cursor_col_name
|
|
1624
|
+
|
|
1625
|
+
# Add to history
|
|
1626
|
+
self._add_history(f"Cleared cell [$success]({ridx + 1}, {col_name})[/]")
|
|
1627
|
+
|
|
1628
|
+
# Update the cell to None in the dataframe
|
|
1629
|
+
try:
|
|
1630
|
+
self.df = self.df.with_columns(
|
|
1631
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
1632
|
+
.then(pl.lit(None))
|
|
1633
|
+
.otherwise(pl.col(col_name))
|
|
1634
|
+
.alias(col_name)
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
# Update the display
|
|
1638
|
+
dtype = self.df.dtypes[cidx]
|
|
1639
|
+
dc = DtypeConfig(dtype)
|
|
1640
|
+
formatted_value = Text(NULL_DISPLAY, style=dc.style, justify=dc.justify)
|
|
1641
|
+
|
|
1642
|
+
self.update_cell(row_key, col_key, formatted_value)
|
|
1643
|
+
|
|
1644
|
+
self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
1645
|
+
except Exception as e:
|
|
1646
|
+
self.notify(f"Failed to clear cell: {str(e)}", title="Clear", severity="error")
|
|
1647
|
+
raise e
|
|
1648
|
+
|
|
1649
|
+
def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
|
|
1650
|
+
"""Add acolumn after the current column."""
|
|
1651
|
+
cidx = self.cursor_col_idx
|
|
1652
|
+
|
|
1653
|
+
if not col_name:
|
|
1654
|
+
# Generate a unique column name
|
|
1655
|
+
base_name = "new_col"
|
|
1656
|
+
new_name = base_name
|
|
1657
|
+
counter = 1
|
|
1658
|
+
while new_name in self.df.columns:
|
|
1659
|
+
new_name = f"{base_name}_{counter}"
|
|
1660
|
+
counter += 1
|
|
1661
|
+
else:
|
|
1662
|
+
new_name = col_name
|
|
1663
|
+
|
|
1664
|
+
# Add to history
|
|
1665
|
+
self._add_history(f"Added column [$success]{new_name}[/] after column {cidx + 1}")
|
|
1666
|
+
|
|
1667
|
+
try:
|
|
1668
|
+
# Create an empty column (all None values)
|
|
1669
|
+
if isinstance(col_value, pl.Expr):
|
|
1670
|
+
new_col = col_value.alias(new_name)
|
|
1671
|
+
else:
|
|
1672
|
+
new_col = pl.lit(col_value).alias(new_name)
|
|
1673
|
+
|
|
1674
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1675
|
+
cols = self.df.columns
|
|
1676
|
+
cols_before = cols[: cidx + 1]
|
|
1677
|
+
cols_after = cols[cidx + 1 :]
|
|
1678
|
+
|
|
1679
|
+
# Build the new dataframe with columns reordered
|
|
1680
|
+
select_cols = cols_before + [new_name] + cols_after
|
|
1681
|
+
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
1682
|
+
|
|
1683
|
+
# Recreate the table display
|
|
1684
|
+
self._setup_table()
|
|
1685
|
+
|
|
1686
|
+
# Move cursor to the new column
|
|
1687
|
+
self.move_cursor(column=cidx + 1)
|
|
1688
|
+
|
|
1689
|
+
self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1690
|
+
except Exception as e:
|
|
1691
|
+
self.notify(f"Failed to add column: {str(e)}", title="Add Column", severity="error")
|
|
1692
|
+
raise e
|
|
1693
|
+
|
|
1694
|
+
def _add_column_expr(self) -> None:
|
|
1695
|
+
"""Open screen to add a new column with optional expression."""
|
|
1696
|
+
cidx = self.cursor_col_idx
|
|
1697
|
+
self.app.push_screen(
|
|
1698
|
+
AddColumnScreen(cidx, self.df),
|
|
1699
|
+
self._do_add_column_expr,
|
|
1700
|
+
)
|
|
1701
|
+
|
|
1702
|
+
def _do_add_column_expr(self, result: tuple[int, str, str, pl.Expr] | None) -> None:
|
|
1703
|
+
"""Add a new column with an expression."""
|
|
1704
|
+
if result is None:
|
|
1705
|
+
return
|
|
1706
|
+
|
|
1707
|
+
cidx, col_name, expr = result
|
|
1708
|
+
|
|
1709
|
+
# Add to history
|
|
1710
|
+
self._add_history(f"Added column [$success]{col_name}[/] with expression {expr}.")
|
|
1711
|
+
|
|
1712
|
+
try:
|
|
1713
|
+
# Create the column
|
|
1714
|
+
new_col = expr.alias(col_name)
|
|
1715
|
+
|
|
1716
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1717
|
+
cols = self.df.columns
|
|
1718
|
+
cols_before = cols[: cidx + 1]
|
|
1719
|
+
cols_after = cols[cidx + 1 :]
|
|
1720
|
+
|
|
1721
|
+
# Build the new dataframe with columns reordered
|
|
1722
|
+
select_cols = cols_before + [col_name] + cols_after
|
|
1723
|
+
self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
|
|
1724
|
+
|
|
1725
|
+
# Recreate the table display
|
|
1726
|
+
self._setup_table()
|
|
1727
|
+
|
|
1728
|
+
# Move cursor to the new column
|
|
1729
|
+
self.move_cursor(column=cidx + 1)
|
|
1730
|
+
|
|
1731
|
+
# self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
|
|
1732
|
+
except Exception as e:
|
|
1733
|
+
self.notify(f"Failed to add column: [$error]{str(e)}[/]", title="Add Column", severity="error")
|
|
1734
|
+
raise e
|
|
1735
|
+
|
|
1736
|
+
def _string_to_polars_dtype(self, dtype_str: str) -> pl.DataType:
|
|
1737
|
+
"""Convert string type name to Polars DataType.
|
|
1738
|
+
|
|
1739
|
+
Args:
|
|
1740
|
+
dtype_str: String representation of the type ("string", "int", "float", "bool")
|
|
1741
|
+
|
|
1742
|
+
Returns:
|
|
1743
|
+
Corresponding Polars DataType
|
|
1744
|
+
|
|
1745
|
+
Raises:
|
|
1746
|
+
ValueError: If the type string is not recognized
|
|
1747
|
+
"""
|
|
1748
|
+
dtype_map = {
|
|
1749
|
+
"string": pl.String,
|
|
1750
|
+
"int": pl.Int64,
|
|
1751
|
+
"float": pl.Float64,
|
|
1752
|
+
"bool": pl.Boolean,
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
dtype_lower = dtype_str.lower().strip()
|
|
1756
|
+
return dtype_map.get(dtype_lower)
|
|
1757
|
+
|
|
1758
|
+
def _cast_column_dtype(self, dtype: str | pl.DataType = pl.String) -> None:
|
|
1759
|
+
"""Cast the current column to a different data type.
|
|
1760
|
+
|
|
1761
|
+
Args:
|
|
1762
|
+
dtype: Target data type (string like "int", "float", "bool", "string" or Polars DataType)
|
|
1763
|
+
"""
|
|
1764
|
+
cidx = self.cursor_col_idx
|
|
1765
|
+
col_name = self.cursor_col_name
|
|
1766
|
+
current_dtype = self.df.dtypes[cidx]
|
|
1767
|
+
|
|
1768
|
+
# Convert string dtype to Polars DataType if needed
|
|
1769
|
+
if isinstance(dtype, str):
|
|
1770
|
+
target_dtype = self._string_to_polars_dtype(dtype)
|
|
1771
|
+
if target_dtype is None:
|
|
1772
|
+
self.notify(
|
|
1773
|
+
f"Use string for unknown data type: {dtype}. Supported types: {', '.join(self._string_to_polars_dtype.keys())}",
|
|
1774
|
+
title="Cast",
|
|
1775
|
+
severity="warning",
|
|
1776
|
+
)
|
|
1777
|
+
target_dtype = pl.String
|
|
1778
|
+
else:
|
|
1779
|
+
target_dtype = dtype
|
|
1780
|
+
|
|
1781
|
+
# Add to history
|
|
1782
|
+
self._add_history(
|
|
1783
|
+
f"Cast column [$accent]{col_name}[/] from [$success]{current_dtype}[/] to [$success]{target_dtype}[/]"
|
|
1784
|
+
)
|
|
1785
|
+
|
|
1786
|
+
try:
|
|
1787
|
+
# Cast the column using Polars
|
|
1788
|
+
self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
|
|
1789
|
+
|
|
1790
|
+
# Recreate the table display
|
|
1791
|
+
self._setup_table()
|
|
1792
|
+
|
|
1793
|
+
self.notify(
|
|
1794
|
+
f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]",
|
|
1795
|
+
title="Cast",
|
|
1796
|
+
)
|
|
1797
|
+
except Exception as e:
|
|
1798
|
+
self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
|
|
1799
|
+
raise e
|
|
1800
|
+
|
|
1801
|
+
def _search_cursor_value(self) -> None:
|
|
1802
|
+
"""Search with cursor value in current column."""
|
|
1803
|
+
cidx = self.cursor_col_idx
|
|
1804
|
+
|
|
1805
|
+
# Get the value of the currently selected cell
|
|
1806
|
+
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1807
|
+
|
|
1808
|
+
self._do_search((term, cidx, False, False))
|
|
1809
|
+
|
|
1810
|
+
def _search_expr(self) -> None:
|
|
1811
|
+
"""Search by expression."""
|
|
1812
|
+
cidx = self.cursor_col_idx
|
|
1813
|
+
|
|
1814
|
+
# Use current cell value as default search term
|
|
1815
|
+
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1816
|
+
|
|
1817
|
+
# Push the search modal screen
|
|
1818
|
+
self.app.push_screen(
|
|
1819
|
+
SearchScreen("Search", term, self.df, cidx),
|
|
1820
|
+
callback=self._do_search,
|
|
1821
|
+
)
|
|
1822
|
+
|
|
1823
|
+
def _do_search(self, result) -> None:
|
|
1824
|
+
"""Search for a term."""
|
|
1825
|
+
if result is None:
|
|
1826
|
+
return
|
|
1827
|
+
term, cidx, match_nocase, match_whole = result
|
|
1828
|
+
col_name = self.df.columns[cidx]
|
|
1829
|
+
|
|
1830
|
+
if term == NULL:
|
|
1831
|
+
expr = pl.col(col_name).is_null()
|
|
1832
|
+
|
|
1833
|
+
# Support for polars expressions
|
|
1834
|
+
elif tentative_expr(term):
|
|
1835
|
+
try:
|
|
1836
|
+
expr = validate_expr(term, self.df, cidx)
|
|
1837
|
+
except Exception as e:
|
|
1838
|
+
self.notify(
|
|
1839
|
+
f"Failed to validate Polars expression [$error]{term}[/]: {str(e)}",
|
|
1840
|
+
title="Search",
|
|
1841
|
+
severity="error",
|
|
1842
|
+
)
|
|
1843
|
+
return
|
|
1844
|
+
|
|
1845
|
+
# Perform type-aware search based on column dtype
|
|
1846
|
+
else:
|
|
1847
|
+
dtype = self.df.dtypes[cidx]
|
|
1848
|
+
if dtype == pl.String:
|
|
1849
|
+
if match_whole:
|
|
1850
|
+
term = f"^{term}$"
|
|
1851
|
+
if match_nocase:
|
|
1852
|
+
term = f"(?i){term}"
|
|
1853
|
+
expr = pl.col(col_name).str.contains(term)
|
|
1854
|
+
else:
|
|
1855
|
+
try:
|
|
1856
|
+
value = DtypeConfig(dtype).convert(term)
|
|
1857
|
+
expr = pl.col(col_name) == value
|
|
1858
|
+
except Exception:
|
|
1859
|
+
if match_whole:
|
|
1860
|
+
term = f"^{term}$"
|
|
1861
|
+
if match_nocase:
|
|
1862
|
+
term = f"(?i){term}"
|
|
1863
|
+
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
1864
|
+
self.notify(
|
|
1865
|
+
f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1866
|
+
title="Search",
|
|
1867
|
+
severity="warning",
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
# Lazyframe for filtering
|
|
1871
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
1872
|
+
if False in self.visible_rows:
|
|
1873
|
+
lf = lf.filter(self.visible_rows)
|
|
1874
|
+
|
|
1875
|
+
# Apply filter to get matched row indices
|
|
1876
|
+
try:
|
|
1877
|
+
matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
|
|
1878
|
+
except Exception as e:
|
|
1879
|
+
self.notify(
|
|
1880
|
+
f"Error applying search filter: [$error]{str(e)}[/]",
|
|
1881
|
+
title="Search",
|
|
1882
|
+
severity="error",
|
|
1883
|
+
)
|
|
1884
|
+
return
|
|
1885
|
+
|
|
1886
|
+
match_count = len(matches)
|
|
1887
|
+
if match_count == 0:
|
|
1888
|
+
self.notify(
|
|
1889
|
+
f"No matches found for [$warning]{term}[/]. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
1890
|
+
title="Search",
|
|
1891
|
+
severity="warning",
|
|
1892
|
+
)
|
|
1893
|
+
return
|
|
1894
|
+
|
|
1895
|
+
# Add to history
|
|
1896
|
+
self._add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
1897
|
+
|
|
1898
|
+
# Update selected rows to include new matches
|
|
1899
|
+
for m in matches:
|
|
1900
|
+
self.selected_rows[m] = True
|
|
1901
|
+
|
|
1902
|
+
# Highlight matches
|
|
1903
|
+
self._do_highlight()
|
|
1904
|
+
|
|
1905
|
+
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
|
|
1906
|
+
|
|
1907
|
+
def _find_matches(
|
|
1908
|
+
self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
|
|
1909
|
+
) -> dict[int, set[int]]:
|
|
1910
|
+
"""Find matches for a term in the dataframe.
|
|
1911
|
+
|
|
1912
|
+
Args:
|
|
1913
|
+
term: The search term (can be NULL, expression, or plain text)
|
|
1914
|
+
cidx: Column index for column-specific search. If None, searches all columns.
|
|
1915
|
+
|
|
1916
|
+
Returns:
|
|
1917
|
+
Dictionary mapping row indices to sets of column indices containing matches.
|
|
1918
|
+
For column-specific search, each matched row has a set with single cidx.
|
|
1919
|
+
For global search, each matched row has a set of all matching cidxs in that row.
|
|
1920
|
+
|
|
1921
|
+
Raises:
|
|
1922
|
+
Exception: If expression validation or filtering fails.
|
|
1923
|
+
"""
|
|
1924
|
+
matches: dict[int, set[int]] = defaultdict(set)
|
|
1925
|
+
|
|
1926
|
+
# Lazyframe for filtering
|
|
1927
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
1928
|
+
if False in self.visible_rows:
|
|
1929
|
+
lf = lf.filter(self.visible_rows)
|
|
1930
|
+
|
|
1931
|
+
# Determine which columns to search: single column or all columns
|
|
1932
|
+
if cidx is not None:
|
|
1933
|
+
columns_to_search = [(cidx, self.df.columns[cidx])]
|
|
1934
|
+
else:
|
|
1935
|
+
columns_to_search = list(enumerate(self.df.columns))
|
|
1936
|
+
|
|
1937
|
+
# Search each column consistently
|
|
1938
|
+
for col_idx, col_name in columns_to_search:
|
|
1939
|
+
# Build expression based on term type
|
|
1940
|
+
if term == NULL:
|
|
1941
|
+
expr = pl.col(col_name).is_null()
|
|
1942
|
+
elif tentative_expr(term):
|
|
1943
|
+
try:
|
|
1944
|
+
expr = validate_expr(term, self.df, col_idx)
|
|
1945
|
+
except Exception as e:
|
|
1946
|
+
raise Exception(f"Error validating Polars expression: {str(e)}")
|
|
1947
|
+
else:
|
|
1948
|
+
if match_whole:
|
|
1949
|
+
term = f"^{term}$"
|
|
1950
|
+
if match_nocase:
|
|
1951
|
+
term = f"(?i){term}"
|
|
1952
|
+
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
1953
|
+
|
|
1954
|
+
# Get matched row indices
|
|
1955
|
+
try:
|
|
1956
|
+
matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
|
|
1957
|
+
except Exception as e:
|
|
1958
|
+
raise Exception(f"Error applying filter: {str(e)}")
|
|
1959
|
+
|
|
1960
|
+
for ridx in matched_ridxs:
|
|
1961
|
+
matches[ridx].add(col_idx)
|
|
1962
|
+
|
|
1963
|
+
return matches
|
|
1964
|
+
|
|
1965
|
+
def _find_cursor_value(self, scope="column") -> None:
|
|
1966
|
+
"""Find by cursor value.
|
|
1967
|
+
|
|
1968
|
+
Args:
|
|
1969
|
+
scope: "column" to find in current column, "global" to find across all columns.
|
|
1970
|
+
"""
|
|
1971
|
+
# Get the value of the currently selected cell
|
|
1972
|
+
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1973
|
+
|
|
1974
|
+
if scope == "column":
|
|
1975
|
+
cidx = self.cursor_col_idx
|
|
1976
|
+
self._do_find((term, cidx, False, False))
|
|
1977
|
+
else:
|
|
1978
|
+
self._do_find_global((term, None, False, False))
|
|
1979
|
+
|
|
1980
|
+
def _find_expr(self, scope="column") -> None:
|
|
1981
|
+
"""Open screen to find by expression.
|
|
1982
|
+
|
|
1983
|
+
Args:
|
|
1984
|
+
scope: "column" to find in current column, "global" to find across all columns.
|
|
1985
|
+
"""
|
|
1986
|
+
# Use current cell value as default search term
|
|
1987
|
+
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1988
|
+
cidx = self.cursor_col_idx if scope == "column" else None
|
|
1989
|
+
|
|
1990
|
+
# Push the search modal screen
|
|
1991
|
+
self.app.push_screen(
|
|
1992
|
+
SearchScreen("Find", term, self.df, cidx),
|
|
1993
|
+
callback=self._do_find if scope == "column" else self._do_find_global,
|
|
1994
|
+
)
|
|
1995
|
+
|
|
1996
|
+
def _do_find(self, result) -> None:
|
|
1997
|
+
"""Find a term in current column."""
|
|
1998
|
+
if result is None:
|
|
1999
|
+
return
|
|
2000
|
+
term, cidx, match_nocase, match_whole = result
|
|
2001
|
+
|
|
2002
|
+
col_name = self.df.columns[cidx]
|
|
2003
|
+
|
|
2004
|
+
try:
|
|
2005
|
+
matches = self._find_matches(term, cidx, match_nocase, match_whole)
|
|
2006
|
+
except Exception as e:
|
|
2007
|
+
self.notify(
|
|
2008
|
+
f"Error finding matches: [$error]{str(e)}[/]",
|
|
2009
|
+
title="Find",
|
|
2010
|
+
severity="error",
|
|
2011
|
+
)
|
|
2012
|
+
return
|
|
2013
|
+
|
|
2014
|
+
if not matches:
|
|
2015
|
+
self.notify(
|
|
2016
|
+
f"No matches found for [$warning]{term}[/] in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2017
|
+
title="Find",
|
|
2018
|
+
severity="warning",
|
|
2019
|
+
)
|
|
2020
|
+
return
|
|
2021
|
+
|
|
2022
|
+
# Add to history
|
|
2023
|
+
self._add_history(f"Found [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
2024
|
+
|
|
2025
|
+
# Add to matches and count total
|
|
2026
|
+
match_count = sum(len(col_idxs) for col_idxs in matches.values())
|
|
2027
|
+
for ridx, col_idxs in matches.items():
|
|
2028
|
+
self.matches[ridx].update(col_idxs)
|
|
2029
|
+
|
|
2030
|
+
# Highlight matches
|
|
2031
|
+
self._do_highlight()
|
|
2032
|
+
|
|
2033
|
+
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
|
|
2034
|
+
|
|
2035
|
+
def _do_find_global(self, result) -> None:
|
|
2036
|
+
"""Global find a term across all columns."""
|
|
2037
|
+
if result is None:
|
|
2038
|
+
return
|
|
2039
|
+
term, cidx, match_nocase, match_whole = result
|
|
2040
|
+
|
|
2041
|
+
try:
|
|
2042
|
+
matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2043
|
+
except Exception as e:
|
|
2044
|
+
self.notify(
|
|
2045
|
+
f"Error finding matches: [$error]{str(e)}[/]",
|
|
2046
|
+
title="Find",
|
|
2047
|
+
severity="error",
|
|
2048
|
+
)
|
|
2049
|
+
return
|
|
2050
|
+
|
|
2051
|
+
if not matches:
|
|
2052
|
+
self.notify(
|
|
2053
|
+
f"No matches found for [$warning]{term}[/] in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2054
|
+
title="Global Find",
|
|
2055
|
+
severity="warning",
|
|
2056
|
+
)
|
|
2057
|
+
return
|
|
2058
|
+
|
|
2059
|
+
# Add to history
|
|
2060
|
+
self._add_history(f"Found [$success]{term}[/] across all columns")
|
|
2061
|
+
|
|
2062
|
+
# Add to matches and count total
|
|
2063
|
+
match_count = sum(len(col_idxs) for col_idxs in matches.values())
|
|
2064
|
+
for ridx, col_idxs in matches.items():
|
|
2065
|
+
self.matches[ridx].update(col_idxs)
|
|
2066
|
+
|
|
2067
|
+
# Highlight matches
|
|
2068
|
+
self._do_highlight()
|
|
2069
|
+
|
|
2070
|
+
self.notify(
|
|
2071
|
+
f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
|
|
2072
|
+
title="Global Find",
|
|
2073
|
+
)
|
|
2074
|
+
|
|
2075
|
+
def _move_cursor(self, ridx: int, cidx: int) -> None:
|
|
2076
|
+
"""Move cursor based on the dataframe indices.
|
|
2077
|
+
|
|
2078
|
+
Args:
|
|
2079
|
+
ridx: Row index (0-based) in the dataframe.
|
|
2080
|
+
cidx: Column index (0-based) in the dataframe.
|
|
2081
|
+
"""
|
|
2082
|
+
row_key = str(ridx)
|
|
2083
|
+
col_key = self.df.columns[cidx]
|
|
2084
|
+
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
2085
|
+
self.move_cursor(row=row_idx, column=col_idx)
|
|
2086
|
+
|
|
2087
|
+
def _next_match(self) -> None:
|
|
2088
|
+
"""Move cursor to the next match."""
|
|
2089
|
+
if not self.matches:
|
|
2090
|
+
self.notify("No matches to navigate", title="Next Match", severity="warning")
|
|
2091
|
+
return
|
|
2092
|
+
|
|
2093
|
+
# Get sorted list of matched coordinates
|
|
2094
|
+
ordered_matches = self.ordered_matches
|
|
2095
|
+
|
|
2096
|
+
# Current cursor position
|
|
2097
|
+
current_pos = (self.cursor_row_idx, self.cursor_col_idx)
|
|
2098
|
+
|
|
2099
|
+
# Find the next match after current position
|
|
2100
|
+
for ridx, cidx in ordered_matches:
|
|
2101
|
+
if (ridx, cidx) > current_pos:
|
|
2102
|
+
self._move_cursor(ridx, cidx)
|
|
2103
|
+
return
|
|
2104
|
+
|
|
2105
|
+
# If no next match, wrap around to the first match
|
|
2106
|
+
first_ridx, first_cidx = ordered_matches[0]
|
|
2107
|
+
self._move_cursor(first_ridx, first_cidx)
|
|
2108
|
+
|
|
2109
|
+
def _previous_match(self) -> None:
|
|
2110
|
+
"""Move cursor to the previous match."""
|
|
2111
|
+
if not self.matches:
|
|
2112
|
+
self.notify("No matches to navigate", title="Previous Match", severity="warning")
|
|
2113
|
+
return
|
|
2114
|
+
|
|
2115
|
+
# Get sorted list of matched coordinates
|
|
2116
|
+
ordered_matches = self.ordered_matches
|
|
2117
|
+
|
|
2118
|
+
# Current cursor position
|
|
2119
|
+
current_pos = (self.cursor_row_idx, self.cursor_col_idx)
|
|
2120
|
+
|
|
2121
|
+
# Find the previous match before current position
|
|
2122
|
+
for ridx, cidx in reversed(ordered_matches):
|
|
2123
|
+
if (ridx, cidx) < current_pos:
|
|
2124
|
+
row_key = str(ridx)
|
|
2125
|
+
col_key = self.df.columns[cidx]
|
|
2126
|
+
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
2127
|
+
self.move_cursor(row=row_idx, column=col_idx)
|
|
2128
|
+
return
|
|
2129
|
+
|
|
2130
|
+
# If no previous match, wrap around to the last match
|
|
2131
|
+
last_ridx, last_cidx = ordered_matches[-1]
|
|
2132
|
+
row_key = str(last_ridx)
|
|
2133
|
+
col_key = self.df.columns[last_cidx]
|
|
2134
|
+
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
2135
|
+
self.move_cursor(row=row_idx, column=col_idx)
|
|
2136
|
+
|
|
2137
|
+
def _next_selected_row(self) -> None:
|
|
2138
|
+
"""Move cursor to the next selected row."""
|
|
2139
|
+
if not any(self.selected_rows):
|
|
2140
|
+
self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
|
|
2141
|
+
return
|
|
2142
|
+
|
|
2143
|
+
# Get list of selected row indices in order
|
|
2144
|
+
selected_row_indices = self.ordered_selected_rows
|
|
2145
|
+
|
|
2146
|
+
# Current cursor row
|
|
2147
|
+
current_ridx = self.cursor_row_idx
|
|
2148
|
+
|
|
2149
|
+
# Find the next selected row after current position
|
|
2150
|
+
for ridx in selected_row_indices:
|
|
2151
|
+
if ridx > current_ridx:
|
|
2152
|
+
self._move_cursor(ridx, self.cursor_col_idx)
|
|
2153
|
+
return
|
|
2154
|
+
|
|
2155
|
+
# If no next selected row, wrap around to the first selected row
|
|
2156
|
+
first_ridx = selected_row_indices[0]
|
|
2157
|
+
self._move_cursor(first_ridx, self.cursor_col_idx)
|
|
2158
|
+
|
|
2159
|
+
def _previous_selected_row(self) -> None:
|
|
2160
|
+
"""Move cursor to the previous selected row."""
|
|
2161
|
+
if not any(self.selected_rows):
|
|
2162
|
+
self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
|
|
2163
|
+
return
|
|
2164
|
+
|
|
2165
|
+
# Get list of selected row indices in order
|
|
2166
|
+
selected_row_indices = self.ordered_selected_rows
|
|
2167
|
+
|
|
2168
|
+
# Current cursor row
|
|
2169
|
+
current_ridx = self.cursor_row_idx
|
|
2170
|
+
|
|
2171
|
+
# Find the previous selected row before current position
|
|
2172
|
+
for ridx in reversed(selected_row_indices):
|
|
2173
|
+
if ridx < current_ridx:
|
|
2174
|
+
self._move_cursor(ridx, self.cursor_col_idx)
|
|
2175
|
+
return
|
|
2176
|
+
|
|
2177
|
+
# If no previous selected row, wrap around to the last selected row
|
|
2178
|
+
last_ridx = selected_row_indices[-1]
|
|
2179
|
+
self._move_cursor(last_ridx, self.cursor_col_idx)
|
|
2180
|
+
|
|
2181
|
+
def _replace(self) -> None:
|
|
2182
|
+
"""Open replace screen for current column."""
|
|
2183
|
+
# Push the replace modal screen
|
|
2184
|
+
self.app.push_screen(
|
|
2185
|
+
FindReplaceScreen(self),
|
|
2186
|
+
callback=self._do_replace,
|
|
2187
|
+
)
|
|
2188
|
+
|
|
2189
|
+
def _do_replace(self, result) -> None:
|
|
2190
|
+
"""Handle replace in current column."""
|
|
2191
|
+
self._handle_replace(result, self.cursor_col_idx)
|
|
2192
|
+
|
|
2193
|
+
def _replace_global(self) -> None:
|
|
2194
|
+
"""Open replace screen for all columns."""
|
|
2195
|
+
# Push the replace modal screen
|
|
2196
|
+
self.app.push_screen(
|
|
2197
|
+
FindReplaceScreen(self),
|
|
2198
|
+
callback=self._do_replace_global,
|
|
2199
|
+
)
|
|
2200
|
+
|
|
2201
|
+
def _do_replace_global(self, result) -> None:
|
|
2202
|
+
"""Handle replace across all columns."""
|
|
2203
|
+
self._handle_replace(result, None)
|
|
2204
|
+
|
|
2205
|
+
def _handle_replace(self, result, cidx) -> None:
|
|
2206
|
+
"""Handle replace result from ReplaceScreen.
|
|
2207
|
+
|
|
2208
|
+
Args:
|
|
2209
|
+
result: Result tuple from ReplaceScreen
|
|
2210
|
+
cidx: Column index to perform replacement. If None, replace across all columns.
|
|
2211
|
+
"""
|
|
2212
|
+
if result is None:
|
|
2213
|
+
return
|
|
2214
|
+
term_find, term_replace, match_nocase, match_whole, replace_all = result
|
|
2215
|
+
|
|
2216
|
+
if cidx is None:
|
|
2217
|
+
col_name = "all columns"
|
|
2218
|
+
else:
|
|
2219
|
+
col_name = self.df.columns[cidx]
|
|
2220
|
+
|
|
2221
|
+
# Find all matches
|
|
2222
|
+
matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
|
|
2223
|
+
|
|
2224
|
+
if not matches:
|
|
2225
|
+
self.notify(
|
|
2226
|
+
f"No matches found for [$warning]{term_find}[/]",
|
|
2227
|
+
title="Replace",
|
|
2228
|
+
severity="warning",
|
|
2229
|
+
)
|
|
2230
|
+
return
|
|
2231
|
+
|
|
2232
|
+
# Add to history
|
|
2233
|
+
self._add_history(
|
|
2234
|
+
f"Replacing [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
|
|
2235
|
+
)
|
|
2236
|
+
|
|
2237
|
+
# Update matches
|
|
2238
|
+
self.matches = {ridx: set(col_idxs) for ridx, col_idxs in matches.items()}
|
|
2239
|
+
|
|
2240
|
+
# Highlight matches
|
|
2241
|
+
self._do_highlight()
|
|
2242
|
+
|
|
2243
|
+
# Store state for interactive replacement using dataclass
|
|
2244
|
+
self._replace_state = ReplaceState(
|
|
2245
|
+
term_find=term_find,
|
|
2246
|
+
term_replace=term_replace,
|
|
2247
|
+
match_nocase=match_nocase,
|
|
2248
|
+
match_whole=match_whole,
|
|
2249
|
+
cidx=cidx,
|
|
2250
|
+
rows=sorted(list(self.matches.keys())),
|
|
2251
|
+
cols_per_row=[sorted(list(self.matches[ridx])) for ridx in sorted(self.matches.keys())],
|
|
2252
|
+
current_rpos=0,
|
|
2253
|
+
current_cpos=0,
|
|
2254
|
+
current_occurrence=0,
|
|
2255
|
+
total_occurrence=len(self.matches),
|
|
2256
|
+
replaced_occurrence=0,
|
|
2257
|
+
skipped_occurrence=0,
|
|
2258
|
+
done=False,
|
|
2259
|
+
)
|
|
2260
|
+
|
|
2261
|
+
try:
|
|
2262
|
+
if replace_all:
|
|
2263
|
+
# Replace all occurrences
|
|
2264
|
+
self._do_replace_all(term_find, term_replace)
|
|
2265
|
+
else:
|
|
2266
|
+
# Replace with confirmation for each occurrence
|
|
2267
|
+
self._do_replace_interactive(term_find, term_replace)
|
|
2268
|
+
|
|
2269
|
+
except Exception as e:
|
|
2270
|
+
self.notify(
|
|
2271
|
+
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]: {str(e)}",
|
|
2272
|
+
title="Replace",
|
|
2273
|
+
severity="error",
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
def _do_replace_all(self, term_find: str, term_replace: str) -> None:
|
|
2277
|
+
"""Replace all occurrences."""
|
|
2278
|
+
state = self._replace_state
|
|
2279
|
+
self.app.push_screen(
|
|
2280
|
+
ConfirmScreen(
|
|
2281
|
+
"Replace All",
|
|
2282
|
+
label=f"Replace [$accent]{term_find}[/] with [$success]{term_replace}[/] for all [$accent]{state.total_occurrence}[/] occurrences?",
|
|
2283
|
+
),
|
|
2284
|
+
callback=self._handle_replace_all_confirmation,
|
|
2285
|
+
)
|
|
2286
|
+
|
|
2287
|
+
def _handle_replace_all_confirmation(self, result) -> None:
|
|
2288
|
+
"""Handle user's confirmation for replace all."""
|
|
2289
|
+
if result is None:
|
|
2290
|
+
return
|
|
2291
|
+
|
|
2292
|
+
state = self._replace_state
|
|
2293
|
+
rows = state.rows
|
|
2294
|
+
cols_per_row = state.cols_per_row
|
|
2295
|
+
|
|
2296
|
+
# Replace in each matched row/column
|
|
2297
|
+
for ridx, col_idxs in zip(rows, cols_per_row):
|
|
2298
|
+
for cidx in col_idxs:
|
|
2299
|
+
col_name = self.df.columns[cidx]
|
|
2300
|
+
dtype = self.df.dtypes[cidx]
|
|
2301
|
+
|
|
2302
|
+
# Only applicable to string columns for substring matches
|
|
2303
|
+
if dtype == pl.String and not state.match_whole:
|
|
2304
|
+
term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
|
|
2305
|
+
self.df = self.df.with_columns(
|
|
2306
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2307
|
+
.then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
|
|
2308
|
+
.otherwise(pl.col(col_name))
|
|
2309
|
+
.alias(col_name)
|
|
2310
|
+
)
|
|
2311
|
+
else:
|
|
2312
|
+
# try to convert replacement value to column dtype
|
|
2313
|
+
try:
|
|
2314
|
+
value = DtypeConfig(dtype).convert(state.term_replace)
|
|
2315
|
+
except Exception:
|
|
2316
|
+
value = state.term_replace
|
|
2317
|
+
|
|
2318
|
+
self.df = self.df.with_columns(
|
|
2319
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2320
|
+
.then(pl.lit(value))
|
|
2321
|
+
.otherwise(pl.col(col_name))
|
|
2322
|
+
.alias(col_name)
|
|
2323
|
+
)
|
|
2324
|
+
|
|
2325
|
+
state.replaced_occurrence += 1
|
|
2326
|
+
|
|
2327
|
+
# Recreate the table display
|
|
2328
|
+
self._setup_table()
|
|
2329
|
+
|
|
2330
|
+
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2331
|
+
self.notify(
|
|
2332
|
+
f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]",
|
|
2333
|
+
title="Replace",
|
|
2334
|
+
)
|
|
2335
|
+
|
|
2336
|
+
def _do_replace_interactive(self, term_find: str, term_replace: str) -> None:
|
|
2337
|
+
"""Replace with user confirmation for each occurrence."""
|
|
2338
|
+
try:
|
|
2339
|
+
# Start with first match
|
|
2340
|
+
self._show_next_replace_confirmation()
|
|
2341
|
+
except Exception as e:
|
|
2342
|
+
self.notify(
|
|
2343
|
+
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]: {str(e)}",
|
|
2344
|
+
title="Replace",
|
|
2345
|
+
severity="error",
|
|
2346
|
+
)
|
|
2347
|
+
|
|
2348
|
+
def _show_next_replace_confirmation(self) -> None:
|
|
2349
|
+
"""Show confirmation for next replacement."""
|
|
2350
|
+
state = self._replace_state
|
|
2351
|
+
if state.done:
|
|
2352
|
+
# All done - show final notification
|
|
2353
|
+
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2354
|
+
msg = f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]"
|
|
2355
|
+
if state.skipped_occurrence > 0:
|
|
2356
|
+
msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
|
|
2357
|
+
self.notify(msg, title="Replace")
|
|
2358
|
+
return
|
|
2359
|
+
|
|
2360
|
+
# Move cursor to next match
|
|
2361
|
+
ridx = state.rows[state.current_rpos]
|
|
2362
|
+
cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
|
|
2363
|
+
self.move_cursor(row=ridx, column=cidx)
|
|
2364
|
+
|
|
2365
|
+
state.current_occurrence += 1
|
|
2366
|
+
|
|
2367
|
+
# Show confirmation
|
|
2368
|
+
label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] (Occurrence {state.current_occurrence} of {state.total_occurrence})?"
|
|
2369
|
+
|
|
2370
|
+
self.app.push_screen(
|
|
2371
|
+
ConfirmScreen("Replace", label=label, maybe="Skip"),
|
|
2372
|
+
callback=self._handle_replace_confirmation,
|
|
2373
|
+
)
|
|
2374
|
+
|
|
2375
|
+
def _handle_replace_confirmation(self, result) -> None:
|
|
2376
|
+
"""Handle user's confirmation response."""
|
|
2377
|
+
state = self._replace_state
|
|
2378
|
+
if state.done:
|
|
2379
|
+
return
|
|
2380
|
+
|
|
2381
|
+
ridx = state.rows[state.current_rpos]
|
|
2382
|
+
cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
|
|
2383
|
+
col_name = self.df.columns[cidx]
|
|
2384
|
+
dtype = self.df.dtypes[cidx]
|
|
2385
|
+
|
|
2386
|
+
# Replace
|
|
2387
|
+
if result is True:
|
|
2388
|
+
# Only applicable to string columns for substring matches
|
|
2389
|
+
if dtype == pl.String and not state.match_whole:
|
|
2390
|
+
term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
|
|
2391
|
+
self.df = self.df.with_columns(
|
|
2392
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2393
|
+
.then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
|
|
2394
|
+
.otherwise(pl.col(col_name))
|
|
2395
|
+
.alias(col_name)
|
|
2396
|
+
)
|
|
2397
|
+
else:
|
|
2398
|
+
# try to convert replacement value to column dtype
|
|
2399
|
+
try:
|
|
2400
|
+
value = DtypeConfig(dtype).convert(state.term_replace)
|
|
2401
|
+
except Exception:
|
|
2402
|
+
value = state.term_replace
|
|
2403
|
+
|
|
2404
|
+
self.df = self.df.with_columns(
|
|
2405
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2406
|
+
.then(pl.lit(value))
|
|
2407
|
+
.otherwise(pl.col(col_name))
|
|
2408
|
+
.alias(col_name)
|
|
2409
|
+
)
|
|
2410
|
+
|
|
2411
|
+
state.replaced_occurrence += 1
|
|
2412
|
+
|
|
2413
|
+
# Skip
|
|
2414
|
+
elif result is False:
|
|
2415
|
+
state.skipped_occurrence += 1
|
|
2416
|
+
|
|
2417
|
+
# Cancel
|
|
2418
|
+
else:
|
|
2419
|
+
state.done = True
|
|
2420
|
+
self._setup_table()
|
|
2421
|
+
return
|
|
2422
|
+
|
|
2423
|
+
# Move to next
|
|
2424
|
+
if state.current_cpos + 1 < len(state.cols_per_row[state.current_rpos]):
|
|
2425
|
+
state.current_cpos += 1
|
|
2426
|
+
else:
|
|
2427
|
+
state.current_cpos = 0
|
|
2428
|
+
state.current_rpos += 1
|
|
2429
|
+
|
|
2430
|
+
if state.current_rpos >= len(state.rows):
|
|
2431
|
+
state.done = True
|
|
2432
|
+
|
|
2433
|
+
# Recreate the table display
|
|
2434
|
+
self._setup_table()
|
|
2435
|
+
|
|
2436
|
+
# Show next confirmation
|
|
2437
|
+
self._show_next_replace_confirmation()
|
|
2438
|
+
|
|
2439
|
+
def _toggle_selections(self) -> None:
|
|
2440
|
+
"""Toggle selected rows highlighting on/off."""
|
|
2441
|
+
# Save current state to history
|
|
2442
|
+
self._add_history("Toggled row selection")
|
|
2443
|
+
|
|
2444
|
+
if False in self.visible_rows:
|
|
2445
|
+
# Some rows are hidden - invert only selected visible rows and clear selections for hidden rows
|
|
2446
|
+
for i in range(len(self.selected_rows)):
|
|
2447
|
+
if self.visible_rows[i]:
|
|
2448
|
+
self.selected_rows[i] = not self.selected_rows[i]
|
|
2449
|
+
else:
|
|
2450
|
+
self.selected_rows[i] = False
|
|
2451
|
+
else:
|
|
2452
|
+
# Invert all selected rows
|
|
2453
|
+
self.selected_rows = [not selected for selected in self.selected_rows]
|
|
2454
|
+
|
|
2455
|
+
# Check if we're highlighting or un-highlighting
|
|
2456
|
+
if new_selected_count := self.selected_rows.count(True):
|
|
2457
|
+
self.notify(
|
|
2458
|
+
f"Toggled selection for [$accent]{new_selected_count}[/] rows",
|
|
2459
|
+
title="Toggle",
|
|
2460
|
+
)
|
|
2461
|
+
|
|
2462
|
+
# Refresh the highlighting (also restores default styles for unselected rows)
|
|
2463
|
+
self._do_highlight()
|
|
2464
|
+
|
|
2465
|
+
def _make_selections(self) -> None:
|
|
2466
|
+
"""Make selections based on current matches or toggle current row selection."""
|
|
2467
|
+
# Save current state to history
|
|
2468
|
+
self._add_history("Toggled row selection")
|
|
2469
|
+
|
|
2470
|
+
if self.matches:
|
|
2471
|
+
# There are matched cells - select rows with matches
|
|
2472
|
+
for ridx in self.matches.keys():
|
|
2473
|
+
self.selected_rows[ridx] = True
|
|
2474
|
+
else:
|
|
2475
|
+
# No matched cells - select/deselect the current row
|
|
2476
|
+
ridx = self.cursor_row_idx
|
|
2477
|
+
self.selected_rows[ridx] = not self.selected_rows[ridx]
|
|
2478
|
+
|
|
2479
|
+
# Check if we're highlighting or un-highlighting
|
|
2480
|
+
if new_selected_count := self.selected_rows.count(True):
|
|
2481
|
+
self.notify(f"Selected [$accent]{new_selected_count}[/] rows", title="Toggle")
|
|
2482
|
+
|
|
2483
|
+
# Refresh the highlighting (also restores default styles for unselected rows)
|
|
2484
|
+
self._do_highlight()
|
|
2485
|
+
|
|
2486
|
+
def _clear_selections(self) -> None:
|
|
2487
|
+
"""Clear all selected rows without removing them from the dataframe."""
|
|
2488
|
+
# Check if any selected rows or matches
|
|
2489
|
+
if not any(self.selected_rows) and not self.matches:
|
|
2490
|
+
self.notify("No selections to clear", title="Clear", severity="warning")
|
|
2491
|
+
return
|
|
2492
|
+
|
|
2493
|
+
row_count = sum(
|
|
2494
|
+
1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
|
|
2495
|
+
)
|
|
2496
|
+
|
|
2497
|
+
# Save current state to history
|
|
2498
|
+
self._add_history("Cleared all selected rows")
|
|
2499
|
+
|
|
2500
|
+
# Clear all selections and refresh highlighting
|
|
2501
|
+
self._do_highlight(clear=True)
|
|
2502
|
+
|
|
2503
|
+
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
2504
|
+
|
|
2505
|
+
def _filter_selected_rows(self) -> None:
|
|
2506
|
+
"""Keep only the selected rows and remove unselected ones."""
|
|
2507
|
+
selected_count = self.selected_rows.count(True)
|
|
2508
|
+
if selected_count == 0:
|
|
2509
|
+
self.notify("No rows selected to filter", title="Filter", severity="warning")
|
|
2510
|
+
return
|
|
2511
|
+
|
|
2512
|
+
# Save current state to history
|
|
2513
|
+
self._add_history("Filtered to selected rows")
|
|
2514
|
+
|
|
2515
|
+
# Update dataframe to only include selected rows
|
|
2516
|
+
self.df = self.df.filter(self.selected_rows)
|
|
2517
|
+
self.selected_rows = [True] * len(self.df)
|
|
2518
|
+
|
|
2519
|
+
# Recreate the table for display
|
|
2520
|
+
self._setup_table()
|
|
2521
|
+
|
|
2522
|
+
self.notify(
|
|
2523
|
+
f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
|
|
2524
|
+
title="Filter",
|
|
2525
|
+
)
|
|
2526
|
+
|
|
2527
|
+
def _view_rows(self) -> None:
|
|
2528
|
+
"""View rows.
|
|
2529
|
+
|
|
2530
|
+
If there are selected rows, view those rows.
|
|
2531
|
+
Otherwise, view based on the value of the currently selected cell.
|
|
2532
|
+
"""
|
|
2533
|
+
|
|
2534
|
+
cidx = self.cursor_col_idx
|
|
2535
|
+
|
|
2536
|
+
# If there are selected rows or matches, use those
|
|
2537
|
+
if any(self.selected_rows) or self.matches:
|
|
2538
|
+
term = [
|
|
2539
|
+
True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
|
|
2540
|
+
]
|
|
2541
|
+
# Otherwise, use the current cell value
|
|
2542
|
+
else:
|
|
2543
|
+
ridx = self.cursor_row_idx
|
|
2544
|
+
term = str(self.df.item(ridx, cidx))
|
|
2545
|
+
|
|
2546
|
+
self._do_view_rows((term, cidx, False, False))
|
|
2547
|
+
|
|
2548
|
+
def _view_rows_expr(self) -> None:
|
|
2549
|
+
"""Open the filter screen to enter an expression."""
|
|
2550
|
+
ridx = self.cursor_row_idx
|
|
2551
|
+
cidx = self.cursor_col_idx
|
|
2552
|
+
cursor_value = str(self.df.item(ridx, cidx))
|
|
2553
|
+
|
|
2554
|
+
self.app.push_screen(
|
|
2555
|
+
FilterScreen(self.df, cidx, cursor_value),
|
|
2556
|
+
callback=self._do_view_rows,
|
|
2557
|
+
)
|
|
2558
|
+
|
|
2559
|
+
def _do_view_rows(self, result) -> None:
|
|
2560
|
+
"""Show only those matching rows and hide others. Do not modify the dataframe."""
|
|
2561
|
+
if result is None:
|
|
2562
|
+
return
|
|
2563
|
+
term, cidx, match_nocase, match_whole = result
|
|
2564
|
+
|
|
2565
|
+
col_name = self.df.columns[cidx]
|
|
2566
|
+
|
|
2567
|
+
if term == NULL:
|
|
2568
|
+
expr = pl.col(col_name).is_null()
|
|
2569
|
+
elif isinstance(term, (list, pl.Series)):
|
|
2570
|
+
# Support for list of booleans (selected rows)
|
|
2571
|
+
expr = term
|
|
2572
|
+
elif tentative_expr(term):
|
|
2573
|
+
# Support for polars expressions
|
|
2574
|
+
try:
|
|
2575
|
+
expr = validate_expr(term, self.df, cidx)
|
|
2576
|
+
except Exception as e:
|
|
2577
|
+
self.notify(
|
|
2578
|
+
f"Error validating Polars expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
|
|
2579
|
+
)
|
|
2580
|
+
return
|
|
2581
|
+
else:
|
|
2582
|
+
dtype = self.df.dtypes[cidx]
|
|
2583
|
+
if dtype == pl.String:
|
|
2584
|
+
if match_whole:
|
|
2585
|
+
term = f"^{term}$"
|
|
2586
|
+
if match_nocase:
|
|
2587
|
+
term = f"(?i){term}"
|
|
2588
|
+
expr = pl.col(col_name).str.contains(term)
|
|
2589
|
+
else:
|
|
2590
|
+
try:
|
|
2591
|
+
value = DtypeConfig(dtype).convert(term)
|
|
2592
|
+
expr = pl.col(col_name) == value
|
|
2593
|
+
except Exception:
|
|
2594
|
+
if match_whole:
|
|
2595
|
+
term = f"^{term}$"
|
|
2596
|
+
if match_nocase:
|
|
2597
|
+
term = f"(?i){term}"
|
|
2598
|
+
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
2599
|
+
self.notify(
|
|
2600
|
+
f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
|
|
2601
|
+
title="Filter",
|
|
2602
|
+
severity="warning",
|
|
2603
|
+
)
|
|
2604
|
+
|
|
2605
|
+
# Lazyframe with row indices
|
|
2606
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
2607
|
+
|
|
2608
|
+
# Apply existing visibility filter first
|
|
2609
|
+
if False in self.visible_rows:
|
|
2610
|
+
lf = lf.filter(self.visible_rows)
|
|
2611
|
+
|
|
2612
|
+
# Apply the filter expression
|
|
2613
|
+
try:
|
|
2614
|
+
df_filtered = lf.filter(expr).collect()
|
|
2615
|
+
except Exception as e:
|
|
2616
|
+
self.notify(f"Failed to apply filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
|
|
2617
|
+
self.histories.pop() # Remove last history entry
|
|
2618
|
+
return
|
|
2619
|
+
|
|
2620
|
+
matched_count = len(df_filtered)
|
|
2621
|
+
if not matched_count:
|
|
2622
|
+
self.notify(
|
|
2623
|
+
f"No rows match the expression: [$success]{expr}[/]",
|
|
2624
|
+
title="Filter",
|
|
2625
|
+
severity="warning",
|
|
2626
|
+
)
|
|
2627
|
+
return
|
|
2628
|
+
|
|
2629
|
+
# Add to history
|
|
2630
|
+
self._add_history(f"Filtered by expression [$success]{expr}[/]")
|
|
2631
|
+
|
|
2632
|
+
# Mark unfiltered rows as invisible
|
|
2633
|
+
filtered_row_indices = set(df_filtered[RIDX].to_list())
|
|
2634
|
+
if filtered_row_indices:
|
|
2635
|
+
for ridx in range(len(self.visible_rows)):
|
|
2636
|
+
if ridx not in filtered_row_indices:
|
|
2637
|
+
self.visible_rows[ridx] = False
|
|
2638
|
+
|
|
2639
|
+
# Recreate the table for display
|
|
2640
|
+
self._setup_table()
|
|
2641
|
+
|
|
2642
|
+
self.notify(
|
|
2643
|
+
f"Filtered to [$accent]{matched_count}[/] matching rows",
|
|
2644
|
+
title="Filter",
|
|
2645
|
+
)
|
|
2646
|
+
|
|
2647
|
+
def _cycle_cursor_type(self) -> None:
|
|
2648
|
+
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
2649
|
+
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
2650
|
+
self.cursor_type = next_type
|
|
2651
|
+
|
|
2652
|
+
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
2653
|
+
|
|
2654
|
+
def _copy_to_clipboard(self, content: str, message: str) -> None:
|
|
2655
|
+
"""Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
|
|
2656
|
+
|
|
2657
|
+
Args:
|
|
2658
|
+
content: The text content to copy to clipboard.
|
|
2659
|
+
message: The notification message to display on success.
|
|
2660
|
+
"""
|
|
2661
|
+
import subprocess
|
|
2662
|
+
|
|
2663
|
+
try:
|
|
2664
|
+
subprocess.run(
|
|
2665
|
+
[
|
|
2666
|
+
"pbcopy" if sys.platform == "darwin" else "xclip",
|
|
2667
|
+
"-selection",
|
|
2668
|
+
"clipboard",
|
|
2669
|
+
],
|
|
2670
|
+
input=content,
|
|
2671
|
+
text=True,
|
|
2672
|
+
)
|
|
2673
|
+
self.notify(message, title="Clipboard")
|
|
2674
|
+
except FileNotFoundError:
|
|
2675
|
+
self.notify("Error copying to clipboard", title="Clipboard", severity="error")
|
|
2676
|
+
|
|
2677
|
+
def _save_to_file(self) -> None:
|
|
2678
|
+
"""Open screen to save file."""
|
|
2679
|
+
self.app.push_screen(SaveFileScreen(self.filename), callback=self._do_save_file)
|
|
2680
|
+
|
|
2681
|
+
def _do_save_file(self, filename: str | None, all_tabs: bool = False) -> None:
|
|
2682
|
+
"""Handle result from SaveFileScreen."""
|
|
2683
|
+
if filename is None:
|
|
2684
|
+
return
|
|
2685
|
+
filepath = Path(filename)
|
|
2686
|
+
ext = filepath.suffix.lower()
|
|
2687
|
+
|
|
2688
|
+
# Whether to save all tabs (for Excel files)
|
|
2689
|
+
self._all_tabs = all_tabs
|
|
2690
|
+
|
|
2691
|
+
# Check if file exists
|
|
2692
|
+
if filepath.exists():
|
|
2693
|
+
self._pending_filename = filename
|
|
2694
|
+
self.app.push_screen(
|
|
2695
|
+
ConfirmScreen("File already exists. Overwrite?"),
|
|
2696
|
+
callback=self._on_overwrite_screen,
|
|
2697
|
+
)
|
|
2698
|
+
elif ext in (".xlsx", ".xls"):
|
|
2699
|
+
self._do_save_excel(filename)
|
|
2700
|
+
else:
|
|
2701
|
+
self._do_save(filename)
|
|
2702
|
+
|
|
2703
|
+
def _on_overwrite_screen(self, should_overwrite: bool) -> None:
|
|
2704
|
+
"""Handle result from ConfirmScreen."""
|
|
2705
|
+
if should_overwrite:
|
|
2706
|
+
self._do_save(self._pending_filename)
|
|
2707
|
+
else:
|
|
2708
|
+
# Go back to SaveFileScreen to allow user to enter a different name
|
|
2709
|
+
self.app.push_screen(
|
|
2710
|
+
SaveFileScreen(self._pending_filename),
|
|
2711
|
+
callback=self._do_save_file,
|
|
2712
|
+
)
|
|
2713
|
+
|
|
2714
|
+
def _do_save(self, filename: str) -> None:
|
|
2715
|
+
"""Actually save the dataframe to a file."""
|
|
2716
|
+
filepath = Path(filename)
|
|
2717
|
+
ext = filepath.suffix.lower()
|
|
2718
|
+
|
|
2719
|
+
try:
|
|
2720
|
+
if ext in (".xlsx", ".xls"):
|
|
2721
|
+
self._do_save_excel(filename)
|
|
2722
|
+
elif ext in (".tsv", ".tab"):
|
|
2723
|
+
self.df.write_csv(filename, separator="\t")
|
|
2724
|
+
elif ext == ".json":
|
|
2725
|
+
self.df.write_json(filename)
|
|
2726
|
+
elif ext == ".parquet":
|
|
2727
|
+
self.df.write_parquet(filename)
|
|
2728
|
+
else:
|
|
2729
|
+
self.df.write_csv(filename)
|
|
2730
|
+
|
|
2731
|
+
self.lazyframe = self.df.lazy() # Update original dataframe
|
|
2732
|
+
self.filename = filename # Update current filename
|
|
2733
|
+
if not self._all_tabs:
|
|
2734
|
+
extra = "current tab with " if len(self.app.tabs) > 1 else ""
|
|
2735
|
+
self.notify(
|
|
2736
|
+
f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
|
|
2737
|
+
title="Save",
|
|
2738
|
+
)
|
|
2739
|
+
except Exception as e:
|
|
2740
|
+
self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
|
|
2741
|
+
raise e
|
|
2742
|
+
|
|
2743
|
+
def _do_save_excel(self, filename: str) -> None:
|
|
2744
|
+
"""Save to an Excel file."""
|
|
2745
|
+
import xlsxwriter
|
|
2746
|
+
|
|
2747
|
+
if not self._all_tabs or len(self.app.tabs) == 1:
|
|
2748
|
+
# Single tab - save directly
|
|
2749
|
+
self.df.write_excel(filename)
|
|
2750
|
+
else:
|
|
2751
|
+
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
2752
|
+
with xlsxwriter.Workbook(filename) as wb:
|
|
2753
|
+
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
2754
|
+
for tab, table in tabs.items():
|
|
2755
|
+
worksheet = wb.add_worksheet(tab.name)
|
|
2756
|
+
table.df.write_excel(workbook=wb, worksheet=worksheet)
|
|
2757
|
+
|
|
2758
|
+
# From ConfirmScreen callback, so notify accordingly
|
|
2759
|
+
if self._all_tabs is True:
|
|
2760
|
+
self.notify(
|
|
2761
|
+
f"Saved all tabs to [$success]{filename}[/]",
|
|
2762
|
+
title="Save",
|
|
2763
|
+
)
|
|
2764
|
+
else:
|
|
2765
|
+
self.notify(
|
|
2766
|
+
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
|
|
2767
|
+
title="Save",
|
|
2768
|
+
)
|