dataframe-textual 0.3.0__py3-none-any.whl → 1.0.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.
- dataframe_textual/__main__.py +29 -12
- dataframe_textual/common.py +207 -91
- dataframe_textual/data_frame_help_panel.py +22 -4
- dataframe_textual/data_frame_table.py +1964 -591
- dataframe_textual/data_frame_viewer.py +285 -133
- dataframe_textual/table_screen.py +320 -145
- dataframe_textual/yes_no_screen.py +429 -166
- dataframe_textual-1.0.0.dist-info/METADATA +733 -0
- dataframe_textual-1.0.0.dist-info/RECORD +13 -0
- dataframe_textual-0.3.0.dist-info/METADATA +0 -548
- dataframe_textual-0.3.0.dist-info/RECORD +0 -13
- {dataframe_textual-0.3.0.dist-info → dataframe_textual-1.0.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-0.3.0.dist-info → dataframe_textual-1.0.0.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-0.3.0.dist-info → dataframe_textual-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
"""DataFrameTable widget for displaying and interacting with Polars DataFrames."""
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
-
from collections import deque
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from textwrap import dedent
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
import polars as pl
|
|
10
11
|
from rich.text import Text
|
|
11
12
|
from textual.coordinate import Coordinate
|
|
12
|
-
from textual.
|
|
13
|
+
from textual.events import Click
|
|
14
|
+
from textual.widgets import DataTable, TabPane
|
|
13
15
|
from textual.widgets._data_table import (
|
|
14
16
|
CellDoesNotExist,
|
|
15
17
|
CellKey,
|
|
@@ -19,22 +21,28 @@ from textual.widgets._data_table import (
|
|
|
19
21
|
)
|
|
20
22
|
|
|
21
23
|
from .common import (
|
|
22
|
-
BATCH_SIZE,
|
|
23
|
-
BOOLS,
|
|
24
24
|
CURSOR_TYPES,
|
|
25
|
-
|
|
25
|
+
NULL,
|
|
26
|
+
NULL_DISPLAY,
|
|
27
|
+
RIDX,
|
|
26
28
|
SUBSCRIPT_DIGITS,
|
|
27
29
|
DtypeConfig,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
format_row,
|
|
31
|
+
get_next_item,
|
|
32
|
+
rindex,
|
|
33
|
+
tentative_expr,
|
|
34
|
+
validate_expr,
|
|
31
35
|
)
|
|
32
|
-
from .table_screen import FrequencyScreen, RowDetailScreen
|
|
36
|
+
from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
|
|
33
37
|
from .yes_no_screen import (
|
|
38
|
+
AddColumnScreen,
|
|
34
39
|
ConfirmScreen,
|
|
35
40
|
EditCellScreen,
|
|
41
|
+
EditColumnScreen,
|
|
36
42
|
FilterScreen,
|
|
43
|
+
FindReplaceScreen,
|
|
37
44
|
FreezeScreen,
|
|
45
|
+
RenameColumnScreen,
|
|
38
46
|
SaveFileScreen,
|
|
39
47
|
SearchScreen,
|
|
40
48
|
)
|
|
@@ -49,11 +57,33 @@ class History:
|
|
|
49
57
|
filename: str
|
|
50
58
|
loaded_rows: int
|
|
51
59
|
sorted_columns: dict[str, bool]
|
|
60
|
+
hidden_columns: set[str]
|
|
52
61
|
selected_rows: list[bool]
|
|
53
62
|
visible_rows: list[bool]
|
|
54
63
|
fixed_rows: int
|
|
55
64
|
fixed_columns: int
|
|
56
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
|
|
57
87
|
|
|
58
88
|
|
|
59
89
|
class DataFrameTable(DataTable):
|
|
@@ -72,77 +102,186 @@ class DataFrameTable(DataTable):
|
|
|
72
102
|
## 👁️ View & Display
|
|
73
103
|
- **Enter** - 📋 Show row details in modal
|
|
74
104
|
- **F** - 📊 Show frequency distribution
|
|
75
|
-
- **
|
|
76
|
-
-
|
|
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
|
|
77
109
|
|
|
78
110
|
## ↕️ Sorting
|
|
79
111
|
- **[** - 🔼 Sort column ascending
|
|
80
112
|
- **]** - 🔽 Sort column descending
|
|
81
113
|
- *(Multi-column sort supported)*
|
|
82
114
|
|
|
83
|
-
## 🔍 Search
|
|
84
|
-
- **|** - 🔎 Search in current column
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
- **
|
|
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
|
|
90
135
|
- **t** - 💡 Toggle row selection (invert all)
|
|
91
|
-
- **
|
|
136
|
+
- **{** - ⬆️ Go to previous selected row
|
|
137
|
+
- **}** - ⬇️ Go to next selected row
|
|
138
|
+
- **"** - 📍 Filter to show only selected rows
|
|
92
139
|
- **T** - 🧹 Clear all selections
|
|
93
|
-
- **v** - 🎯 Filter by selected rows or current cell value
|
|
94
|
-
- **V** - 🔧 Filter by Polars expression
|
|
95
140
|
|
|
96
141
|
## ✏️ Edit & Modify
|
|
142
|
+
- **Double-click** - ✍️ Edit cell or rename column header
|
|
97
143
|
- **e** - ✍️ Edit current cell
|
|
98
|
-
- **
|
|
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
|
|
99
150
|
- **-** - ❌ Delete current column
|
|
151
|
+
- **d** - 📋 Duplicate current column
|
|
152
|
+
- **h** - 👁️ Hide current column
|
|
153
|
+
- **H** - 👀 Show all hidden columns
|
|
100
154
|
|
|
101
155
|
## 🎯 Reorder
|
|
102
156
|
- **Shift+↑↓** - ⬆️⬇️ Move row up/down
|
|
103
157
|
- **Shift+←→** - ⬅️➡️ Move column left/right
|
|
104
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
|
+
|
|
105
168
|
## 💾 Data Management
|
|
106
|
-
- **
|
|
169
|
+
- **z** - 📌 Freeze rows and columns
|
|
170
|
+
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
107
171
|
- **c** - 📋 Copy cell to clipboard
|
|
108
|
-
- **Ctrl+
|
|
172
|
+
- **Ctrl+c** - 📊 Copy column to clipboard
|
|
173
|
+
- **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
|
|
174
|
+
- **Ctrl+s** - 💾 Save current tab to file
|
|
109
175
|
- **u** - ↩️ Undo last action
|
|
110
176
|
- **U** - 🔄 Reset to original data
|
|
111
|
-
|
|
112
|
-
*Use `?` to see app-level controls*
|
|
113
177
|
""").strip()
|
|
114
178
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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:
|
|
122
253
|
"""Initialize the DataFrameTable with a dataframe and manage all state.
|
|
123
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
|
+
|
|
124
258
|
Args:
|
|
125
|
-
df: The Polars DataFrame to display
|
|
126
|
-
filename: Optional filename
|
|
127
|
-
|
|
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
|
|
128
266
|
"""
|
|
129
|
-
super().__init__(**kwargs)
|
|
267
|
+
super().__init__(name=(name or Path(filename).stem), **kwargs)
|
|
130
268
|
|
|
131
269
|
# DataFrame state
|
|
132
|
-
self.
|
|
133
|
-
self.df =
|
|
270
|
+
self.lazyframe = df.lazy() # Original dataframe
|
|
271
|
+
self.df = self.lazyframe.collect() # Internal/working dataframe
|
|
134
272
|
self.filename = filename # Current filename
|
|
135
|
-
self.tabname = tabname or Path(filename).stem # Current tab name
|
|
136
273
|
|
|
137
274
|
# Pagination & Loading
|
|
275
|
+
self.INITIAL_BATCH_SIZE = (self.app.size.height // 100 + 1) * 100
|
|
276
|
+
self.BATCH_SIZE = self.INITIAL_BATCH_SIZE // 2
|
|
138
277
|
self.loaded_rows = 0 # Track how many rows are currently loaded
|
|
139
278
|
|
|
140
279
|
# State tracking (all 0-based indexing)
|
|
141
280
|
self.sorted_columns: dict[str, bool] = {} # col_name -> descending
|
|
142
|
-
self.
|
|
143
|
-
self.
|
|
144
|
-
|
|
145
|
-
) # Track
|
|
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
|
|
146
285
|
|
|
147
286
|
# Freezing
|
|
148
287
|
self.fixed_rows = 0 # Number of fixed rows
|
|
@@ -154,36 +293,117 @@ class DataFrameTable(DataTable):
|
|
|
154
293
|
# Pending filename for save operations
|
|
155
294
|
self._pending_filename = ""
|
|
156
295
|
|
|
296
|
+
# Whether to use thousand separator for numeric display
|
|
297
|
+
self.thousand_separator = False
|
|
298
|
+
|
|
157
299
|
@property
|
|
158
300
|
def cursor_key(self) -> CellKey:
|
|
159
|
-
"""Get the current cursor position as a CellKey.
|
|
301
|
+
"""Get the current cursor position as a CellKey.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
CellKey: A CellKey object representing the current cursor position.
|
|
305
|
+
"""
|
|
160
306
|
return self.coordinate_to_cell_key(self.cursor_coordinate)
|
|
161
307
|
|
|
162
308
|
@property
|
|
163
309
|
def cursor_row_key(self) -> RowKey:
|
|
164
|
-
"""Get the current cursor row as a
|
|
310
|
+
"""Get the current cursor row as a RowKey.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
RowKey: The row key for the row containing the cursor.
|
|
314
|
+
"""
|
|
165
315
|
return self.cursor_key.row_key
|
|
166
316
|
|
|
167
317
|
@property
|
|
168
|
-
def
|
|
169
|
-
"""Get the current cursor column as a ColumnKey.
|
|
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
|
+
"""
|
|
170
324
|
return self.cursor_key.column_key
|
|
171
325
|
|
|
172
326
|
@property
|
|
173
|
-
def
|
|
174
|
-
"""Get the current cursor row index (0-based).
|
|
175
|
-
|
|
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
|
|
176
393
|
|
|
177
394
|
def on_mount(self) -> None:
|
|
178
|
-
"""Initialize table display when widget is mounted.
|
|
179
|
-
|
|
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.
|
|
180
399
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
187
407
|
"""Determine if the given cell should be highlighted because of the cursor.
|
|
188
408
|
|
|
189
409
|
In "cell" mode, also highlights the row and column headers. In "row" and "column"
|
|
@@ -192,10 +412,10 @@ class DataFrameTable(DataTable):
|
|
|
192
412
|
Args:
|
|
193
413
|
cursor: The current position of the cursor.
|
|
194
414
|
target_cell: The cell we're checking for the need to highlight.
|
|
195
|
-
type_of_cursor: The type of cursor that is currently active.
|
|
415
|
+
type_of_cursor: The type of cursor that is currently active ("cell", "row", or "column").
|
|
196
416
|
|
|
197
417
|
Returns:
|
|
198
|
-
|
|
418
|
+
bool: True if the target cell should be highlighted, False otherwise.
|
|
199
419
|
"""
|
|
200
420
|
if type_of_cursor == "cell":
|
|
201
421
|
# Return true if the cursor is over the target cell
|
|
@@ -216,15 +436,19 @@ class DataFrameTable(DataTable):
|
|
|
216
436
|
else:
|
|
217
437
|
return False
|
|
218
438
|
|
|
219
|
-
def watch_cursor_coordinate(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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).
|
|
223
445
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
446
|
+
Args:
|
|
447
|
+
old_coordinate: The previous cursor coordinate.
|
|
448
|
+
new_coordinate: The new cursor coordinate.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
None
|
|
228
452
|
"""
|
|
229
453
|
if old_coordinate != new_coordinate:
|
|
230
454
|
# Emit CellSelected message for cell cursor type (keyboard navigation only)
|
|
@@ -263,93 +487,334 @@ class DataFrameTable(DataTable):
|
|
|
263
487
|
self._scroll_cursor_into_view()
|
|
264
488
|
|
|
265
489
|
def on_key(self, event) -> None:
|
|
266
|
-
"""Handle
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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"):
|
|
275
502
|
# Let the table handle the navigation first
|
|
276
503
|
self._check_and_load_more()
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
#
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
+
)
|
|
353
818
|
|
|
354
819
|
def on_mouse_scroll_down(self, event) -> None:
|
|
355
820
|
"""Load more rows when scrolling down with mouse."""
|
|
@@ -357,16 +822,22 @@ class DataFrameTable(DataTable):
|
|
|
357
822
|
|
|
358
823
|
# Setup & Loading
|
|
359
824
|
def _setup_table(self, reset: bool = False) -> None:
|
|
360
|
-
"""Setup the table for display.
|
|
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
|
+
"""
|
|
361
830
|
# Reset to original dataframe
|
|
362
831
|
if reset:
|
|
363
|
-
self.df = self.
|
|
832
|
+
self.df = self.lazyframe.collect()
|
|
364
833
|
self.loaded_rows = 0
|
|
365
834
|
self.sorted_columns = {}
|
|
835
|
+
self.hidden_columns = set()
|
|
366
836
|
self.selected_rows = [False] * len(self.df)
|
|
367
837
|
self.visible_rows = [True] * len(self.df)
|
|
368
838
|
self.fixed_rows = 0
|
|
369
839
|
self.fixed_columns = 0
|
|
840
|
+
self.matches = defaultdict(set)
|
|
370
841
|
|
|
371
842
|
# Lazy load up to INITIAL_BATCH_SIZE visible rows
|
|
372
843
|
stop, visible_count = len(self.df), 0
|
|
@@ -374,61 +845,55 @@ class DataFrameTable(DataTable):
|
|
|
374
845
|
if not visible:
|
|
375
846
|
continue
|
|
376
847
|
visible_count += 1
|
|
377
|
-
if visible_count >= INITIAL_BATCH_SIZE:
|
|
848
|
+
if visible_count >= self.INITIAL_BATCH_SIZE:
|
|
378
849
|
stop = row_idx + 1
|
|
379
850
|
break
|
|
380
851
|
|
|
852
|
+
# Save current cursor position before clearing
|
|
853
|
+
row_idx, col_idx = self.cursor_coordinate
|
|
854
|
+
|
|
381
855
|
self._setup_columns()
|
|
382
856
|
self._load_rows(stop)
|
|
383
|
-
self.
|
|
857
|
+
self._do_highlight()
|
|
384
858
|
|
|
385
859
|
# Restore cursor position
|
|
386
|
-
row_idx, col_idx = self.cursor_coordinate
|
|
387
860
|
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
388
861
|
self.move_cursor(row=row_idx, column=col_idx)
|
|
389
862
|
|
|
390
863
|
def _setup_columns(self) -> None:
|
|
391
|
-
"""Clear table and setup columns.
|
|
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
|
+
"""
|
|
392
869
|
self.loaded_rows = 0
|
|
393
870
|
self.clear(columns=True)
|
|
394
871
|
self.show_row_labels = True
|
|
395
872
|
|
|
396
873
|
# Add columns with justified headers
|
|
397
874
|
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
875
|
+
if col in self.hidden_columns:
|
|
876
|
+
continue # Skip hidden columns
|
|
398
877
|
for idx, c in enumerate(self.sorted_columns, 1):
|
|
399
878
|
if c == col:
|
|
400
879
|
# Add sort indicator to column header
|
|
401
880
|
descending = self.sorted_columns[col]
|
|
402
881
|
sort_indicator = (
|
|
403
|
-
f" ▼{SUBSCRIPT_DIGITS.get(idx, '')}"
|
|
404
|
-
if descending
|
|
405
|
-
else f" ▲{SUBSCRIPT_DIGITS.get(idx, '')}"
|
|
406
|
-
)
|
|
407
|
-
header_text = col + sort_indicator
|
|
408
|
-
self.add_column(
|
|
409
|
-
Text(header_text, justify=DtypeConfig(dtype).justify), key=col
|
|
882
|
+
f" ▼{SUBSCRIPT_DIGITS.get(idx, '')}" if descending else f" ▲{SUBSCRIPT_DIGITS.get(idx, '')}"
|
|
410
883
|
)
|
|
411
|
-
|
|
884
|
+
cell_value = col + sort_indicator
|
|
412
885
|
break
|
|
413
886
|
else: # No break occurred, so column is not sorted
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def _check_and_load_more(self) -> None:
|
|
417
|
-
"""Check if we need to load more rows and load them."""
|
|
418
|
-
# If we've loaded everything, no need to check
|
|
419
|
-
if self.loaded_rows >= len(self.df):
|
|
420
|
-
return
|
|
421
|
-
|
|
422
|
-
visible_row_count = self.size.height - self.header_height
|
|
423
|
-
bottom_visible_row = self.scroll_y + visible_row_count
|
|
887
|
+
cell_value = col
|
|
424
888
|
|
|
425
|
-
|
|
426
|
-
if bottom_visible_row >= self.loaded_rows - 10:
|
|
427
|
-
self._load_rows(self.loaded_rows + BATCH_SIZE)
|
|
889
|
+
self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col)
|
|
428
890
|
|
|
429
891
|
def _load_rows(self, stop: int | None = None) -> None:
|
|
430
892
|
"""Load a batch of rows into the table.
|
|
431
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
|
+
|
|
432
897
|
Args:
|
|
433
898
|
stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
|
|
434
899
|
"""
|
|
@@ -445,53 +910,66 @@ class DataFrameTable(DataTable):
|
|
|
445
910
|
if not self.visible_rows[row_idx]:
|
|
446
911
|
continue # Skip hidden rows
|
|
447
912
|
vals, dtypes = [], []
|
|
448
|
-
for val, dtype in zip(row, self.df.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
|
|
449
916
|
vals.append(val)
|
|
450
917
|
dtypes.append(dtype)
|
|
451
|
-
formatted_row =
|
|
918
|
+
formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
|
|
452
919
|
# Always add labels so they can be shown/hidden via CSS
|
|
453
|
-
self.add_row(*formatted_row, key=str(row_idx
|
|
920
|
+
self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
|
|
454
921
|
|
|
455
922
|
# Update loaded rows count
|
|
456
923
|
self.loaded_rows = stop
|
|
457
924
|
|
|
458
|
-
self.
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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)
|
|
462
939
|
|
|
463
|
-
def
|
|
464
|
-
"""Update all rows, highlighting selected ones
|
|
940
|
+
def _do_highlight(self, clear: bool = False) -> None:
|
|
941
|
+
"""Update all rows, highlighting selected ones and restoring others to default.
|
|
465
942
|
|
|
466
943
|
Args:
|
|
467
944
|
clear: If True, clear all highlights.
|
|
468
945
|
"""
|
|
469
|
-
if True not in self.selected_rows:
|
|
470
|
-
return
|
|
471
|
-
|
|
472
946
|
if clear:
|
|
473
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)
|
|
474
953
|
|
|
475
|
-
# Ensure all highlighted rows are loaded
|
|
476
|
-
stop = _rindex(self.selected_rows, True) + 1
|
|
477
954
|
self._load_rows(stop)
|
|
955
|
+
self._highlight_table()
|
|
478
956
|
|
|
957
|
+
def _highlight_table(self) -> None:
|
|
958
|
+
"""Highlight selected rows/cells in red."""
|
|
479
959
|
# Update all rows based on selected state
|
|
480
960
|
for row in self.ordered_rows:
|
|
481
|
-
row_idx = int(row.key.value)
|
|
961
|
+
row_idx = int(row.key.value) # 0-based index
|
|
482
962
|
is_selected = self.selected_rows[row_idx]
|
|
963
|
+
match_cols = self.matches.get(row_idx, set())
|
|
483
964
|
|
|
484
965
|
# Update all cells in this row
|
|
485
966
|
for col_idx, col in enumerate(self.ordered_columns):
|
|
486
967
|
cell_text: Text = self.get_cell(row.key, col.key)
|
|
487
|
-
dtype = self.df.dtypes[col_idx]
|
|
488
968
|
|
|
489
969
|
# Get style config based on dtype
|
|
970
|
+
dtype = self.df.dtypes[col_idx]
|
|
490
971
|
dc = DtypeConfig(dtype)
|
|
491
|
-
|
|
492
|
-
# Use red for selected rows, default style for others
|
|
493
|
-
style = "red" if is_selected else dc.style
|
|
494
|
-
cell_text.style = style
|
|
972
|
+
cell_text.style = "red" if is_selected or col_idx in match_cols else dc.style
|
|
495
973
|
|
|
496
974
|
# Update the cell in the table
|
|
497
975
|
self.update_cell(row.key, col.key, cell_text)
|
|
@@ -509,18 +987,20 @@ class DataFrameTable(DataTable):
|
|
|
509
987
|
filename=self.filename,
|
|
510
988
|
loaded_rows=self.loaded_rows,
|
|
511
989
|
sorted_columns=self.sorted_columns.copy(),
|
|
990
|
+
hidden_columns=self.hidden_columns.copy(),
|
|
512
991
|
selected_rows=self.selected_rows.copy(),
|
|
513
992
|
visible_rows=self.visible_rows.copy(),
|
|
514
993
|
fixed_rows=self.fixed_rows,
|
|
515
994
|
fixed_columns=self.fixed_columns,
|
|
516
995
|
cursor_coordinate=self.cursor_coordinate,
|
|
996
|
+
matches={k: v.copy() for k, v in self.matches.items()},
|
|
517
997
|
)
|
|
518
998
|
self.histories.append(history)
|
|
519
999
|
|
|
520
1000
|
def _undo(self) -> None:
|
|
521
1001
|
"""Undo the last action."""
|
|
522
1002
|
if not self.histories:
|
|
523
|
-
self.
|
|
1003
|
+
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
524
1004
|
return
|
|
525
1005
|
|
|
526
1006
|
history = self.histories.pop()
|
|
@@ -530,44 +1010,54 @@ class DataFrameTable(DataTable):
|
|
|
530
1010
|
self.filename = history.filename
|
|
531
1011
|
self.loaded_rows = history.loaded_rows
|
|
532
1012
|
self.sorted_columns = history.sorted_columns.copy()
|
|
1013
|
+
self.hidden_columns = history.hidden_columns.copy()
|
|
533
1014
|
self.selected_rows = history.selected_rows.copy()
|
|
534
1015
|
self.visible_rows = history.visible_rows.copy()
|
|
535
1016
|
self.fixed_rows = history.fixed_rows
|
|
536
1017
|
self.fixed_columns = history.fixed_columns
|
|
537
1018
|
self.cursor_coordinate = history.cursor_coordinate
|
|
1019
|
+
self.matches = {k: v.copy() for k, v in history.matches.items()}
|
|
538
1020
|
|
|
539
1021
|
# Recreate the table for display
|
|
540
1022
|
self._setup_table()
|
|
541
1023
|
|
|
542
|
-
self.
|
|
1024
|
+
# self.notify(f"Reverted: {history.description}", title="Undo")
|
|
543
1025
|
|
|
544
1026
|
# View
|
|
545
1027
|
def _view_row_detail(self) -> None:
|
|
546
1028
|
"""Open a modal screen to view the selected row's details."""
|
|
547
|
-
|
|
548
|
-
if row_idx >= len(self.df):
|
|
549
|
-
return
|
|
1029
|
+
ridx = self.cursor_row_idx
|
|
550
1030
|
|
|
551
1031
|
# Push the modal screen
|
|
552
|
-
self.app.push_screen(RowDetailScreen(
|
|
1032
|
+
self.app.push_screen(RowDetailScreen(ridx, self))
|
|
553
1033
|
|
|
554
1034
|
def _show_frequency(self) -> None:
|
|
555
1035
|
"""Show frequency distribution for the current column."""
|
|
556
|
-
|
|
557
|
-
if col_idx >= len(self.df.columns):
|
|
558
|
-
return
|
|
1036
|
+
cidx = self.cursor_col_idx
|
|
559
1037
|
|
|
560
1038
|
# Push the frequency modal screen
|
|
561
|
-
self.app.push_screen(
|
|
562
|
-
|
|
563
|
-
|
|
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.
|
|
564
1043
|
|
|
565
|
-
|
|
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:
|
|
566
1056
|
"""Open the freeze screen to set fixed rows and columns."""
|
|
567
1057
|
self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
|
|
568
1058
|
|
|
569
1059
|
def _do_freeze(self, result: tuple[int, int] | None) -> None:
|
|
570
|
-
"""Handle result from
|
|
1060
|
+
"""Handle result from PinScreen.
|
|
571
1061
|
|
|
572
1062
|
Args:
|
|
573
1063
|
result: Tuple of (fixed_rows, fixed_columns) or None if cancelled.
|
|
@@ -578,9 +1068,7 @@ class DataFrameTable(DataTable):
|
|
|
578
1068
|
fixed_rows, fixed_columns = result
|
|
579
1069
|
|
|
580
1070
|
# Add to history
|
|
581
|
-
self._add_history(
|
|
582
|
-
f"Pinned [on $primary]{fixed_rows}[/] rows and [on $primary]{fixed_columns}[/] columns"
|
|
583
|
-
)
|
|
1071
|
+
self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
|
|
584
1072
|
|
|
585
1073
|
# Apply the pin settings to the table
|
|
586
1074
|
if fixed_rows > 0:
|
|
@@ -588,41 +1076,118 @@ class DataFrameTable(DataTable):
|
|
|
588
1076
|
if fixed_columns > 0:
|
|
589
1077
|
self.fixed_columns = fixed_columns
|
|
590
1078
|
|
|
591
|
-
self.
|
|
592
|
-
f"Pinned [
|
|
1079
|
+
self.notify(
|
|
1080
|
+
f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
|
|
593
1081
|
title="Pin",
|
|
594
1082
|
)
|
|
595
1083
|
|
|
596
1084
|
# Delete & Move
|
|
597
1085
|
def _delete_column(self) -> None:
|
|
598
1086
|
"""Remove the currently selected column from the table."""
|
|
1087
|
+
# Get the column to remove
|
|
599
1088
|
col_idx = self.cursor_column
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
# Get the column name to remove
|
|
604
|
-
col_to_remove = self.df.columns[col_idx]
|
|
1089
|
+
col_name = self.cursor_col_name
|
|
1090
|
+
col_key = self.cursor_col_key
|
|
605
1091
|
|
|
606
1092
|
# Add to history
|
|
607
|
-
self._add_history(f"Removed column [
|
|
1093
|
+
self._add_history(f"Removed column [$success]{col_name}[/]")
|
|
608
1094
|
|
|
609
1095
|
# Remove the column from the table display using the column name as key
|
|
610
|
-
self.remove_column(
|
|
1096
|
+
self.remove_column(col_key)
|
|
611
1097
|
|
|
612
1098
|
# Move cursor left if we deleted the last column
|
|
613
1099
|
if col_idx >= len(self.columns):
|
|
614
1100
|
self.move_cursor(column=len(self.columns) - 1)
|
|
615
1101
|
|
|
616
1102
|
# Remove from sorted columns if present
|
|
617
|
-
if
|
|
618
|
-
del self.sorted_columns[
|
|
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]
|
|
619
1112
|
|
|
620
1113
|
# Remove from dataframe
|
|
621
|
-
self.df = self.df.drop(
|
|
1114
|
+
self.df = self.df.drop(col_name)
|
|
622
1115
|
|
|
623
|
-
self.
|
|
624
|
-
|
|
625
|
-
|
|
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",
|
|
626
1191
|
)
|
|
627
1192
|
|
|
628
1193
|
def _delete_row(self) -> None:
|
|
@@ -631,48 +1196,83 @@ class DataFrameTable(DataTable):
|
|
|
631
1196
|
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
632
1197
|
"""
|
|
633
1198
|
old_count = len(self.df)
|
|
634
|
-
|
|
1199
|
+
predicates = [True] * len(self.df)
|
|
635
1200
|
|
|
636
1201
|
# Delete all selected rows
|
|
637
1202
|
if selected_count := self.selected_rows.count(True):
|
|
638
1203
|
history_desc = f"Deleted {selected_count} selected row(s)"
|
|
639
1204
|
|
|
640
|
-
for
|
|
641
|
-
if
|
|
642
|
-
|
|
1205
|
+
for ridx, selected in enumerate(self.selected_rows):
|
|
1206
|
+
if selected:
|
|
1207
|
+
predicates[ridx] = False
|
|
1208
|
+
|
|
643
1209
|
# Delete the row at the cursor
|
|
644
1210
|
else:
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
filter_expr[i] = False
|
|
649
|
-
history_desc = f"Deleted row [on $primary]{row_key.value}[/]"
|
|
1211
|
+
ridx = self.cursor_row_idx
|
|
1212
|
+
history_desc = f"Deleted row [$success]{ridx + 1}[/]"
|
|
1213
|
+
predicates[ridx] = False
|
|
650
1214
|
|
|
651
1215
|
# Add to history
|
|
652
1216
|
self._add_history(history_desc)
|
|
653
1217
|
|
|
654
1218
|
# Apply the filter to remove rows
|
|
655
|
-
|
|
656
|
-
|
|
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)
|
|
657
1227
|
|
|
658
1228
|
# Update selected and visible rows tracking
|
|
659
|
-
old_row_indices = set(df[
|
|
660
|
-
self.selected_rows = [
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
self.visible_rows = [
|
|
666
|
-
visible
|
|
667
|
-
for i, visible in enumerate(self.visible_rows)
|
|
668
|
-
if i in old_row_indices
|
|
669
|
-
]
|
|
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)
|
|
670
1235
|
|
|
671
1236
|
# Recreate the table display
|
|
672
1237
|
self._setup_table()
|
|
673
1238
|
|
|
674
1239
|
deleted_count = old_count - len(self.df)
|
|
675
|
-
|
|
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")
|
|
676
1276
|
|
|
677
1277
|
def _move_column(self, direction: str) -> None:
|
|
678
1278
|
"""Move the current column left or right.
|
|
@@ -681,36 +1281,32 @@ class DataFrameTable(DataTable):
|
|
|
681
1281
|
direction: "left" to move left, "right" to move right.
|
|
682
1282
|
"""
|
|
683
1283
|
row_idx, col_idx = self.cursor_coordinate
|
|
684
|
-
col_key = self.
|
|
1284
|
+
col_key = self.cursor_col_key
|
|
1285
|
+
col_name = col_key.value
|
|
1286
|
+
cidx = self.cursor_col_idx
|
|
685
1287
|
|
|
686
1288
|
# Validate move is possible
|
|
687
1289
|
if direction == "left":
|
|
688
1290
|
if col_idx <= 0:
|
|
689
|
-
self.
|
|
690
|
-
"Cannot move column left", title="Move", severity="warning"
|
|
691
|
-
)
|
|
1291
|
+
self.notify("Cannot move column left", title="Move", severity="warning")
|
|
692
1292
|
return
|
|
693
1293
|
swap_idx = col_idx - 1
|
|
694
1294
|
elif direction == "right":
|
|
695
1295
|
if col_idx >= len(self.columns) - 1:
|
|
696
|
-
self.
|
|
697
|
-
"Cannot move column right", title="Move", severity="warning"
|
|
698
|
-
)
|
|
1296
|
+
self.notify("Cannot move column right", title="Move", severity="warning")
|
|
699
1297
|
return
|
|
700
1298
|
swap_idx = col_idx + 1
|
|
701
1299
|
|
|
702
|
-
# Get column
|
|
703
|
-
|
|
704
|
-
swap_name =
|
|
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)
|
|
705
1304
|
|
|
706
1305
|
# Add to history
|
|
707
|
-
self._add_history(
|
|
708
|
-
f"Moved column [on $primary]{col_name}[/] {direction} (swapped with [on $primary]{swap_name}[/])"
|
|
709
|
-
)
|
|
1306
|
+
self._add_history(f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])")
|
|
710
1307
|
|
|
711
1308
|
# Swap columns in the table's internal column locations
|
|
712
1309
|
self.check_idle()
|
|
713
|
-
swap_key = self.df.columns[swap_idx] # str as column key
|
|
714
1310
|
|
|
715
1311
|
(
|
|
716
1312
|
self._column_locations[col_key],
|
|
@@ -728,13 +1324,10 @@ class DataFrameTable(DataTable):
|
|
|
728
1324
|
|
|
729
1325
|
# Update the dataframe column order
|
|
730
1326
|
cols = list(self.df.columns)
|
|
731
|
-
cols[
|
|
1327
|
+
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
732
1328
|
self.df = self.df.select(cols)
|
|
733
1329
|
|
|
734
|
-
self.
|
|
735
|
-
f"Moved column [on $primary]{col_name}[/] {direction}",
|
|
736
|
-
title="Move",
|
|
737
|
-
)
|
|
1330
|
+
# self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
|
|
738
1331
|
|
|
739
1332
|
def _move_row(self, direction: str) -> None:
|
|
740
1333
|
"""Move the current row up or down.
|
|
@@ -747,20 +1340,16 @@ class DataFrameTable(DataTable):
|
|
|
747
1340
|
# Validate move is possible
|
|
748
1341
|
if direction == "up":
|
|
749
1342
|
if row_idx <= 0:
|
|
750
|
-
self.
|
|
1343
|
+
self.notify("Cannot move row up", title="Move", severity="warning")
|
|
751
1344
|
return
|
|
752
1345
|
swap_idx = row_idx - 1
|
|
753
1346
|
elif direction == "down":
|
|
754
1347
|
if row_idx >= len(self.rows) - 1:
|
|
755
|
-
self.
|
|
756
|
-
"Cannot move row down", title="Move", severity="warning"
|
|
757
|
-
)
|
|
1348
|
+
self.notify("Cannot move row down", title="Move", severity="warning")
|
|
758
1349
|
return
|
|
759
1350
|
swap_idx = row_idx + 1
|
|
760
1351
|
else:
|
|
761
|
-
self.
|
|
762
|
-
f"Invalid direction: {direction}", title="Move", severity="error"
|
|
763
|
-
)
|
|
1352
|
+
self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
|
|
764
1353
|
return
|
|
765
1354
|
|
|
766
1355
|
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
@@ -768,7 +1357,7 @@ class DataFrameTable(DataTable):
|
|
|
768
1357
|
|
|
769
1358
|
# Add to history
|
|
770
1359
|
self._add_history(
|
|
771
|
-
f"Moved row [
|
|
1360
|
+
f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])"
|
|
772
1361
|
)
|
|
773
1362
|
|
|
774
1363
|
# Swap rows in the table's internal row locations
|
|
@@ -789,9 +1378,9 @@ class DataFrameTable(DataTable):
|
|
|
789
1378
|
self.move_cursor(row=swap_idx, column=col_idx)
|
|
790
1379
|
|
|
791
1380
|
# Swap rows in the dataframe
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
first, second = sorted([
|
|
1381
|
+
ridx = int(row_key.value) # 0-based
|
|
1382
|
+
swap_ridx = int(swap_key.value) # 0-based
|
|
1383
|
+
first, second = sorted([ridx, swap_ridx])
|
|
795
1384
|
|
|
796
1385
|
self.df = pl.concat(
|
|
797
1386
|
[
|
|
@@ -803,9 +1392,7 @@ class DataFrameTable(DataTable):
|
|
|
803
1392
|
]
|
|
804
1393
|
)
|
|
805
1394
|
|
|
806
|
-
self.
|
|
807
|
-
f"Moved row [on $primary]{row_key.value}[/] {direction}", title="Move"
|
|
808
|
-
)
|
|
1395
|
+
# self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
|
|
809
1396
|
|
|
810
1397
|
# Sort
|
|
811
1398
|
def _sort_by_column(self, descending: bool = False) -> None:
|
|
@@ -818,47 +1405,40 @@ class DataFrameTable(DataTable):
|
|
|
818
1405
|
Args:
|
|
819
1406
|
descending: If True, sort in descending order. If False, ascending order.
|
|
820
1407
|
"""
|
|
1408
|
+
col_name = self.cursor_col_name
|
|
821
1409
|
col_idx = self.cursor_column
|
|
822
|
-
if col_idx >= len(self.df.columns):
|
|
823
|
-
return
|
|
824
|
-
|
|
825
|
-
col_to_sort = self.df.columns[col_idx]
|
|
826
1410
|
|
|
827
1411
|
# Check if this column is already in the sort keys
|
|
828
|
-
old_desc = self.sorted_columns.get(
|
|
829
|
-
if old_desc == descending:
|
|
830
|
-
# Same direction - remove this column from sort
|
|
831
|
-
self.app.notify(
|
|
832
|
-
f"Already sorted by [on $primary]{col_to_sort}[/] ({'desc' if descending else 'asc'})",
|
|
833
|
-
title="Sort",
|
|
834
|
-
severity="warning",
|
|
835
|
-
)
|
|
836
|
-
return
|
|
1412
|
+
old_desc = self.sorted_columns.get(col_name)
|
|
837
1413
|
|
|
838
1414
|
# Add to history
|
|
839
|
-
self._add_history(f"Sorted on column [
|
|
1415
|
+
self._add_history(f"Sorted on column [$success]{col_name}[/]")
|
|
840
1416
|
if old_desc is None:
|
|
841
1417
|
# Add new column to sort
|
|
842
|
-
self.sorted_columns[
|
|
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]
|
|
843
1422
|
else:
|
|
844
|
-
#
|
|
845
|
-
del self.sorted_columns[
|
|
846
|
-
self.sorted_columns[
|
|
1423
|
+
# Move to end of sort order
|
|
1424
|
+
del self.sorted_columns[col_name]
|
|
1425
|
+
self.sorted_columns[col_name] = descending
|
|
847
1426
|
|
|
848
1427
|
# Apply multi-column sort
|
|
849
|
-
sort_cols
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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)
|
|
854
1434
|
|
|
855
1435
|
# Updated selected_rows and visible_rows to match new order
|
|
856
|
-
old_row_indices = df_sorted[
|
|
1436
|
+
old_row_indices = df_sorted[RIDX].to_list()
|
|
857
1437
|
self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
|
|
858
1438
|
self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
|
|
859
1439
|
|
|
860
1440
|
# Update the dataframe
|
|
861
|
-
self.df = df_sorted.drop(
|
|
1441
|
+
self.df = df_sorted.drop(RIDX)
|
|
862
1442
|
|
|
863
1443
|
# Recreate the table for display
|
|
864
1444
|
self._setup_table()
|
|
@@ -867,22 +1447,18 @@ class DataFrameTable(DataTable):
|
|
|
867
1447
|
self.move_cursor(column=col_idx, row=0)
|
|
868
1448
|
|
|
869
1449
|
# Edit
|
|
870
|
-
def _edit_cell(self) -> None:
|
|
1450
|
+
def _edit_cell(self, ridx: int = None, cidx: int = None) -> None:
|
|
871
1451
|
"""Open modal to edit the selected cell."""
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if row_idx >= len(self.df) or col_idx >= len(self.df.columns):
|
|
877
|
-
return
|
|
878
|
-
col_name = self.df.columns[col_idx]
|
|
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]
|
|
879
1455
|
|
|
880
1456
|
# Save current state to history
|
|
881
|
-
self._add_history(f"Edited cell [
|
|
1457
|
+
self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
|
|
882
1458
|
|
|
883
1459
|
# Push the edit modal screen
|
|
884
1460
|
self.app.push_screen(
|
|
885
|
-
EditCellScreen(
|
|
1461
|
+
EditCellScreen(ridx, cidx, self.df),
|
|
886
1462
|
callback=self._do_edit_cell,
|
|
887
1463
|
)
|
|
888
1464
|
|
|
@@ -891,292 +1467,1046 @@ class DataFrameTable(DataTable):
|
|
|
891
1467
|
if result is None:
|
|
892
1468
|
return
|
|
893
1469
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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]
|
|
897
1479
|
|
|
898
1480
|
# Update the cell in the dataframe
|
|
899
1481
|
try:
|
|
900
1482
|
self.df = self.df.with_columns(
|
|
901
|
-
pl.when(pl.arange(0, len(self.df)) ==
|
|
1483
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
902
1484
|
.then(pl.lit(new_value))
|
|
903
1485
|
.otherwise(pl.col(col_name))
|
|
904
1486
|
.alias(col_name)
|
|
905
1487
|
)
|
|
906
1488
|
|
|
907
1489
|
# Update the display
|
|
908
|
-
cell_value = self.df.item(
|
|
1490
|
+
cell_value = self.df.item(ridx, cidx)
|
|
909
1491
|
if cell_value is None:
|
|
910
|
-
cell_value =
|
|
911
|
-
dtype = self.df.dtypes[
|
|
1492
|
+
cell_value = NULL_DISPLAY
|
|
1493
|
+
dtype = self.df.dtypes[cidx]
|
|
912
1494
|
dc = DtypeConfig(dtype)
|
|
913
1495
|
formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
|
|
914
1496
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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)
|
|
918
1501
|
|
|
919
|
-
self.
|
|
920
|
-
f"Cell updated to [on $primary]{cell_value}[/]", title="Edit"
|
|
921
|
-
)
|
|
1502
|
+
self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
922
1503
|
except Exception as e:
|
|
923
|
-
self.
|
|
924
|
-
f"Failed to update cell: {str(e)}", title="Edit", severity="error"
|
|
925
|
-
)
|
|
926
|
-
raise e
|
|
1504
|
+
self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
|
|
927
1505
|
|
|
928
|
-
def
|
|
929
|
-
"""
|
|
930
|
-
|
|
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()
|
|
931
1562
|
|
|
932
|
-
|
|
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
|
|
933
1571
|
col_idx = self.cursor_column
|
|
934
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
|
|
935
1629
|
try:
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
"clipboard",
|
|
942
|
-
],
|
|
943
|
-
input=cell_str,
|
|
944
|
-
text=True,
|
|
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)
|
|
945
1635
|
)
|
|
946
|
-
self.app.notify(f"Copied: {cell_str[:50]}", title="Clipboard")
|
|
947
|
-
except (FileNotFoundError, IndexError):
|
|
948
|
-
self.app.notify("Error copying cell", title="Clipboard", severity="error")
|
|
949
1636
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
self.app.notify("Invalid column selected", title="Search", severity="error")
|
|
955
|
-
return
|
|
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)
|
|
956
1641
|
|
|
957
|
-
|
|
958
|
-
col_dtype = self.df.dtypes[col_idx]
|
|
1642
|
+
self.update_cell(row_key, col_key, formatted_value)
|
|
959
1643
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
|
963
1648
|
|
|
964
|
-
|
|
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
|
|
965
1697
|
self.app.push_screen(
|
|
966
|
-
|
|
967
|
-
|
|
1698
|
+
AddColumnScreen(cidx, self.df),
|
|
1699
|
+
self._do_add_column_expr,
|
|
968
1700
|
)
|
|
969
1701
|
|
|
970
|
-
def
|
|
971
|
-
"""
|
|
1702
|
+
def _do_add_column_expr(self, result: tuple[int, str, str, pl.Expr] | None) -> None:
|
|
1703
|
+
"""Add a new column with an expression."""
|
|
972
1704
|
if result is None:
|
|
973
1705
|
return
|
|
974
1706
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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)
|
|
982
1715
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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.
|
|
987
1738
|
|
|
988
1739
|
Args:
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
|
992
1747
|
"""
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1748
|
+
dtype_map = {
|
|
1749
|
+
"string": pl.String,
|
|
1750
|
+
"int": pl.Int64,
|
|
1751
|
+
"float": pl.Float64,
|
|
1752
|
+
"bool": pl.Boolean,
|
|
1753
|
+
}
|
|
996
1754
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
|
1008
1778
|
else:
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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",
|
|
1013
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:
|
|
1014
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)
|
|
1015
1874
|
|
|
1016
1875
|
# Apply filter to get matched row indices
|
|
1017
|
-
|
|
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
|
|
1018
1885
|
|
|
1019
1886
|
match_count = len(matches)
|
|
1020
1887
|
if match_count == 0:
|
|
1021
|
-
self.
|
|
1022
|
-
f"No matches found for
|
|
1888
|
+
self.notify(
|
|
1889
|
+
f"No matches found for [$warning]{term}[/]. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
1023
1890
|
title="Search",
|
|
1024
1891
|
severity="warning",
|
|
1025
1892
|
)
|
|
1026
1893
|
return
|
|
1027
1894
|
|
|
1028
1895
|
# Add to history
|
|
1029
|
-
self._add_history(
|
|
1030
|
-
f"Searched and highlighted [on $primary]{term}[/] in column [on $primary]{col_name}[/]"
|
|
1031
|
-
)
|
|
1896
|
+
self._add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
1032
1897
|
|
|
1033
1898
|
# Update selected rows to include new matches
|
|
1034
1899
|
for m in matches:
|
|
1035
1900
|
self.selected_rows[m] = True
|
|
1036
1901
|
|
|
1037
|
-
# Highlight
|
|
1038
|
-
self.
|
|
1902
|
+
# Highlight matches
|
|
1903
|
+
self._do_highlight()
|
|
1039
1904
|
|
|
1040
|
-
self.
|
|
1041
|
-
f"Found [on $primary]{match_count}[/] matches for [on $primary]{term}[/]",
|
|
1042
|
-
title="Search",
|
|
1043
|
-
)
|
|
1905
|
+
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
|
|
1044
1906
|
|
|
1045
|
-
def
|
|
1046
|
-
|
|
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.
|
|
1047
1911
|
|
|
1048
1912
|
Args:
|
|
1049
|
-
term: The search term
|
|
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.
|
|
1050
1923
|
"""
|
|
1051
|
-
|
|
1924
|
+
matches: dict[int, set[int]] = defaultdict(set)
|
|
1925
|
+
|
|
1926
|
+
# Lazyframe for filtering
|
|
1927
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
1052
1928
|
if False in self.visible_rows:
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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))
|
|
1067
1977
|
else:
|
|
1068
|
-
|
|
1069
|
-
for col_idx, col in enumerate(df_rid.columns[1:]):
|
|
1070
|
-
col_series = df_rid[col].cast(pl.String)
|
|
1071
|
-
masks = col_series.str.contains(term)
|
|
1072
|
-
matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
|
|
1073
|
-
for rid in matched_rids:
|
|
1074
|
-
if rid not in matches:
|
|
1075
|
-
matches[rid] = set()
|
|
1076
|
-
matches[rid].add(col_idx)
|
|
1077
|
-
match_count += 1
|
|
1978
|
+
self._do_find_global((term, None, False, False))
|
|
1078
1979
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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",
|
|
1083
2055
|
severity="warning",
|
|
1084
2056
|
)
|
|
1085
2057
|
return
|
|
1086
2058
|
|
|
1087
|
-
#
|
|
1088
|
-
self.
|
|
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
|
|
1089
2231
|
|
|
1090
2232
|
# Add to history
|
|
1091
2233
|
self._add_history(
|
|
1092
|
-
f"
|
|
2234
|
+
f"Replacing [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
|
|
1093
2235
|
)
|
|
1094
2236
|
|
|
1095
|
-
#
|
|
1096
|
-
for
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
+
)
|
|
1100
2260
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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)
|
|
1104
2268
|
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
+
)
|
|
1107
2275
|
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
+
)
|
|
1110
2324
|
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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",
|
|
1114
2334
|
)
|
|
1115
2335
|
|
|
1116
|
-
def
|
|
1117
|
-
"""
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
+
)
|
|
1121
2347
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
|
1125
2412
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
2413
|
+
# Skip
|
|
2414
|
+
elif result is False:
|
|
2415
|
+
state.skipped_occurrence += 1
|
|
1129
2416
|
|
|
1130
|
-
|
|
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:
|
|
1131
2440
|
"""Toggle selected rows highlighting on/off."""
|
|
1132
2441
|
# Save current state to history
|
|
1133
2442
|
self._add_history("Toggled row selection")
|
|
1134
2443
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
|
1139
2451
|
else:
|
|
1140
2452
|
# Invert all selected rows
|
|
1141
|
-
self.selected_rows = [not
|
|
2453
|
+
self.selected_rows = [not selected for selected in self.selected_rows]
|
|
1142
2454
|
|
|
1143
2455
|
# Check if we're highlighting or un-highlighting
|
|
1144
2456
|
if new_selected_count := self.selected_rows.count(True):
|
|
1145
|
-
self.
|
|
1146
|
-
f"Toggled selection
|
|
2457
|
+
self.notify(
|
|
2458
|
+
f"Toggled selection for [$accent]{new_selected_count}[/] rows",
|
|
1147
2459
|
title="Toggle",
|
|
1148
2460
|
)
|
|
1149
2461
|
|
|
1150
2462
|
# Refresh the highlighting (also restores default styles for unselected rows)
|
|
1151
|
-
self.
|
|
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()
|
|
1152
2485
|
|
|
1153
|
-
def
|
|
2486
|
+
def _clear_selections(self) -> None:
|
|
1154
2487
|
"""Clear all selected rows without removing them from the dataframe."""
|
|
1155
|
-
# Check if any rows
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
self.app.notify(
|
|
1159
|
-
"No rows selected to clear", title="Clear", severity="warning"
|
|
1160
|
-
)
|
|
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")
|
|
1161
2491
|
return
|
|
1162
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
|
+
|
|
1163
2497
|
# Save current state to history
|
|
1164
2498
|
self._add_history("Cleared all selected rows")
|
|
1165
2499
|
|
|
1166
2500
|
# Clear all selections and refresh highlighting
|
|
1167
|
-
self.
|
|
2501
|
+
self._do_highlight(clear=True)
|
|
1168
2502
|
|
|
1169
|
-
self.
|
|
1170
|
-
f"Cleared [on $primary]{selected_count}[/] selected rows", title="Clear"
|
|
1171
|
-
)
|
|
2503
|
+
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
1172
2504
|
|
|
1173
2505
|
def _filter_selected_rows(self) -> None:
|
|
1174
|
-
"""
|
|
2506
|
+
"""Keep only the selected rows and remove unselected ones."""
|
|
1175
2507
|
selected_count = self.selected_rows.count(True)
|
|
1176
2508
|
if selected_count == 0:
|
|
1177
|
-
self.
|
|
1178
|
-
"No rows selected to filter", title="Filter", severity="warning"
|
|
1179
|
-
)
|
|
2509
|
+
self.notify("No rows selected to filter", title="Filter", severity="warning")
|
|
1180
2510
|
return
|
|
1181
2511
|
|
|
1182
2512
|
# Save current state to history
|
|
@@ -1189,126 +2519,166 @@ class DataFrameTable(DataTable):
|
|
|
1189
2519
|
# Recreate the table for display
|
|
1190
2520
|
self._setup_table()
|
|
1191
2521
|
|
|
1192
|
-
self.
|
|
1193
|
-
f"Removed unselected rows. Now showing [
|
|
2522
|
+
self.notify(
|
|
2523
|
+
f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
|
|
1194
2524
|
title="Filter",
|
|
1195
2525
|
)
|
|
1196
2526
|
|
|
1197
|
-
def
|
|
1198
|
-
"""
|
|
1199
|
-
row_key = self.cursor_row_key
|
|
1200
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based index
|
|
1201
|
-
col_idx = self.cursor_column
|
|
2527
|
+
def _view_rows(self) -> None:
|
|
2528
|
+
"""View rows.
|
|
1202
2529
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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))
|
|
1206
2553
|
|
|
1207
2554
|
self.app.push_screen(
|
|
1208
|
-
FilterScreen(
|
|
1209
|
-
|
|
1210
|
-
),
|
|
1211
|
-
callback=self._do_filter,
|
|
2555
|
+
FilterScreen(self.df, cidx, cursor_value),
|
|
2556
|
+
callback=self._do_view_rows,
|
|
1212
2557
|
)
|
|
1213
2558
|
|
|
1214
|
-
def
|
|
1215
|
-
"""
|
|
1216
|
-
|
|
1217
|
-
Args:
|
|
1218
|
-
expression: The filter expression or None if cancelled.
|
|
1219
|
-
"""
|
|
2559
|
+
def _do_view_rows(self, result) -> None:
|
|
2560
|
+
"""Show only those matching rows and hide others. Do not modify the dataframe."""
|
|
1220
2561
|
if result is None:
|
|
1221
2562
|
return
|
|
1222
|
-
|
|
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
|
+
)
|
|
1223
2604
|
|
|
1224
|
-
#
|
|
1225
|
-
|
|
2605
|
+
# Lazyframe with row indices
|
|
2606
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
1226
2607
|
|
|
1227
2608
|
# Apply existing visibility filter first
|
|
1228
2609
|
if False in self.visible_rows:
|
|
1229
|
-
|
|
2610
|
+
lf = lf.filter(self.visible_rows)
|
|
1230
2611
|
|
|
1231
2612
|
# Apply the filter expression
|
|
1232
|
-
|
|
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
|
|
1233
2619
|
|
|
1234
2620
|
matched_count = len(df_filtered)
|
|
1235
2621
|
if not matched_count:
|
|
1236
|
-
self.
|
|
1237
|
-
f"No rows match the expression: [
|
|
2622
|
+
self.notify(
|
|
2623
|
+
f"No rows match the expression: [$success]{expr}[/]",
|
|
1238
2624
|
title="Filter",
|
|
1239
2625
|
severity="warning",
|
|
1240
2626
|
)
|
|
1241
2627
|
return
|
|
1242
2628
|
|
|
1243
2629
|
# Add to history
|
|
1244
|
-
self._add_history(f"Filtered by expression [
|
|
2630
|
+
self._add_history(f"Filtered by expression [$success]{expr}[/]")
|
|
1245
2631
|
|
|
1246
|
-
# Mark unfiltered rows as invisible
|
|
1247
|
-
filtered_row_indices = set(df_filtered[
|
|
2632
|
+
# Mark unfiltered rows as invisible
|
|
2633
|
+
filtered_row_indices = set(df_filtered[RIDX].to_list())
|
|
1248
2634
|
if filtered_row_indices:
|
|
1249
|
-
for
|
|
1250
|
-
if
|
|
1251
|
-
self.visible_rows[
|
|
1252
|
-
self.selected_rows[rid] = False
|
|
2635
|
+
for ridx in range(len(self.visible_rows)):
|
|
2636
|
+
if ridx not in filtered_row_indices:
|
|
2637
|
+
self.visible_rows[ridx] = False
|
|
1253
2638
|
|
|
1254
2639
|
# Recreate the table for display
|
|
1255
2640
|
self._setup_table()
|
|
1256
2641
|
|
|
1257
|
-
self.
|
|
1258
|
-
f"Filtered to [
|
|
2642
|
+
self.notify(
|
|
2643
|
+
f"Filtered to [$accent]{matched_count}[/] matching rows",
|
|
1259
2644
|
title="Filter",
|
|
1260
2645
|
)
|
|
1261
2646
|
|
|
1262
|
-
def _filter_rows(self) -> None:
|
|
1263
|
-
"""Filter rows.
|
|
1264
|
-
|
|
1265
|
-
If there are selected rows, filter to those rows.
|
|
1266
|
-
Otherwise, filter based on the value of the currently selected cell.
|
|
1267
|
-
"""
|
|
1268
|
-
|
|
1269
|
-
if True in self.selected_rows:
|
|
1270
|
-
expr = self.selected_rows
|
|
1271
|
-
expr_str = "selected rows"
|
|
1272
|
-
else:
|
|
1273
|
-
row_key = self.cursor_row_key
|
|
1274
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based index
|
|
1275
|
-
col_idx = self.cursor_column
|
|
1276
|
-
|
|
1277
|
-
cell_value = self.df.item(row_idx, col_idx)
|
|
1278
|
-
|
|
1279
|
-
if cell_value is None:
|
|
1280
|
-
expr = pl.col(self.df.columns[col_idx]).is_null()
|
|
1281
|
-
expr_str = "NULL"
|
|
1282
|
-
else:
|
|
1283
|
-
expr = pl.col(self.df.columns[col_idx]) == cell_value
|
|
1284
|
-
expr_str = f"$_ == {repr(cell_value)}"
|
|
1285
|
-
|
|
1286
|
-
self._do_filter((expr, expr_str))
|
|
1287
|
-
|
|
1288
2647
|
def _cycle_cursor_type(self) -> None:
|
|
1289
2648
|
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
1290
|
-
next_type =
|
|
2649
|
+
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
1291
2650
|
self.cursor_type = next_type
|
|
1292
2651
|
|
|
1293
|
-
self.
|
|
1294
|
-
f"Changed cursor type to [on $primary]{next_type}[/]", title="Cursor"
|
|
1295
|
-
)
|
|
2652
|
+
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
1296
2653
|
|
|
1297
|
-
def
|
|
1298
|
-
"""
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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")
|
|
1302
2676
|
|
|
1303
2677
|
def _save_to_file(self) -> None:
|
|
1304
|
-
"""Open save file
|
|
1305
|
-
self.app.push_screen(
|
|
1306
|
-
SaveFileScreen(self.filename), callback=self._on_save_file_screen
|
|
1307
|
-
)
|
|
2678
|
+
"""Open screen to save file."""
|
|
2679
|
+
self.app.push_screen(SaveFileScreen(self.filename), callback=self._do_save_file)
|
|
1308
2680
|
|
|
1309
|
-
def
|
|
1310
|
-
self, filename: str | None, all_tabs: bool = False
|
|
1311
|
-
) -> None:
|
|
2681
|
+
def _do_save_file(self, filename: str | None, all_tabs: bool = False) -> None:
|
|
1312
2682
|
"""Handle result from SaveFileScreen."""
|
|
1313
2683
|
if filename is None:
|
|
1314
2684
|
return
|
|
@@ -1338,7 +2708,7 @@ class DataFrameTable(DataTable):
|
|
|
1338
2708
|
# Go back to SaveFileScreen to allow user to enter a different name
|
|
1339
2709
|
self.app.push_screen(
|
|
1340
2710
|
SaveFileScreen(self._pending_filename),
|
|
1341
|
-
callback=self.
|
|
2711
|
+
callback=self._do_save_file,
|
|
1342
2712
|
)
|
|
1343
2713
|
|
|
1344
2714
|
def _do_save(self, filename: str) -> None:
|
|
@@ -1358,15 +2728,16 @@ class DataFrameTable(DataTable):
|
|
|
1358
2728
|
else:
|
|
1359
2729
|
self.df.write_csv(filename)
|
|
1360
2730
|
|
|
1361
|
-
self.
|
|
2731
|
+
self.lazyframe = self.df.lazy() # Update original dataframe
|
|
1362
2732
|
self.filename = filename # Update current filename
|
|
1363
2733
|
if not self._all_tabs:
|
|
1364
|
-
self.app.
|
|
1365
|
-
|
|
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}[/]",
|
|
1366
2737
|
title="Save",
|
|
1367
2738
|
)
|
|
1368
2739
|
except Exception as e:
|
|
1369
|
-
self.
|
|
2740
|
+
self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
|
|
1370
2741
|
raise e
|
|
1371
2742
|
|
|
1372
2743
|
def _do_save_excel(self, filename: str) -> None:
|
|
@@ -1379,17 +2750,19 @@ class DataFrameTable(DataTable):
|
|
|
1379
2750
|
else:
|
|
1380
2751
|
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
1381
2752
|
with xlsxwriter.Workbook(filename) as wb:
|
|
1382
|
-
|
|
1383
|
-
|
|
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)
|
|
1384
2757
|
|
|
1385
2758
|
# From ConfirmScreen callback, so notify accordingly
|
|
1386
2759
|
if self._all_tabs is True:
|
|
1387
|
-
self.
|
|
1388
|
-
f"Saved all tabs to [
|
|
2760
|
+
self.notify(
|
|
2761
|
+
f"Saved all tabs to [$success]{filename}[/]",
|
|
1389
2762
|
title="Save",
|
|
1390
2763
|
)
|
|
1391
2764
|
else:
|
|
1392
|
-
self.
|
|
1393
|
-
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [
|
|
2765
|
+
self.notify(
|
|
2766
|
+
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
|
|
1394
2767
|
title="Save",
|
|
1395
2768
|
)
|