dataframe-textual 0.3.2__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 +1962 -587
- dataframe_textual/data_frame_viewer.py +273 -136
- dataframe_textual/table_screen.py +265 -86
- dataframe_textual/yes_no_screen.py +428 -163
- dataframe_textual-1.0.0.dist-info/METADATA +733 -0
- dataframe_textual-1.0.0.dist-info/RECORD +13 -0
- dataframe_textual-0.3.2.dist-info/METADATA +0 -548
- dataframe_textual-0.3.2.dist-info/RECORD +0 -13
- {dataframe_textual-0.3.2.dist-info → dataframe_textual-1.0.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-0.3.2.dist-info → dataframe_textual-1.0.0.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-0.3.2.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,42 +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(FrequencyScreen(
|
|
1039
|
+
self.app.push_screen(FrequencyScreen(cidx, self))
|
|
1040
|
+
|
|
1041
|
+
def _show_statistics(self, scope: str = "column") -> None:
|
|
1042
|
+
"""Show statistics for the current column or entire dataframe.
|
|
1043
|
+
|
|
1044
|
+
Args:
|
|
1045
|
+
scope: Either "column" for current column stats or "dataframe" for all columns.
|
|
1046
|
+
"""
|
|
1047
|
+
if scope == "dataframe":
|
|
1048
|
+
# Show statistics for entire dataframe
|
|
1049
|
+
self.app.push_screen(StatisticsScreen(self, col_idx=None))
|
|
1050
|
+
else:
|
|
1051
|
+
# Show statistics for current column
|
|
1052
|
+
cidx = self.cursor_col_idx
|
|
1053
|
+
self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
|
|
562
1054
|
|
|
563
|
-
def
|
|
1055
|
+
def _freeze_row_column(self) -> None:
|
|
564
1056
|
"""Open the freeze screen to set fixed rows and columns."""
|
|
565
1057
|
self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
|
|
566
1058
|
|
|
567
1059
|
def _do_freeze(self, result: tuple[int, int] | None) -> None:
|
|
568
|
-
"""Handle result from
|
|
1060
|
+
"""Handle result from PinScreen.
|
|
569
1061
|
|
|
570
1062
|
Args:
|
|
571
1063
|
result: Tuple of (fixed_rows, fixed_columns) or None if cancelled.
|
|
@@ -576,9 +1068,7 @@ class DataFrameTable(DataTable):
|
|
|
576
1068
|
fixed_rows, fixed_columns = result
|
|
577
1069
|
|
|
578
1070
|
# Add to history
|
|
579
|
-
self._add_history(
|
|
580
|
-
f"Pinned [$accent]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns"
|
|
581
|
-
)
|
|
1071
|
+
self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
|
|
582
1072
|
|
|
583
1073
|
# Apply the pin settings to the table
|
|
584
1074
|
if fixed_rows > 0:
|
|
@@ -586,41 +1076,118 @@ class DataFrameTable(DataTable):
|
|
|
586
1076
|
if fixed_columns > 0:
|
|
587
1077
|
self.fixed_columns = fixed_columns
|
|
588
1078
|
|
|
589
|
-
self.
|
|
590
|
-
f"Pinned [$accent]{fixed_rows}[/] rows and [$
|
|
1079
|
+
self.notify(
|
|
1080
|
+
f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
|
|
591
1081
|
title="Pin",
|
|
592
1082
|
)
|
|
593
1083
|
|
|
594
1084
|
# Delete & Move
|
|
595
1085
|
def _delete_column(self) -> None:
|
|
596
1086
|
"""Remove the currently selected column from the table."""
|
|
1087
|
+
# Get the column to remove
|
|
597
1088
|
col_idx = self.cursor_column
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
# Get the column name to remove
|
|
602
|
-
col_to_remove = self.df.columns[col_idx]
|
|
1089
|
+
col_name = self.cursor_col_name
|
|
1090
|
+
col_key = self.cursor_col_key
|
|
603
1091
|
|
|
604
1092
|
# Add to history
|
|
605
|
-
self._add_history(f"Removed column [
|
|
1093
|
+
self._add_history(f"Removed column [$success]{col_name}[/]")
|
|
606
1094
|
|
|
607
1095
|
# Remove the column from the table display using the column name as key
|
|
608
|
-
self.remove_column(
|
|
1096
|
+
self.remove_column(col_key)
|
|
609
1097
|
|
|
610
1098
|
# Move cursor left if we deleted the last column
|
|
611
1099
|
if col_idx >= len(self.columns):
|
|
612
1100
|
self.move_cursor(column=len(self.columns) - 1)
|
|
613
1101
|
|
|
614
1102
|
# Remove from sorted columns if present
|
|
615
|
-
if
|
|
616
|
-
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]
|
|
617
1112
|
|
|
618
1113
|
# Remove from dataframe
|
|
619
|
-
self.df = self.df.drop(
|
|
1114
|
+
self.df = self.df.drop(col_name)
|
|
620
1115
|
|
|
621
|
-
self.
|
|
622
|
-
|
|
623
|
-
|
|
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",
|
|
624
1191
|
)
|
|
625
1192
|
|
|
626
1193
|
def _delete_row(self) -> None:
|
|
@@ -629,48 +1196,83 @@ class DataFrameTable(DataTable):
|
|
|
629
1196
|
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
630
1197
|
"""
|
|
631
1198
|
old_count = len(self.df)
|
|
632
|
-
|
|
1199
|
+
predicates = [True] * len(self.df)
|
|
633
1200
|
|
|
634
1201
|
# Delete all selected rows
|
|
635
1202
|
if selected_count := self.selected_rows.count(True):
|
|
636
1203
|
history_desc = f"Deleted {selected_count} selected row(s)"
|
|
637
1204
|
|
|
638
|
-
for
|
|
639
|
-
if
|
|
640
|
-
|
|
1205
|
+
for ridx, selected in enumerate(self.selected_rows):
|
|
1206
|
+
if selected:
|
|
1207
|
+
predicates[ridx] = False
|
|
1208
|
+
|
|
641
1209
|
# Delete the row at the cursor
|
|
642
1210
|
else:
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
filter_expr[i] = False
|
|
647
|
-
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
|
|
648
1214
|
|
|
649
1215
|
# Add to history
|
|
650
1216
|
self._add_history(history_desc)
|
|
651
1217
|
|
|
652
1218
|
# Apply the filter to remove rows
|
|
653
|
-
|
|
654
|
-
|
|
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)
|
|
655
1227
|
|
|
656
1228
|
# Update selected and visible rows tracking
|
|
657
|
-
old_row_indices = set(df[
|
|
658
|
-
self.selected_rows = [
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
self.visible_rows = [
|
|
664
|
-
visible
|
|
665
|
-
for i, visible in enumerate(self.visible_rows)
|
|
666
|
-
if i in old_row_indices
|
|
667
|
-
]
|
|
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)
|
|
668
1235
|
|
|
669
1236
|
# Recreate the table display
|
|
670
1237
|
self._setup_table()
|
|
671
1238
|
|
|
672
1239
|
deleted_count = old_count - len(self.df)
|
|
673
|
-
|
|
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")
|
|
674
1276
|
|
|
675
1277
|
def _move_column(self, direction: str) -> None:
|
|
676
1278
|
"""Move the current column left or right.
|
|
@@ -679,36 +1281,32 @@ class DataFrameTable(DataTable):
|
|
|
679
1281
|
direction: "left" to move left, "right" to move right.
|
|
680
1282
|
"""
|
|
681
1283
|
row_idx, col_idx = self.cursor_coordinate
|
|
682
|
-
col_key = self.
|
|
1284
|
+
col_key = self.cursor_col_key
|
|
1285
|
+
col_name = col_key.value
|
|
1286
|
+
cidx = self.cursor_col_idx
|
|
683
1287
|
|
|
684
1288
|
# Validate move is possible
|
|
685
1289
|
if direction == "left":
|
|
686
1290
|
if col_idx <= 0:
|
|
687
|
-
self.
|
|
688
|
-
"Cannot move column left", title="Move", severity="warning"
|
|
689
|
-
)
|
|
1291
|
+
self.notify("Cannot move column left", title="Move", severity="warning")
|
|
690
1292
|
return
|
|
691
1293
|
swap_idx = col_idx - 1
|
|
692
1294
|
elif direction == "right":
|
|
693
1295
|
if col_idx >= len(self.columns) - 1:
|
|
694
|
-
self.
|
|
695
|
-
"Cannot move column right", title="Move", severity="warning"
|
|
696
|
-
)
|
|
1296
|
+
self.notify("Cannot move column right", title="Move", severity="warning")
|
|
697
1297
|
return
|
|
698
1298
|
swap_idx = col_idx + 1
|
|
699
1299
|
|
|
700
|
-
# Get column
|
|
701
|
-
|
|
702
|
-
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)
|
|
703
1304
|
|
|
704
1305
|
# Add to history
|
|
705
|
-
self._add_history(
|
|
706
|
-
f"Moved column [on $primary]{col_name}[/] {direction} (swapped with [on $primary]{swap_name}[/])"
|
|
707
|
-
)
|
|
1306
|
+
self._add_history(f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])")
|
|
708
1307
|
|
|
709
1308
|
# Swap columns in the table's internal column locations
|
|
710
1309
|
self.check_idle()
|
|
711
|
-
swap_key = self.df.columns[swap_idx] # str as column key
|
|
712
1310
|
|
|
713
1311
|
(
|
|
714
1312
|
self._column_locations[col_key],
|
|
@@ -726,13 +1324,10 @@ class DataFrameTable(DataTable):
|
|
|
726
1324
|
|
|
727
1325
|
# Update the dataframe column order
|
|
728
1326
|
cols = list(self.df.columns)
|
|
729
|
-
cols[
|
|
1327
|
+
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
730
1328
|
self.df = self.df.select(cols)
|
|
731
1329
|
|
|
732
|
-
self.
|
|
733
|
-
f"Moved column [on $primary]{col_name}[/] {direction}",
|
|
734
|
-
title="Move",
|
|
735
|
-
)
|
|
1330
|
+
# self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
|
|
736
1331
|
|
|
737
1332
|
def _move_row(self, direction: str) -> None:
|
|
738
1333
|
"""Move the current row up or down.
|
|
@@ -745,20 +1340,16 @@ class DataFrameTable(DataTable):
|
|
|
745
1340
|
# Validate move is possible
|
|
746
1341
|
if direction == "up":
|
|
747
1342
|
if row_idx <= 0:
|
|
748
|
-
self.
|
|
1343
|
+
self.notify("Cannot move row up", title="Move", severity="warning")
|
|
749
1344
|
return
|
|
750
1345
|
swap_idx = row_idx - 1
|
|
751
1346
|
elif direction == "down":
|
|
752
1347
|
if row_idx >= len(self.rows) - 1:
|
|
753
|
-
self.
|
|
754
|
-
"Cannot move row down", title="Move", severity="warning"
|
|
755
|
-
)
|
|
1348
|
+
self.notify("Cannot move row down", title="Move", severity="warning")
|
|
756
1349
|
return
|
|
757
1350
|
swap_idx = row_idx + 1
|
|
758
1351
|
else:
|
|
759
|
-
self.
|
|
760
|
-
f"Invalid direction: {direction}", title="Move", severity="error"
|
|
761
|
-
)
|
|
1352
|
+
self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
|
|
762
1353
|
return
|
|
763
1354
|
|
|
764
1355
|
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
@@ -766,7 +1357,7 @@ class DataFrameTable(DataTable):
|
|
|
766
1357
|
|
|
767
1358
|
# Add to history
|
|
768
1359
|
self._add_history(
|
|
769
|
-
f"Moved row [
|
|
1360
|
+
f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])"
|
|
770
1361
|
)
|
|
771
1362
|
|
|
772
1363
|
# Swap rows in the table's internal row locations
|
|
@@ -787,9 +1378,9 @@ class DataFrameTable(DataTable):
|
|
|
787
1378
|
self.move_cursor(row=swap_idx, column=col_idx)
|
|
788
1379
|
|
|
789
1380
|
# Swap rows in the dataframe
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
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])
|
|
793
1384
|
|
|
794
1385
|
self.df = pl.concat(
|
|
795
1386
|
[
|
|
@@ -801,9 +1392,7 @@ class DataFrameTable(DataTable):
|
|
|
801
1392
|
]
|
|
802
1393
|
)
|
|
803
1394
|
|
|
804
|
-
self.
|
|
805
|
-
f"Moved row [on $primary]{row_key.value}[/] {direction}", title="Move"
|
|
806
|
-
)
|
|
1395
|
+
# self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
|
|
807
1396
|
|
|
808
1397
|
# Sort
|
|
809
1398
|
def _sort_by_column(self, descending: bool = False) -> None:
|
|
@@ -816,47 +1405,40 @@ class DataFrameTable(DataTable):
|
|
|
816
1405
|
Args:
|
|
817
1406
|
descending: If True, sort in descending order. If False, ascending order.
|
|
818
1407
|
"""
|
|
1408
|
+
col_name = self.cursor_col_name
|
|
819
1409
|
col_idx = self.cursor_column
|
|
820
|
-
if col_idx >= len(self.df.columns):
|
|
821
|
-
return
|
|
822
|
-
|
|
823
|
-
col_to_sort = self.df.columns[col_idx]
|
|
824
1410
|
|
|
825
1411
|
# Check if this column is already in the sort keys
|
|
826
|
-
old_desc = self.sorted_columns.get(
|
|
827
|
-
if old_desc == descending:
|
|
828
|
-
# Same direction - remove this column from sort
|
|
829
|
-
self.app.notify(
|
|
830
|
-
f"Already sorted by [on $primary]{col_to_sort}[/] ({'desc' if descending else 'asc'})",
|
|
831
|
-
title="Sort",
|
|
832
|
-
severity="warning",
|
|
833
|
-
)
|
|
834
|
-
return
|
|
1412
|
+
old_desc = self.sorted_columns.get(col_name)
|
|
835
1413
|
|
|
836
1414
|
# Add to history
|
|
837
|
-
self._add_history(f"Sorted on column [
|
|
1415
|
+
self._add_history(f"Sorted on column [$success]{col_name}[/]")
|
|
838
1416
|
if old_desc is None:
|
|
839
1417
|
# Add new column to sort
|
|
840
|
-
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]
|
|
841
1422
|
else:
|
|
842
|
-
#
|
|
843
|
-
del self.sorted_columns[
|
|
844
|
-
self.sorted_columns[
|
|
1423
|
+
# Move to end of sort order
|
|
1424
|
+
del self.sorted_columns[col_name]
|
|
1425
|
+
self.sorted_columns[col_name] = descending
|
|
845
1426
|
|
|
846
1427
|
# Apply multi-column sort
|
|
847
|
-
sort_cols
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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)
|
|
852
1434
|
|
|
853
1435
|
# Updated selected_rows and visible_rows to match new order
|
|
854
|
-
old_row_indices = df_sorted[
|
|
1436
|
+
old_row_indices = df_sorted[RIDX].to_list()
|
|
855
1437
|
self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
|
|
856
1438
|
self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
|
|
857
1439
|
|
|
858
1440
|
# Update the dataframe
|
|
859
|
-
self.df = df_sorted.drop(
|
|
1441
|
+
self.df = df_sorted.drop(RIDX)
|
|
860
1442
|
|
|
861
1443
|
# Recreate the table for display
|
|
862
1444
|
self._setup_table()
|
|
@@ -865,22 +1447,18 @@ class DataFrameTable(DataTable):
|
|
|
865
1447
|
self.move_cursor(column=col_idx, row=0)
|
|
866
1448
|
|
|
867
1449
|
# Edit
|
|
868
|
-
def _edit_cell(self) -> None:
|
|
1450
|
+
def _edit_cell(self, ridx: int = None, cidx: int = None) -> None:
|
|
869
1451
|
"""Open modal to edit the selected cell."""
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
if row_idx >= len(self.df) or col_idx >= len(self.df.columns):
|
|
875
|
-
return
|
|
876
|
-
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]
|
|
877
1455
|
|
|
878
1456
|
# Save current state to history
|
|
879
|
-
self._add_history(f"Edited cell [
|
|
1457
|
+
self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
|
|
880
1458
|
|
|
881
1459
|
# Push the edit modal screen
|
|
882
1460
|
self.app.push_screen(
|
|
883
|
-
EditCellScreen(
|
|
1461
|
+
EditCellScreen(ridx, cidx, self.df),
|
|
884
1462
|
callback=self._do_edit_cell,
|
|
885
1463
|
)
|
|
886
1464
|
|
|
@@ -889,292 +1467,1046 @@ class DataFrameTable(DataTable):
|
|
|
889
1467
|
if result is None:
|
|
890
1468
|
return
|
|
891
1469
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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]
|
|
895
1479
|
|
|
896
1480
|
# Update the cell in the dataframe
|
|
897
1481
|
try:
|
|
898
1482
|
self.df = self.df.with_columns(
|
|
899
|
-
pl.when(pl.arange(0, len(self.df)) ==
|
|
1483
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
900
1484
|
.then(pl.lit(new_value))
|
|
901
1485
|
.otherwise(pl.col(col_name))
|
|
902
1486
|
.alias(col_name)
|
|
903
1487
|
)
|
|
904
1488
|
|
|
905
1489
|
# Update the display
|
|
906
|
-
cell_value = self.df.item(
|
|
1490
|
+
cell_value = self.df.item(ridx, cidx)
|
|
907
1491
|
if cell_value is None:
|
|
908
|
-
cell_value =
|
|
909
|
-
dtype = self.df.dtypes[
|
|
1492
|
+
cell_value = NULL_DISPLAY
|
|
1493
|
+
dtype = self.df.dtypes[cidx]
|
|
910
1494
|
dc = DtypeConfig(dtype)
|
|
911
1495
|
formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
|
|
912
1496
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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)
|
|
916
1501
|
|
|
917
|
-
self.
|
|
918
|
-
f"Cell updated to [on $primary]{cell_value}[/]", title="Edit"
|
|
919
|
-
)
|
|
1502
|
+
self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
920
1503
|
except Exception as e:
|
|
921
|
-
self.
|
|
922
|
-
f"Failed to update cell: {str(e)}", title="Edit", severity="error"
|
|
923
|
-
)
|
|
924
|
-
raise e
|
|
1504
|
+
self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
|
|
925
1505
|
|
|
926
|
-
def
|
|
927
|
-
"""
|
|
928
|
-
|
|
1506
|
+
def _edit_column(self) -> None:
|
|
1507
|
+
"""Open modal to edit the entire column with an expression."""
|
|
1508
|
+
cidx = self.cursor_col_idx
|
|
1509
|
+
|
|
1510
|
+
# Push the edit column modal screen
|
|
1511
|
+
self.app.push_screen(
|
|
1512
|
+
EditColumnScreen(cidx, self.df),
|
|
1513
|
+
callback=self._do_edit_column,
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
def _do_edit_column(self, result) -> None:
|
|
1517
|
+
"""Edit a column."""
|
|
1518
|
+
if result is None:
|
|
1519
|
+
return
|
|
1520
|
+
term, cidx = result
|
|
1521
|
+
|
|
1522
|
+
col_name = self.df.columns[cidx]
|
|
1523
|
+
|
|
1524
|
+
# Null case
|
|
1525
|
+
if term is None or term == NULL:
|
|
1526
|
+
expr = pl.lit(None)
|
|
1527
|
+
|
|
1528
|
+
# Check if term is a valid expression
|
|
1529
|
+
elif tentative_expr(term):
|
|
1530
|
+
try:
|
|
1531
|
+
expr = validate_expr(term, self.df, cidx)
|
|
1532
|
+
except Exception as e:
|
|
1533
|
+
self.notify(f"Error validating expression [$error]{term}[/]: {str(e)}", title="Edit", severity="error")
|
|
1534
|
+
return
|
|
1535
|
+
|
|
1536
|
+
# Otherwise, treat term as a literal value
|
|
1537
|
+
else:
|
|
1538
|
+
dtype = self.df.dtypes[cidx]
|
|
1539
|
+
try:
|
|
1540
|
+
value = DtypeConfig(dtype).convert(term)
|
|
1541
|
+
expr = pl.lit(value)
|
|
1542
|
+
except Exception:
|
|
1543
|
+
self.notify(
|
|
1544
|
+
f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1545
|
+
title="Edit",
|
|
1546
|
+
severity="error",
|
|
1547
|
+
)
|
|
1548
|
+
expr = pl.lit(str(term))
|
|
1549
|
+
|
|
1550
|
+
# Add to history
|
|
1551
|
+
self._add_history(f"Edited column [$accent]{col_name}[/] with expression")
|
|
1552
|
+
|
|
1553
|
+
try:
|
|
1554
|
+
# Apply the expression to the column
|
|
1555
|
+
self.df = self.df.with_columns(expr.alias(col_name))
|
|
1556
|
+
except Exception as e:
|
|
1557
|
+
self.notify(f"Failed to apply expression: [$error]{str(e)}[/]", title="Edit", severity="error")
|
|
1558
|
+
return
|
|
1559
|
+
|
|
1560
|
+
# Recreate the table for display
|
|
1561
|
+
self._setup_table()
|
|
1562
|
+
|
|
1563
|
+
self.notify(
|
|
1564
|
+
f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
|
|
1565
|
+
title="Edit",
|
|
1566
|
+
)
|
|
929
1567
|
|
|
930
|
-
|
|
1568
|
+
def _rename_column(self) -> None:
|
|
1569
|
+
"""Open modal to rename the selected column."""
|
|
1570
|
+
col_name = self.cursor_col_name
|
|
931
1571
|
col_idx = self.cursor_column
|
|
932
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
|
|
933
1629
|
try:
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
"clipboard",
|
|
940
|
-
],
|
|
941
|
-
input=cell_str,
|
|
942
|
-
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)
|
|
943
1635
|
)
|
|
944
|
-
self.app.notify(f"Copied: {cell_str[:50]}", title="Clipboard")
|
|
945
|
-
except (FileNotFoundError, IndexError):
|
|
946
|
-
self.app.notify("Error copying cell", title="Clipboard", severity="error")
|
|
947
1636
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
self.app.notify("Invalid column selected", title="Search", severity="error")
|
|
953
|
-
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)
|
|
954
1641
|
|
|
955
|
-
|
|
956
|
-
col_dtype = self.df.dtypes[col_idx]
|
|
1642
|
+
self.update_cell(row_key, col_key, formatted_value)
|
|
957
1643
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
|
961
1648
|
|
|
962
|
-
|
|
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
|
|
963
1697
|
self.app.push_screen(
|
|
964
|
-
|
|
965
|
-
|
|
1698
|
+
AddColumnScreen(cidx, self.df),
|
|
1699
|
+
self._do_add_column_expr,
|
|
966
1700
|
)
|
|
967
1701
|
|
|
968
|
-
def
|
|
969
|
-
"""
|
|
1702
|
+
def _do_add_column_expr(self, result: tuple[int, str, str, pl.Expr] | None) -> None:
|
|
1703
|
+
"""Add a new column with an expression."""
|
|
970
1704
|
if result is None:
|
|
971
1705
|
return
|
|
972
1706
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1707
|
+
cidx, col_name, expr = result
|
|
1708
|
+
|
|
1709
|
+
# Add to history
|
|
1710
|
+
self._add_history(f"Added column [$success]{col_name}[/] with expression {expr}.")
|
|
1711
|
+
|
|
1712
|
+
try:
|
|
1713
|
+
# Create the column
|
|
1714
|
+
new_col = expr.alias(col_name)
|
|
1715
|
+
|
|
1716
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1717
|
+
cols = self.df.columns
|
|
1718
|
+
cols_before = cols[: cidx + 1]
|
|
1719
|
+
cols_after = cols[cidx + 1 :]
|
|
1720
|
+
|
|
1721
|
+
# Build the new dataframe with columns reordered
|
|
1722
|
+
select_cols = cols_before + [col_name] + cols_after
|
|
1723
|
+
self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
|
|
980
1724
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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.
|
|
985
1738
|
|
|
986
1739
|
Args:
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
|
990
1747
|
"""
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1748
|
+
dtype_map = {
|
|
1749
|
+
"string": pl.String,
|
|
1750
|
+
"int": pl.Int64,
|
|
1751
|
+
"float": pl.Float64,
|
|
1752
|
+
"bool": pl.Boolean,
|
|
1753
|
+
}
|
|
994
1754
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
|
1006
1778
|
else:
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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",
|
|
1011
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:
|
|
1012
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)
|
|
1013
1874
|
|
|
1014
1875
|
# Apply filter to get matched row indices
|
|
1015
|
-
|
|
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
|
|
1016
1885
|
|
|
1017
1886
|
match_count = len(matches)
|
|
1018
1887
|
if match_count == 0:
|
|
1019
|
-
self.
|
|
1020
|
-
f"No matches found for
|
|
1888
|
+
self.notify(
|
|
1889
|
+
f"No matches found for [$warning]{term}[/]. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
1021
1890
|
title="Search",
|
|
1022
1891
|
severity="warning",
|
|
1023
1892
|
)
|
|
1024
1893
|
return
|
|
1025
1894
|
|
|
1026
1895
|
# Add to history
|
|
1027
|
-
self._add_history(
|
|
1028
|
-
f"Searched and highlighted [on $primary]{term}[/] in column [on $primary]{col_name}[/]"
|
|
1029
|
-
)
|
|
1896
|
+
self._add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
1030
1897
|
|
|
1031
1898
|
# Update selected rows to include new matches
|
|
1032
1899
|
for m in matches:
|
|
1033
1900
|
self.selected_rows[m] = True
|
|
1034
1901
|
|
|
1035
|
-
# Highlight
|
|
1036
|
-
self.
|
|
1902
|
+
# Highlight matches
|
|
1903
|
+
self._do_highlight()
|
|
1037
1904
|
|
|
1038
|
-
self.
|
|
1039
|
-
f"Found [on $primary]{match_count}[/] matches for [on $primary]{term}[/]",
|
|
1040
|
-
title="Search",
|
|
1041
|
-
)
|
|
1905
|
+
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
|
|
1042
1906
|
|
|
1043
|
-
def
|
|
1044
|
-
|
|
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.
|
|
1045
1911
|
|
|
1046
1912
|
Args:
|
|
1047
|
-
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.
|
|
1048
1923
|
"""
|
|
1049
|
-
|
|
1924
|
+
matches: dict[int, set[int]] = defaultdict(set)
|
|
1925
|
+
|
|
1926
|
+
# Lazyframe for filtering
|
|
1927
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
1050
1928
|
if False in self.visible_rows:
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
# Search for NULL values across all columns
|
|
1057
|
-
for col_idx, col in enumerate(df_rid.columns[1:]):
|
|
1058
|
-
masks = df_rid[col].is_null()
|
|
1059
|
-
matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
|
|
1060
|
-
for rid in matched_rids:
|
|
1061
|
-
if rid not in matches:
|
|
1062
|
-
matches[rid] = set()
|
|
1063
|
-
matches[rid].add(col_idx)
|
|
1064
|
-
match_count += 1
|
|
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])]
|
|
1065
1934
|
else:
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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)
|
|
1076
1953
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1954
|
+
# Get matched row indices
|
|
1955
|
+
try:
|
|
1956
|
+
matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
|
|
1957
|
+
except Exception as e:
|
|
1958
|
+
raise Exception(f"Error applying filter: {str(e)}")
|
|
1959
|
+
|
|
1960
|
+
for ridx in matched_ridxs:
|
|
1961
|
+
matches[ridx].add(col_idx)
|
|
1962
|
+
|
|
1963
|
+
return matches
|
|
1964
|
+
|
|
1965
|
+
def _find_cursor_value(self, scope="column") -> None:
|
|
1966
|
+
"""Find by cursor value.
|
|
1967
|
+
|
|
1968
|
+
Args:
|
|
1969
|
+
scope: "column" to find in current column, "global" to find across all columns.
|
|
1970
|
+
"""
|
|
1971
|
+
# Get the value of the currently selected cell
|
|
1972
|
+
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1973
|
+
|
|
1974
|
+
if scope == "column":
|
|
1975
|
+
cidx = self.cursor_col_idx
|
|
1976
|
+
self._do_find((term, cidx, False, False))
|
|
1977
|
+
else:
|
|
1978
|
+
self._do_find_global((term, None, False, False))
|
|
1979
|
+
|
|
1980
|
+
def _find_expr(self, scope="column") -> None:
|
|
1981
|
+
"""Open screen to find by expression.
|
|
1982
|
+
|
|
1983
|
+
Args:
|
|
1984
|
+
scope: "column" to find in current column, "global" to find across all columns.
|
|
1985
|
+
"""
|
|
1986
|
+
# Use current cell value as default search term
|
|
1987
|
+
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1988
|
+
cidx = self.cursor_col_idx if scope == "column" else None
|
|
1989
|
+
|
|
1990
|
+
# Push the search modal screen
|
|
1991
|
+
self.app.push_screen(
|
|
1992
|
+
SearchScreen("Find", term, self.df, cidx),
|
|
1993
|
+
callback=self._do_find if scope == "column" else self._do_find_global,
|
|
1994
|
+
)
|
|
1995
|
+
|
|
1996
|
+
def _do_find(self, result) -> None:
|
|
1997
|
+
"""Find a term in current column."""
|
|
1998
|
+
if result is None:
|
|
1999
|
+
return
|
|
2000
|
+
term, cidx, match_nocase, match_whole = result
|
|
2001
|
+
|
|
2002
|
+
col_name = self.df.columns[cidx]
|
|
2003
|
+
|
|
2004
|
+
try:
|
|
2005
|
+
matches = self._find_matches(term, cidx, match_nocase, match_whole)
|
|
2006
|
+
except Exception as e:
|
|
2007
|
+
self.notify(
|
|
2008
|
+
f"Error finding matches: [$error]{str(e)}[/]",
|
|
2009
|
+
title="Find",
|
|
2010
|
+
severity="error",
|
|
2011
|
+
)
|
|
2012
|
+
return
|
|
2013
|
+
|
|
2014
|
+
if not matches:
|
|
2015
|
+
self.notify(
|
|
2016
|
+
f"No matches found for [$warning]{term}[/] in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2017
|
+
title="Find",
|
|
2018
|
+
severity="warning",
|
|
2019
|
+
)
|
|
2020
|
+
return
|
|
2021
|
+
|
|
2022
|
+
# Add to history
|
|
2023
|
+
self._add_history(f"Found [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
2024
|
+
|
|
2025
|
+
# Add to matches and count total
|
|
2026
|
+
match_count = sum(len(col_idxs) for col_idxs in matches.values())
|
|
2027
|
+
for ridx, col_idxs in matches.items():
|
|
2028
|
+
self.matches[ridx].update(col_idxs)
|
|
2029
|
+
|
|
2030
|
+
# Highlight matches
|
|
2031
|
+
self._do_highlight()
|
|
2032
|
+
|
|
2033
|
+
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
|
|
2034
|
+
|
|
2035
|
+
def _do_find_global(self, result) -> None:
|
|
2036
|
+
"""Global find a term across all columns."""
|
|
2037
|
+
if result is None:
|
|
2038
|
+
return
|
|
2039
|
+
term, cidx, match_nocase, match_whole = result
|
|
2040
|
+
|
|
2041
|
+
try:
|
|
2042
|
+
matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2043
|
+
except Exception as e:
|
|
2044
|
+
self.notify(
|
|
2045
|
+
f"Error finding matches: [$error]{str(e)}[/]",
|
|
2046
|
+
title="Find",
|
|
2047
|
+
severity="error",
|
|
2048
|
+
)
|
|
2049
|
+
return
|
|
2050
|
+
|
|
2051
|
+
if not matches:
|
|
2052
|
+
self.notify(
|
|
2053
|
+
f"No matches found for [$warning]{term}[/] in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2054
|
+
title="Global Find",
|
|
1081
2055
|
severity="warning",
|
|
1082
2056
|
)
|
|
1083
2057
|
return
|
|
1084
2058
|
|
|
1085
|
-
#
|
|
1086
|
-
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
|
|
1087
2231
|
|
|
1088
2232
|
# Add to history
|
|
1089
2233
|
self._add_history(
|
|
1090
|
-
f"
|
|
2234
|
+
f"Replacing [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
|
|
1091
2235
|
)
|
|
1092
2236
|
|
|
1093
|
-
#
|
|
1094
|
-
for
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
+
)
|
|
1098
2260
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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)
|
|
1102
2268
|
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
+
)
|
|
1105
2275
|
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
|
1108
2291
|
|
|
1109
|
-
self.
|
|
1110
|
-
|
|
1111
|
-
|
|
2292
|
+
state = self._replace_state
|
|
2293
|
+
rows = state.rows
|
|
2294
|
+
cols_per_row = state.cols_per_row
|
|
2295
|
+
|
|
2296
|
+
# Replace in each matched row/column
|
|
2297
|
+
for ridx, col_idxs in zip(rows, cols_per_row):
|
|
2298
|
+
for cidx in col_idxs:
|
|
2299
|
+
col_name = self.df.columns[cidx]
|
|
2300
|
+
dtype = self.df.dtypes[cidx]
|
|
2301
|
+
|
|
2302
|
+
# Only applicable to string columns for substring matches
|
|
2303
|
+
if dtype == pl.String and not state.match_whole:
|
|
2304
|
+
term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
|
|
2305
|
+
self.df = self.df.with_columns(
|
|
2306
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2307
|
+
.then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
|
|
2308
|
+
.otherwise(pl.col(col_name))
|
|
2309
|
+
.alias(col_name)
|
|
2310
|
+
)
|
|
2311
|
+
else:
|
|
2312
|
+
# try to convert replacement value to column dtype
|
|
2313
|
+
try:
|
|
2314
|
+
value = DtypeConfig(dtype).convert(state.term_replace)
|
|
2315
|
+
except Exception:
|
|
2316
|
+
value = state.term_replace
|
|
2317
|
+
|
|
2318
|
+
self.df = self.df.with_columns(
|
|
2319
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2320
|
+
.then(pl.lit(value))
|
|
2321
|
+
.otherwise(pl.col(col_name))
|
|
2322
|
+
.alias(col_name)
|
|
2323
|
+
)
|
|
2324
|
+
|
|
2325
|
+
state.replaced_occurrence += 1
|
|
2326
|
+
|
|
2327
|
+
# Recreate the table display
|
|
2328
|
+
self._setup_table()
|
|
2329
|
+
|
|
2330
|
+
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2331
|
+
self.notify(
|
|
2332
|
+
f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]",
|
|
2333
|
+
title="Replace",
|
|
1112
2334
|
)
|
|
1113
2335
|
|
|
1114
|
-
def
|
|
1115
|
-
"""
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
+
)
|
|
1119
2347
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
2348
|
+
def _show_next_replace_confirmation(self) -> None:
|
|
2349
|
+
"""Show confirmation for next replacement."""
|
|
2350
|
+
state = self._replace_state
|
|
2351
|
+
if state.done:
|
|
2352
|
+
# All done - show final notification
|
|
2353
|
+
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2354
|
+
msg = f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]"
|
|
2355
|
+
if state.skipped_occurrence > 0:
|
|
2356
|
+
msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
|
|
2357
|
+
self.notify(msg, title="Replace")
|
|
2358
|
+
return
|
|
2359
|
+
|
|
2360
|
+
# Move cursor to next match
|
|
2361
|
+
ridx = state.rows[state.current_rpos]
|
|
2362
|
+
cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
|
|
2363
|
+
self.move_cursor(row=ridx, column=cidx)
|
|
2364
|
+
|
|
2365
|
+
state.current_occurrence += 1
|
|
2366
|
+
|
|
2367
|
+
# Show confirmation
|
|
2368
|
+
label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] (Occurrence {state.current_occurrence} of {state.total_occurrence})?"
|
|
2369
|
+
|
|
2370
|
+
self.app.push_screen(
|
|
2371
|
+
ConfirmScreen("Replace", label=label, maybe="Skip"),
|
|
2372
|
+
callback=self._handle_replace_confirmation,
|
|
2373
|
+
)
|
|
2374
|
+
|
|
2375
|
+
def _handle_replace_confirmation(self, result) -> None:
|
|
2376
|
+
"""Handle user's confirmation response."""
|
|
2377
|
+
state = self._replace_state
|
|
2378
|
+
if state.done:
|
|
2379
|
+
return
|
|
2380
|
+
|
|
2381
|
+
ridx = state.rows[state.current_rpos]
|
|
2382
|
+
cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
|
|
2383
|
+
col_name = self.df.columns[cidx]
|
|
2384
|
+
dtype = self.df.dtypes[cidx]
|
|
2385
|
+
|
|
2386
|
+
# Replace
|
|
2387
|
+
if result is True:
|
|
2388
|
+
# Only applicable to string columns for substring matches
|
|
2389
|
+
if dtype == pl.String and not state.match_whole:
|
|
2390
|
+
term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
|
|
2391
|
+
self.df = self.df.with_columns(
|
|
2392
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2393
|
+
.then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
|
|
2394
|
+
.otherwise(pl.col(col_name))
|
|
2395
|
+
.alias(col_name)
|
|
2396
|
+
)
|
|
2397
|
+
else:
|
|
2398
|
+
# try to convert replacement value to column dtype
|
|
2399
|
+
try:
|
|
2400
|
+
value = DtypeConfig(dtype).convert(state.term_replace)
|
|
2401
|
+
except Exception:
|
|
2402
|
+
value = state.term_replace
|
|
2403
|
+
|
|
2404
|
+
self.df = self.df.with_columns(
|
|
2405
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2406
|
+
.then(pl.lit(value))
|
|
2407
|
+
.otherwise(pl.col(col_name))
|
|
2408
|
+
.alias(col_name)
|
|
2409
|
+
)
|
|
2410
|
+
|
|
2411
|
+
state.replaced_occurrence += 1
|
|
2412
|
+
|
|
2413
|
+
# Skip
|
|
2414
|
+
elif result is False:
|
|
2415
|
+
state.skipped_occurrence += 1
|
|
1123
2416
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
|
1127
2429
|
|
|
1128
|
-
|
|
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:
|
|
1129
2440
|
"""Toggle selected rows highlighting on/off."""
|
|
1130
2441
|
# Save current state to history
|
|
1131
2442
|
self._add_history("Toggled row selection")
|
|
1132
2443
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
|
1137
2451
|
else:
|
|
1138
2452
|
# Invert all selected rows
|
|
1139
|
-
self.selected_rows = [not
|
|
2453
|
+
self.selected_rows = [not selected for selected in self.selected_rows]
|
|
1140
2454
|
|
|
1141
2455
|
# Check if we're highlighting or un-highlighting
|
|
1142
2456
|
if new_selected_count := self.selected_rows.count(True):
|
|
1143
|
-
self.
|
|
1144
|
-
f"Toggled selection
|
|
2457
|
+
self.notify(
|
|
2458
|
+
f"Toggled selection for [$accent]{new_selected_count}[/] rows",
|
|
1145
2459
|
title="Toggle",
|
|
1146
2460
|
)
|
|
1147
2461
|
|
|
1148
2462
|
# Refresh the highlighting (also restores default styles for unselected rows)
|
|
1149
|
-
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()
|
|
1150
2485
|
|
|
1151
|
-
def
|
|
2486
|
+
def _clear_selections(self) -> None:
|
|
1152
2487
|
"""Clear all selected rows without removing them from the dataframe."""
|
|
1153
|
-
# Check if any rows
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
self.app.notify(
|
|
1157
|
-
"No rows selected to clear", title="Clear", severity="warning"
|
|
1158
|
-
)
|
|
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")
|
|
1159
2491
|
return
|
|
1160
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
|
+
|
|
1161
2497
|
# Save current state to history
|
|
1162
2498
|
self._add_history("Cleared all selected rows")
|
|
1163
2499
|
|
|
1164
2500
|
# Clear all selections and refresh highlighting
|
|
1165
|
-
self.
|
|
2501
|
+
self._do_highlight(clear=True)
|
|
1166
2502
|
|
|
1167
|
-
self.
|
|
1168
|
-
f"Cleared [$accent]{selected_count}[/] selected rows", title="Clear"
|
|
1169
|
-
)
|
|
2503
|
+
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
1170
2504
|
|
|
1171
2505
|
def _filter_selected_rows(self) -> None:
|
|
1172
|
-
"""
|
|
2506
|
+
"""Keep only the selected rows and remove unselected ones."""
|
|
1173
2507
|
selected_count = self.selected_rows.count(True)
|
|
1174
2508
|
if selected_count == 0:
|
|
1175
|
-
self.
|
|
1176
|
-
"No rows selected to filter", title="Filter", severity="warning"
|
|
1177
|
-
)
|
|
2509
|
+
self.notify("No rows selected to filter", title="Filter", severity="warning")
|
|
1178
2510
|
return
|
|
1179
2511
|
|
|
1180
2512
|
# Save current state to history
|
|
@@ -1187,126 +2519,166 @@ class DataFrameTable(DataTable):
|
|
|
1187
2519
|
# Recreate the table for display
|
|
1188
2520
|
self._setup_table()
|
|
1189
2521
|
|
|
1190
|
-
self.
|
|
2522
|
+
self.notify(
|
|
1191
2523
|
f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
|
|
1192
2524
|
title="Filter",
|
|
1193
2525
|
)
|
|
1194
2526
|
|
|
1195
|
-
def
|
|
1196
|
-
"""
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
2527
|
+
def _view_rows(self) -> None:
|
|
2528
|
+
"""View rows.
|
|
2529
|
+
|
|
2530
|
+
If there are selected rows, view those rows.
|
|
2531
|
+
Otherwise, view based on the value of the currently selected cell.
|
|
2532
|
+
"""
|
|
1200
2533
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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))
|
|
1204
2553
|
|
|
1205
2554
|
self.app.push_screen(
|
|
1206
|
-
FilterScreen(
|
|
1207
|
-
|
|
1208
|
-
),
|
|
1209
|
-
callback=self._do_filter,
|
|
2555
|
+
FilterScreen(self.df, cidx, cursor_value),
|
|
2556
|
+
callback=self._do_view_rows,
|
|
1210
2557
|
)
|
|
1211
2558
|
|
|
1212
|
-
def
|
|
1213
|
-
"""
|
|
1214
|
-
|
|
1215
|
-
Args:
|
|
1216
|
-
expression: The filter expression or None if cancelled.
|
|
1217
|
-
"""
|
|
2559
|
+
def _do_view_rows(self, result) -> None:
|
|
2560
|
+
"""Show only those matching rows and hide others. Do not modify the dataframe."""
|
|
1218
2561
|
if result is None:
|
|
1219
2562
|
return
|
|
1220
|
-
|
|
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
|
+
)
|
|
1221
2604
|
|
|
1222
|
-
#
|
|
1223
|
-
|
|
2605
|
+
# Lazyframe with row indices
|
|
2606
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
1224
2607
|
|
|
1225
2608
|
# Apply existing visibility filter first
|
|
1226
2609
|
if False in self.visible_rows:
|
|
1227
|
-
|
|
2610
|
+
lf = lf.filter(self.visible_rows)
|
|
1228
2611
|
|
|
1229
2612
|
# Apply the filter expression
|
|
1230
|
-
|
|
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
|
|
1231
2619
|
|
|
1232
2620
|
matched_count = len(df_filtered)
|
|
1233
2621
|
if not matched_count:
|
|
1234
|
-
self.
|
|
1235
|
-
f"No rows match the expression: [
|
|
2622
|
+
self.notify(
|
|
2623
|
+
f"No rows match the expression: [$success]{expr}[/]",
|
|
1236
2624
|
title="Filter",
|
|
1237
2625
|
severity="warning",
|
|
1238
2626
|
)
|
|
1239
2627
|
return
|
|
1240
2628
|
|
|
1241
2629
|
# Add to history
|
|
1242
|
-
self._add_history(f"Filtered by expression [
|
|
2630
|
+
self._add_history(f"Filtered by expression [$success]{expr}[/]")
|
|
1243
2631
|
|
|
1244
|
-
# Mark unfiltered rows as invisible
|
|
1245
|
-
filtered_row_indices = set(df_filtered[
|
|
2632
|
+
# Mark unfiltered rows as invisible
|
|
2633
|
+
filtered_row_indices = set(df_filtered[RIDX].to_list())
|
|
1246
2634
|
if filtered_row_indices:
|
|
1247
|
-
for
|
|
1248
|
-
if
|
|
1249
|
-
self.visible_rows[
|
|
1250
|
-
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
|
|
1251
2638
|
|
|
1252
2639
|
# Recreate the table for display
|
|
1253
2640
|
self._setup_table()
|
|
1254
2641
|
|
|
1255
|
-
self.
|
|
2642
|
+
self.notify(
|
|
1256
2643
|
f"Filtered to [$accent]{matched_count}[/] matching rows",
|
|
1257
2644
|
title="Filter",
|
|
1258
2645
|
)
|
|
1259
2646
|
|
|
1260
|
-
def _filter_rows(self) -> None:
|
|
1261
|
-
"""Filter rows.
|
|
1262
|
-
|
|
1263
|
-
If there are selected rows, filter to those rows.
|
|
1264
|
-
Otherwise, filter based on the value of the currently selected cell.
|
|
1265
|
-
"""
|
|
1266
|
-
|
|
1267
|
-
if True in self.selected_rows:
|
|
1268
|
-
expr = self.selected_rows
|
|
1269
|
-
expr_str = "selected rows"
|
|
1270
|
-
else:
|
|
1271
|
-
row_key = self.cursor_row_key
|
|
1272
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based index
|
|
1273
|
-
col_idx = self.cursor_column
|
|
1274
|
-
|
|
1275
|
-
cell_value = self.df.item(row_idx, col_idx)
|
|
1276
|
-
|
|
1277
|
-
if cell_value is None:
|
|
1278
|
-
expr = pl.col(self.df.columns[col_idx]).is_null()
|
|
1279
|
-
expr_str = "NULL"
|
|
1280
|
-
else:
|
|
1281
|
-
expr = pl.col(self.df.columns[col_idx]) == cell_value
|
|
1282
|
-
expr_str = f"$_ == {repr(cell_value)}"
|
|
1283
|
-
|
|
1284
|
-
self._do_filter((expr, expr_str))
|
|
1285
|
-
|
|
1286
2647
|
def _cycle_cursor_type(self) -> None:
|
|
1287
2648
|
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
1288
|
-
next_type =
|
|
2649
|
+
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
1289
2650
|
self.cursor_type = next_type
|
|
1290
2651
|
|
|
1291
|
-
self.
|
|
1292
|
-
f"Changed cursor type to [on $primary]{next_type}[/]", title="Cursor"
|
|
1293
|
-
)
|
|
2652
|
+
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
1294
2653
|
|
|
1295
|
-
def
|
|
1296
|
-
"""
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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")
|
|
1300
2676
|
|
|
1301
2677
|
def _save_to_file(self) -> None:
|
|
1302
|
-
"""Open save file
|
|
1303
|
-
self.app.push_screen(
|
|
1304
|
-
SaveFileScreen(self.filename), callback=self._on_save_file_screen
|
|
1305
|
-
)
|
|
2678
|
+
"""Open screen to save file."""
|
|
2679
|
+
self.app.push_screen(SaveFileScreen(self.filename), callback=self._do_save_file)
|
|
1306
2680
|
|
|
1307
|
-
def
|
|
1308
|
-
self, filename: str | None, all_tabs: bool = False
|
|
1309
|
-
) -> None:
|
|
2681
|
+
def _do_save_file(self, filename: str | None, all_tabs: bool = False) -> None:
|
|
1310
2682
|
"""Handle result from SaveFileScreen."""
|
|
1311
2683
|
if filename is None:
|
|
1312
2684
|
return
|
|
@@ -1336,7 +2708,7 @@ class DataFrameTable(DataTable):
|
|
|
1336
2708
|
# Go back to SaveFileScreen to allow user to enter a different name
|
|
1337
2709
|
self.app.push_screen(
|
|
1338
2710
|
SaveFileScreen(self._pending_filename),
|
|
1339
|
-
callback=self.
|
|
2711
|
+
callback=self._do_save_file,
|
|
1340
2712
|
)
|
|
1341
2713
|
|
|
1342
2714
|
def _do_save(self, filename: str) -> None:
|
|
@@ -1356,15 +2728,16 @@ class DataFrameTable(DataTable):
|
|
|
1356
2728
|
else:
|
|
1357
2729
|
self.df.write_csv(filename)
|
|
1358
2730
|
|
|
1359
|
-
self.
|
|
2731
|
+
self.lazyframe = self.df.lazy() # Update original dataframe
|
|
1360
2732
|
self.filename = filename # Update current filename
|
|
1361
2733
|
if not self._all_tabs:
|
|
1362
|
-
self.app.
|
|
1363
|
-
|
|
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}[/]",
|
|
1364
2737
|
title="Save",
|
|
1365
2738
|
)
|
|
1366
2739
|
except Exception as e:
|
|
1367
|
-
self.
|
|
2740
|
+
self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
|
|
1368
2741
|
raise e
|
|
1369
2742
|
|
|
1370
2743
|
def _do_save_excel(self, filename: str) -> None:
|
|
@@ -1377,17 +2750,19 @@ class DataFrameTable(DataTable):
|
|
|
1377
2750
|
else:
|
|
1378
2751
|
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
1379
2752
|
with xlsxwriter.Workbook(filename) as wb:
|
|
1380
|
-
|
|
1381
|
-
|
|
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)
|
|
1382
2757
|
|
|
1383
2758
|
# From ConfirmScreen callback, so notify accordingly
|
|
1384
2759
|
if self._all_tabs is True:
|
|
1385
|
-
self.
|
|
1386
|
-
f"Saved all tabs to [
|
|
2760
|
+
self.notify(
|
|
2761
|
+
f"Saved all tabs to [$success]{filename}[/]",
|
|
1387
2762
|
title="Save",
|
|
1388
2763
|
)
|
|
1389
2764
|
else:
|
|
1390
|
-
self.
|
|
1391
|
-
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}[/]",
|
|
1392
2767
|
title="Save",
|
|
1393
2768
|
)
|