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