dataframe-textual 1.0.0__py3-none-any.whl → 1.4.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 +48 -23
- dataframe_textual/common.py +372 -23
- dataframe_textual/data_frame_help_panel.py +6 -4
- dataframe_textual/data_frame_table.py +893 -449
- dataframe_textual/data_frame_viewer.py +39 -141
- dataframe_textual/sql_screen.py +202 -0
- dataframe_textual/table_screen.py +45 -28
- dataframe_textual/yes_no_screen.py +12 -8
- {dataframe_textual-1.0.0.dist-info → dataframe_textual-1.4.0.dist-info}/METADATA +205 -46
- dataframe_textual-1.4.0.dist-info/RECORD +14 -0
- {dataframe_textual-1.0.0.dist-info → dataframe_textual-1.4.0.dist-info}/entry_points.txt +1 -0
- dataframe_textual-1.0.0.dist-info/RECORD +0 -13
- {dataframe_textual-1.0.0.dist-info → dataframe_textual-1.4.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-1.0.0.dist-info → dataframe_textual-1.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,8 +9,10 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
import polars as pl
|
|
11
11
|
from rich.text import Text
|
|
12
|
+
from textual import work
|
|
12
13
|
from textual.coordinate import Coordinate
|
|
13
14
|
from textual.events import Click
|
|
15
|
+
from textual.render import measure
|
|
14
16
|
from textual.widgets import DataTable, TabPane
|
|
15
17
|
from textual.widgets._data_table import (
|
|
16
18
|
CellDoesNotExist,
|
|
@@ -30,9 +32,11 @@ from .common import (
|
|
|
30
32
|
format_row,
|
|
31
33
|
get_next_item,
|
|
32
34
|
rindex,
|
|
35
|
+
sleep_async,
|
|
33
36
|
tentative_expr,
|
|
34
37
|
validate_expr,
|
|
35
38
|
)
|
|
39
|
+
from .sql_screen import AdvancedSqlScreen, SimpleSqlScreen
|
|
36
40
|
from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
|
|
37
41
|
from .yes_no_screen import (
|
|
38
42
|
AddColumnScreen,
|
|
@@ -47,6 +51,15 @@ from .yes_no_screen import (
|
|
|
47
51
|
SearchScreen,
|
|
48
52
|
)
|
|
49
53
|
|
|
54
|
+
# Color for highlighting selections and matches
|
|
55
|
+
HIGHLIGHT_COLOR = "red"
|
|
56
|
+
|
|
57
|
+
# Warning threshold for loading rows
|
|
58
|
+
WARN_ROWS_THRESHOLD = 50_000
|
|
59
|
+
|
|
60
|
+
# Maximum width for string columns before truncation
|
|
61
|
+
STRING_WIDTH_CAP = 35
|
|
62
|
+
|
|
50
63
|
|
|
51
64
|
@dataclass
|
|
52
65
|
class History:
|
|
@@ -97,6 +110,8 @@ class DataFrameTable(DataTable):
|
|
|
97
110
|
- **↑↓←→** - 🎯 Move cursor (cell/row/column)
|
|
98
111
|
- **g** - ⬆️ Jump to first row
|
|
99
112
|
- **G** - ⬇️ Jump to last row
|
|
113
|
+
- **Ctrl+F** - 📜 Page down
|
|
114
|
+
- **Ctrl+B** - 📜 Page up
|
|
100
115
|
- **PgUp/PgDn** - 📜 Page up/down
|
|
101
116
|
|
|
102
117
|
## 👁️ View & Display
|
|
@@ -104,8 +119,13 @@ class DataFrameTable(DataTable):
|
|
|
104
119
|
- **F** - 📊 Show frequency distribution
|
|
105
120
|
- **s** - 📈 Show statistics for current column
|
|
106
121
|
- **S** - 📊 Show statistics for entire dataframe
|
|
107
|
-
- **
|
|
122
|
+
- **h** - 👁️ Hide current column
|
|
123
|
+
- **H** - 👀 Show all hidden rows/columns
|
|
124
|
+
- **_** - 📏 Expand column to full width
|
|
125
|
+
- **z** - 📌 Freeze rows and columns
|
|
108
126
|
- **~** - 🏷️ Toggle row labels
|
|
127
|
+
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
128
|
+
- **K** - 🔄 Cycle cursor (cell → row → column → cell)
|
|
109
129
|
|
|
110
130
|
## ↕️ Sorting
|
|
111
131
|
- **[** - 🔼 Sort column ascending
|
|
@@ -117,8 +137,8 @@ class DataFrameTable(DataTable):
|
|
|
117
137
|
- **\\\\** - 🔎 Search in current column using cursor value
|
|
118
138
|
- **/** - 🔎 Find in current column with cursor value
|
|
119
139
|
- **?** - 🔎 Find in current column with expression
|
|
120
|
-
-
|
|
121
|
-
-
|
|
140
|
+
- **;** - 🌐 Global find using cursor value
|
|
141
|
+
- **:** - 🌐 Global find with expression
|
|
122
142
|
- **n** - ⬇️ Go to next match
|
|
123
143
|
- **N** - ⬆️ Go to previous match
|
|
124
144
|
- **v** - 👁️ View/filter rows by cell or selected rows
|
|
@@ -136,7 +156,11 @@ class DataFrameTable(DataTable):
|
|
|
136
156
|
- **{** - ⬆️ Go to previous selected row
|
|
137
157
|
- **}** - ⬇️ Go to next selected row
|
|
138
158
|
- **"** - 📍 Filter to show only selected rows
|
|
139
|
-
- **T** - 🧹 Clear all selections
|
|
159
|
+
- **T** - 🧹 Clear all selections and matches
|
|
160
|
+
|
|
161
|
+
## 🔍 SQL Interface
|
|
162
|
+
- **l** - 💬 Open simple SQL interface (select columns & WHERE clause)
|
|
163
|
+
- **L** - 🔎 Open advanced SQL interface (full SQL queries)
|
|
140
164
|
|
|
141
165
|
## ✏️ Edit & Modify
|
|
142
166
|
- **Double-click** - ✍️ Edit cell or rename column header
|
|
@@ -144,13 +168,13 @@ class DataFrameTable(DataTable):
|
|
|
144
168
|
- **E** - 📊 Edit entire column with expression
|
|
145
169
|
- **a** - ➕ Add empty column after current
|
|
146
170
|
- **A** - ➕ Add column with name and optional expression
|
|
147
|
-
- **x** -
|
|
148
|
-
- **X** -
|
|
149
|
-
- **
|
|
171
|
+
- **x** - ❌ Delete current row
|
|
172
|
+
- **X** - ❌ Delete row and those below
|
|
173
|
+
- **Ctrl+X** - ❌ Delete row and those above
|
|
174
|
+
- **delete** - ❌ Clear current cell (set to NULL)
|
|
150
175
|
- **-** - ❌ Delete current column
|
|
151
176
|
- **d** - 📋 Duplicate current column
|
|
152
|
-
- **
|
|
153
|
-
- **H** - 👀 Show all hidden columns
|
|
177
|
+
- **D** - 📋 Duplicate current row
|
|
154
178
|
|
|
155
179
|
## 🎯 Reorder
|
|
156
180
|
- **Shift+↑↓** - ⬆️⬇️ Move row up/down
|
|
@@ -166,37 +190,48 @@ class DataFrameTable(DataTable):
|
|
|
166
190
|
- **@** - 🔗 Make URLs in current column clickable with Ctrl/Cmd
|
|
167
191
|
|
|
168
192
|
## 💾 Data Management
|
|
169
|
-
- **z** - 📌 Freeze rows and columns
|
|
170
|
-
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
171
193
|
- **c** - 📋 Copy cell to clipboard
|
|
172
194
|
- **Ctrl+c** - 📊 Copy column to clipboard
|
|
173
195
|
- **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
|
|
174
196
|
- **Ctrl+s** - 💾 Save current tab to file
|
|
175
197
|
- **u** - ↩️ Undo last action
|
|
176
|
-
- **U** - 🔄
|
|
198
|
+
- **U** - 🔄 Redo last undone action
|
|
199
|
+
- **Ctrl+U** - 🔁 Reset to initial state
|
|
177
200
|
""").strip()
|
|
178
201
|
|
|
179
202
|
# fmt: off
|
|
180
203
|
BINDINGS = [
|
|
204
|
+
# Navigation
|
|
181
205
|
("g", "jump_top", "Jump to top"),
|
|
182
206
|
("G", "jump_bottom", "Jump to bottom"),
|
|
207
|
+
("ctrl+f", "forward_page", "Page down"),
|
|
208
|
+
("ctrl+b", "backward_page", "Page up"),
|
|
209
|
+
# Display
|
|
183
210
|
("h", "hide_column", "Hide column"),
|
|
184
|
-
("H", "
|
|
211
|
+
("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
|
|
212
|
+
("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
|
|
213
|
+
("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
|
|
214
|
+
("z", "freeze_row_column", "Freeze rows/columns"),
|
|
215
|
+
("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
|
|
216
|
+
("underscore", "expand_column", "Expand column to full width"), # `_`
|
|
217
|
+
# Copy
|
|
185
218
|
("c", "copy_cell", "Copy cell to clipboard"),
|
|
186
219
|
("ctrl+c", "copy_column", "Copy column to clipboard"),
|
|
187
220
|
("ctrl+r", "copy_row", "Copy row to clipboard"),
|
|
221
|
+
# Save
|
|
188
222
|
("ctrl+s", "save_to_file", "Save to file"),
|
|
223
|
+
# Detail, Frequency, and Statistics
|
|
189
224
|
("enter", "view_row_detail", "View row details"),
|
|
190
|
-
# Frequency & Statistics
|
|
191
225
|
("F", "show_frequency", "Show frequency"),
|
|
192
226
|
("s", "show_statistics", "Show statistics for column"),
|
|
193
227
|
("S", "show_statistics('dataframe')", "Show statistics for dataframe"),
|
|
194
|
-
#
|
|
228
|
+
# Sort
|
|
195
229
|
("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
|
|
196
230
|
("right_square_bracket", "sort_descending", "Sort descending"), # `]`
|
|
197
|
-
# View
|
|
231
|
+
# View & Filter
|
|
198
232
|
("v", "view_rows", "View rows"),
|
|
199
233
|
("V", "view_rows_expr", "View rows by expression"),
|
|
234
|
+
("quotation_mark", "filter_rows", "Filter selected"), # `"`
|
|
200
235
|
# Search
|
|
201
236
|
("backslash", "search_cursor_value", "Search column with cursor value"), # `\`
|
|
202
237
|
("vertical_line", "search_expr", "Search column with expression"), # `|`
|
|
@@ -205,26 +240,30 @@ class DataFrameTable(DataTable):
|
|
|
205
240
|
# Find
|
|
206
241
|
("slash", "find_cursor_value", "Find in column with cursor value"), # `/`
|
|
207
242
|
("question_mark", "find_expr", "Find in column with expression"), # `?`
|
|
208
|
-
("
|
|
209
|
-
("
|
|
243
|
+
("semicolon", "find_cursor_value('global')", "Global find with cursor value"), # `;`
|
|
244
|
+
("colon", "find_expr('global')", "Global find with expression"), # `:`
|
|
210
245
|
("n", "next_match", "Go to next match"), # `n`
|
|
211
246
|
("N", "previous_match", "Go to previous match"), # `Shift+n`
|
|
212
247
|
# Replace
|
|
213
248
|
("r", "replace", "Replace in column"), # `r`
|
|
214
249
|
("R", "replace_global", "Replace global"), # `Shift+R`
|
|
215
250
|
# Selection
|
|
216
|
-
("apostrophe", "
|
|
251
|
+
("apostrophe", "toggle_row_selection", "Toggle row selection"), # `'`
|
|
217
252
|
("t", "toggle_selections", "Toggle all row selections"),
|
|
218
|
-
("T", "
|
|
219
|
-
|
|
220
|
-
|
|
253
|
+
("T", "clear_selections_and_matches", "Clear selections"),
|
|
254
|
+
# Delete
|
|
255
|
+
("delete", "clear_cell", "Clear cell"),
|
|
221
256
|
("minus", "delete_column", "Delete column"), # `-`
|
|
222
257
|
("x", "delete_row", "Delete row"),
|
|
223
|
-
("X", "
|
|
258
|
+
("X", "delete_row_and_below", "Delete row and those below"),
|
|
259
|
+
("ctrl+x", "delete_row_and_up", "Delete row and those up"),
|
|
260
|
+
# Duplicate
|
|
224
261
|
("d", "duplicate_column", "Duplicate column"),
|
|
225
262
|
("D", "duplicate_row", "Duplicate row"),
|
|
263
|
+
# Edit
|
|
226
264
|
("e", "edit_cell", "Edit cell"),
|
|
227
265
|
("E", "edit_column", "Edit column"),
|
|
266
|
+
# Add
|
|
228
267
|
("a", "add_column", "Add column"),
|
|
229
268
|
("A", "add_column_expr", "Add column with expression"),
|
|
230
269
|
# Reorder
|
|
@@ -233,30 +272,29 @@ class DataFrameTable(DataTable):
|
|
|
233
272
|
("shift+up", "move_row_up", "Move row up"),
|
|
234
273
|
("shift+down", "move_row_down", "Move row down"),
|
|
235
274
|
# Type Conversion
|
|
236
|
-
("number_sign", "cast_column_dtype('
|
|
237
|
-
("percent_sign", "cast_column_dtype('
|
|
238
|
-
("exclamation_mark", "cast_column_dtype('
|
|
239
|
-
("dollar_sign", "cast_column_dtype('
|
|
275
|
+
("number_sign", "cast_column_dtype('pl.Int64')", "Cast column dtype to integer"), # `#`
|
|
276
|
+
("percent_sign", "cast_column_dtype('pl.Float64')", "Cast column dtype to float"), # `%`
|
|
277
|
+
("exclamation_mark", "cast_column_dtype('pl.Boolean')", "Cast column dtype to bool"), # `!`
|
|
278
|
+
("dollar_sign", "cast_column_dtype('pl.String')", "Cast column dtype to string"), # `$`
|
|
240
279
|
("at", "make_cell_clickable", "Make cell clickable"), # `@`
|
|
241
|
-
#
|
|
242
|
-
("
|
|
243
|
-
("
|
|
244
|
-
("z", "freeze_row_column", "Freeze rows/columns"),
|
|
245
|
-
("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
|
|
280
|
+
# Sql
|
|
281
|
+
("l", "simple_sql", "Simple SQL interface"),
|
|
282
|
+
("L", "advanced_sql", "Advanced SQL interface"),
|
|
246
283
|
# Undo/Redo
|
|
247
284
|
("u", "undo", "Undo"),
|
|
248
|
-
("U", "
|
|
285
|
+
("U", "redo", "Redo"),
|
|
286
|
+
("ctrl+u", "reset", "Reset to initial state"),
|
|
249
287
|
]
|
|
250
288
|
# fmt: on
|
|
251
289
|
|
|
252
|
-
def __init__(self, df: pl.DataFrame
|
|
290
|
+
def __init__(self, df: pl.DataFrame, filename: str = "", name: str = "", **kwargs) -> None:
|
|
253
291
|
"""Initialize the DataFrameTable with a dataframe and manage all state.
|
|
254
292
|
|
|
255
293
|
Sets up the table widget with display configuration, loads the dataframe, and
|
|
256
294
|
initializes all state tracking variables for row/column operations.
|
|
257
295
|
|
|
258
296
|
Args:
|
|
259
|
-
df: The Polars DataFrame
|
|
297
|
+
df: The Polars DataFrame to display and edit.
|
|
260
298
|
filename: Optional source filename for the data (used in save operations). Defaults to "".
|
|
261
299
|
name: Optional display name for the table tab. Defaults to "" (uses filename stem).
|
|
262
300
|
**kwargs: Additional keyword arguments passed to the parent DataTable widget.
|
|
@@ -267,8 +305,8 @@ class DataFrameTable(DataTable):
|
|
|
267
305
|
super().__init__(name=(name or Path(filename).stem), **kwargs)
|
|
268
306
|
|
|
269
307
|
# DataFrame state
|
|
270
|
-
self.
|
|
271
|
-
self.df =
|
|
308
|
+
self.dataframe = df # Original dataframe
|
|
309
|
+
self.df = df # Internal/working dataframe
|
|
272
310
|
self.filename = filename # Current filename
|
|
273
311
|
|
|
274
312
|
# Pagination & Loading
|
|
@@ -287,8 +325,10 @@ class DataFrameTable(DataTable):
|
|
|
287
325
|
self.fixed_rows = 0 # Number of fixed rows
|
|
288
326
|
self.fixed_columns = 0 # Number of fixed columns
|
|
289
327
|
|
|
290
|
-
# History stack for undo
|
|
328
|
+
# History stack for undo
|
|
291
329
|
self.histories: deque[History] = deque()
|
|
330
|
+
# Current history state for redo
|
|
331
|
+
self.history: History = None
|
|
292
332
|
|
|
293
333
|
# Pending filename for save operations
|
|
294
334
|
self._pending_filename = ""
|
|
@@ -391,17 +431,27 @@ class DataFrameTable(DataTable):
|
|
|
391
431
|
matches.append((ridx, cidx))
|
|
392
432
|
return matches
|
|
393
433
|
|
|
394
|
-
def
|
|
395
|
-
"""
|
|
434
|
+
def get_row_key(self, row_idx: int) -> RowKey:
|
|
435
|
+
"""Get the row key for a given table row index.
|
|
396
436
|
|
|
397
|
-
|
|
398
|
-
|
|
437
|
+
Args:
|
|
438
|
+
row_idx: Row index in the table display.
|
|
399
439
|
|
|
400
440
|
Returns:
|
|
401
|
-
|
|
441
|
+
Corresponding row key as string.
|
|
402
442
|
"""
|
|
403
|
-
|
|
404
|
-
|
|
443
|
+
return self._row_locations.get_key(row_idx)
|
|
444
|
+
|
|
445
|
+
def get_column_key(self, col_idx: int) -> ColumnKey:
|
|
446
|
+
"""Get the column key for a given table column index.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
col_idx: Column index in the table display.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Corresponding column key as string.
|
|
453
|
+
"""
|
|
454
|
+
return self._column_locations.get_key(col_idx)
|
|
405
455
|
|
|
406
456
|
def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
|
|
407
457
|
"""Determine if the given cell should be highlighted because of the cursor.
|
|
@@ -475,10 +525,10 @@ class DataFrameTable(DataTable):
|
|
|
475
525
|
self.refresh_row(new_row)
|
|
476
526
|
elif self.cursor_type == "row":
|
|
477
527
|
self.refresh_row(old_coordinate.row)
|
|
478
|
-
self.
|
|
528
|
+
self._highlight_row(new_coordinate.row)
|
|
479
529
|
elif self.cursor_type == "column":
|
|
480
530
|
self.refresh_column(old_coordinate.column)
|
|
481
|
-
self.
|
|
531
|
+
self._highlight_column(new_coordinate.column)
|
|
482
532
|
|
|
483
533
|
# Handle scrolling if needed
|
|
484
534
|
if self._require_update_dimensions:
|
|
@@ -486,6 +536,34 @@ class DataFrameTable(DataTable):
|
|
|
486
536
|
else:
|
|
487
537
|
self._scroll_cursor_into_view()
|
|
488
538
|
|
|
539
|
+
def move_cursor_to(self, ridx: int, cidx: int) -> None:
|
|
540
|
+
"""Move cursor based on the dataframe indices.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
ridx: Row index (0-based) in the dataframe.
|
|
544
|
+
cidx: Column index (0-based) in the dataframe.
|
|
545
|
+
"""
|
|
546
|
+
# Ensure the target row is loaded
|
|
547
|
+
if ridx >= self.loaded_rows:
|
|
548
|
+
self._load_rows(stop=ridx + self.BATCH_SIZE)
|
|
549
|
+
|
|
550
|
+
row_key = str(ridx)
|
|
551
|
+
col_key = self.df.columns[cidx]
|
|
552
|
+
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
553
|
+
self.move_cursor(row=row_idx, column=col_idx)
|
|
554
|
+
|
|
555
|
+
def on_mount(self) -> None:
|
|
556
|
+
"""Initialize table display when the widget is mounted.
|
|
557
|
+
|
|
558
|
+
Called by Textual when the widget is first added to the display tree.
|
|
559
|
+
Currently a placeholder as table setup is deferred until first use.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
None
|
|
563
|
+
"""
|
|
564
|
+
# self._setup_table()
|
|
565
|
+
pass
|
|
566
|
+
|
|
489
567
|
def on_key(self, event) -> None:
|
|
490
568
|
"""Handle key press events for pagination.
|
|
491
569
|
|
|
@@ -533,8 +611,16 @@ class DataFrameTable(DataTable):
|
|
|
533
611
|
|
|
534
612
|
def action_jump_bottom(self) -> None:
|
|
535
613
|
"""Jump to the bottom of the table."""
|
|
536
|
-
self._load_rows()
|
|
537
|
-
|
|
614
|
+
self._load_rows(move_to_end=True)
|
|
615
|
+
|
|
616
|
+
def action_forward_page(self) -> None:
|
|
617
|
+
"""Scroll down one page."""
|
|
618
|
+
super().action_page_down()
|
|
619
|
+
self._check_and_load_more()
|
|
620
|
+
|
|
621
|
+
def action_backward_page(self) -> None:
|
|
622
|
+
"""Scroll up one page."""
|
|
623
|
+
super().action_page_up()
|
|
538
624
|
|
|
539
625
|
def action_view_row_detail(self) -> None:
|
|
540
626
|
"""View details of the current row."""
|
|
@@ -548,9 +634,13 @@ class DataFrameTable(DataTable):
|
|
|
548
634
|
"""Hide the current column."""
|
|
549
635
|
self._hide_column()
|
|
550
636
|
|
|
551
|
-
def
|
|
552
|
-
"""
|
|
553
|
-
self.
|
|
637
|
+
def action_expand_column(self) -> None:
|
|
638
|
+
"""Expand the current column to its full width."""
|
|
639
|
+
self._expand_column()
|
|
640
|
+
|
|
641
|
+
def action_show_hidden_rows_columns(self) -> None:
|
|
642
|
+
"""Show all hidden rows/columns."""
|
|
643
|
+
self._show_hidden_rows_columns()
|
|
554
644
|
|
|
555
645
|
def action_sort_ascending(self) -> None:
|
|
556
646
|
"""Sort by current column in ascending order."""
|
|
@@ -640,22 +730,30 @@ class DataFrameTable(DataTable):
|
|
|
640
730
|
"""Replace values across all columns."""
|
|
641
731
|
self._replace_global()
|
|
642
732
|
|
|
643
|
-
def
|
|
733
|
+
def action_toggle_row_selection(self) -> None:
|
|
644
734
|
"""Toggle selection for the current row."""
|
|
645
|
-
self.
|
|
735
|
+
self._toggle_row_selection()
|
|
646
736
|
|
|
647
737
|
def action_toggle_selections(self) -> None:
|
|
648
738
|
"""Toggle all row selections."""
|
|
649
739
|
self._toggle_selections()
|
|
650
740
|
|
|
651
|
-
def
|
|
741
|
+
def action_filter_rows(self) -> None:
|
|
652
742
|
"""Filter to show only selected rows."""
|
|
653
|
-
self.
|
|
743
|
+
self._filter_rows()
|
|
654
744
|
|
|
655
745
|
def action_delete_row(self) -> None:
|
|
656
746
|
"""Delete the current row."""
|
|
657
747
|
self._delete_row()
|
|
658
748
|
|
|
749
|
+
def action_delete_row_and_below(self) -> None:
|
|
750
|
+
"""Delete the current row and those below."""
|
|
751
|
+
self._delete_row(more="below")
|
|
752
|
+
|
|
753
|
+
def action_delete_row_and_up(self) -> None:
|
|
754
|
+
"""Delete the current row and those above."""
|
|
755
|
+
self._delete_row(more="above")
|
|
756
|
+
|
|
659
757
|
def action_duplicate_column(self) -> None:
|
|
660
758
|
"""Duplicate the current column."""
|
|
661
759
|
self._duplicate_column()
|
|
@@ -668,10 +766,14 @@ class DataFrameTable(DataTable):
|
|
|
668
766
|
"""Undo the last action."""
|
|
669
767
|
self._undo()
|
|
670
768
|
|
|
769
|
+
def action_redo(self) -> None:
|
|
770
|
+
"""Redo the last undone action."""
|
|
771
|
+
self._redo()
|
|
772
|
+
|
|
671
773
|
def action_reset(self) -> None:
|
|
672
|
-
"""Reset to the
|
|
774
|
+
"""Reset to the initial state."""
|
|
673
775
|
self._setup_table(reset=True)
|
|
674
|
-
self.notify("Restored
|
|
776
|
+
self.notify("Restored initial state", title="Reset")
|
|
675
777
|
|
|
676
778
|
def action_move_column_left(self) -> None:
|
|
677
779
|
"""Move the current column to the left."""
|
|
@@ -689,9 +791,9 @@ class DataFrameTable(DataTable):
|
|
|
689
791
|
"""Move the current row down."""
|
|
690
792
|
self._move_row("down")
|
|
691
793
|
|
|
692
|
-
def
|
|
693
|
-
"""Clear all row selections."""
|
|
694
|
-
self.
|
|
794
|
+
def action_clear_selections_and_matches(self) -> None:
|
|
795
|
+
"""Clear all row selections and matches."""
|
|
796
|
+
self._clear_selections_and_matches()
|
|
695
797
|
|
|
696
798
|
def action_cycle_cursor_type(self) -> None:
|
|
697
799
|
"""Cycle through cursor types."""
|
|
@@ -781,40 +883,13 @@ class DataFrameTable(DataTable):
|
|
|
781
883
|
"""Go to the previous selected row."""
|
|
782
884
|
self._previous_selected_row()
|
|
783
885
|
|
|
784
|
-
def
|
|
785
|
-
"""
|
|
886
|
+
def action_simple_sql(self) -> None:
|
|
887
|
+
"""Open the SQL interface screen."""
|
|
888
|
+
self._simple_sql()
|
|
786
889
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
Returns:
|
|
792
|
-
None
|
|
793
|
-
"""
|
|
794
|
-
cidx = self.cursor_col_idx
|
|
795
|
-
col_key = self.cursor_col_key
|
|
796
|
-
dtype = self.df.dtypes[cidx]
|
|
797
|
-
|
|
798
|
-
# Only process string columns
|
|
799
|
-
if dtype != pl.String:
|
|
800
|
-
return
|
|
801
|
-
|
|
802
|
-
# Count how many URLs were made clickable
|
|
803
|
-
url_count = 0
|
|
804
|
-
|
|
805
|
-
# Iterate through all loaded rows and make URLs clickable
|
|
806
|
-
for row in self.ordered_rows:
|
|
807
|
-
cell_text: Text = self.get_cell(row.key, col_key)
|
|
808
|
-
if cell_text.plain.startswith(("http://", "https://")):
|
|
809
|
-
cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
|
|
810
|
-
self.update_cell(row.key, col_key, cell_text)
|
|
811
|
-
url_count += 1
|
|
812
|
-
|
|
813
|
-
if url_count:
|
|
814
|
-
self.notify(
|
|
815
|
-
f"Made [$accent]{url_count}[/] cell(s) clickable in column [$success]{col_key.value}[/]",
|
|
816
|
-
title="Make Clickable",
|
|
817
|
-
)
|
|
890
|
+
def action_advanced_sql(self) -> None:
|
|
891
|
+
"""Open the advanced SQL interface screen."""
|
|
892
|
+
self._advanced_sql()
|
|
818
893
|
|
|
819
894
|
def on_mouse_scroll_down(self, event) -> None:
|
|
820
895
|
"""Load more rows when scrolling down with mouse."""
|
|
@@ -827,9 +902,12 @@ class DataFrameTable(DataTable):
|
|
|
827
902
|
Row keys are 0-based indices, which map directly to dataframe row indices.
|
|
828
903
|
Column keys are header names from the dataframe.
|
|
829
904
|
"""
|
|
905
|
+
self.loaded_rows = 0
|
|
906
|
+
self.show_row_labels = True
|
|
907
|
+
|
|
830
908
|
# Reset to original dataframe
|
|
831
909
|
if reset:
|
|
832
|
-
self.df = self.
|
|
910
|
+
self.df = self.dataframe
|
|
833
911
|
self.loaded_rows = 0
|
|
834
912
|
self.sorted_columns = {}
|
|
835
913
|
self.hidden_columns = set()
|
|
@@ -840,35 +918,109 @@ class DataFrameTable(DataTable):
|
|
|
840
918
|
self.matches = defaultdict(set)
|
|
841
919
|
|
|
842
920
|
# Lazy load up to INITIAL_BATCH_SIZE visible rows
|
|
843
|
-
stop, visible_count =
|
|
921
|
+
stop, visible_count = self.INITIAL_BATCH_SIZE, 0
|
|
844
922
|
for row_idx, visible in enumerate(self.visible_rows):
|
|
845
923
|
if not visible:
|
|
846
924
|
continue
|
|
847
925
|
visible_count += 1
|
|
848
|
-
if visible_count
|
|
849
|
-
stop = row_idx +
|
|
926
|
+
if visible_count > self.INITIAL_BATCH_SIZE:
|
|
927
|
+
stop = row_idx + self.BATCH_SIZE
|
|
850
928
|
break
|
|
929
|
+
else:
|
|
930
|
+
stop = row_idx + self.BATCH_SIZE
|
|
931
|
+
|
|
932
|
+
# # Ensure all selected rows or matches are loaded
|
|
933
|
+
# stop = max(stop, rindex(self.selected_rows, True) + 1)
|
|
934
|
+
# stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
851
935
|
|
|
852
936
|
# Save current cursor position before clearing
|
|
853
937
|
row_idx, col_idx = self.cursor_coordinate
|
|
854
938
|
|
|
855
939
|
self._setup_columns()
|
|
856
940
|
self._load_rows(stop)
|
|
857
|
-
self._do_highlight()
|
|
858
941
|
|
|
859
942
|
# Restore cursor position
|
|
860
943
|
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
861
944
|
self.move_cursor(row=row_idx, column=col_idx)
|
|
862
945
|
|
|
946
|
+
def _determine_column_widths(self) -> dict[str, int]:
|
|
947
|
+
"""Determine optimal width for each column based on data type and content.
|
|
948
|
+
|
|
949
|
+
For String columns:
|
|
950
|
+
- Minimum width: length of column label
|
|
951
|
+
- Ideal width: maximum width of all cells in the column
|
|
952
|
+
- If space constrained: find appropriate width smaller than maximum
|
|
953
|
+
|
|
954
|
+
For non-String columns:
|
|
955
|
+
- Return None to let Textual auto-determine width
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
dict[str, int]: Mapping of column name to width (None for auto-sizing columns).
|
|
959
|
+
"""
|
|
960
|
+
column_widths = {}
|
|
961
|
+
|
|
962
|
+
# Get available width for the table (with some padding for borders/scrollbar)
|
|
963
|
+
available_width = self.size.width - 4 # Account for borders and scrollbar
|
|
964
|
+
|
|
965
|
+
# Calculate how much width we need for string columns first
|
|
966
|
+
string_cols = [col for col, dtype in zip(self.df.columns, self.df.dtypes) if dtype == pl.String]
|
|
967
|
+
|
|
968
|
+
# No string columns, let TextualDataTable auto-size all columns
|
|
969
|
+
if not string_cols:
|
|
970
|
+
return column_widths
|
|
971
|
+
|
|
972
|
+
# Sample a reasonable number of rows to calculate widths (don't scan entire dataframe)
|
|
973
|
+
sample_size = min(self.INITIAL_BATCH_SIZE, len(self.df))
|
|
974
|
+
sample_lf = self.df.lazy().slice(0, sample_size)
|
|
975
|
+
|
|
976
|
+
# Determine widths for each column
|
|
977
|
+
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
978
|
+
if col in self.hidden_columns:
|
|
979
|
+
continue
|
|
980
|
+
|
|
981
|
+
# Get column label width
|
|
982
|
+
# Add padding for sort indicators if any
|
|
983
|
+
label_width = measure(self.app.console, col, 1) + 2
|
|
984
|
+
|
|
985
|
+
try:
|
|
986
|
+
# Get sample values from the column
|
|
987
|
+
sample_values = sample_lf.select(col).collect().get_column(col).to_list()
|
|
988
|
+
|
|
989
|
+
# Find maximum width in sample
|
|
990
|
+
max_cell_width = max(
|
|
991
|
+
(measure(self.app.console, str(val), 1) for val in sample_values if val),
|
|
992
|
+
default=label_width,
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
# Set column width to max of label and sampled data (capped at reasonable max)
|
|
996
|
+
max_width = max(label_width, max_cell_width)
|
|
997
|
+
except Exception:
|
|
998
|
+
# If any error, let Textual auto-size
|
|
999
|
+
max_width = label_width
|
|
1000
|
+
|
|
1001
|
+
if dtype == pl.String:
|
|
1002
|
+
column_widths[col] = max_width
|
|
1003
|
+
|
|
1004
|
+
available_width -= max_width
|
|
1005
|
+
|
|
1006
|
+
# If there's no more available width, auto-size remaining columns
|
|
1007
|
+
if available_width < 0:
|
|
1008
|
+
for col in column_widths:
|
|
1009
|
+
if column_widths[col] > STRING_WIDTH_CAP:
|
|
1010
|
+
column_widths[col] = STRING_WIDTH_CAP # Cap string columns
|
|
1011
|
+
|
|
1012
|
+
return column_widths
|
|
1013
|
+
|
|
863
1014
|
def _setup_columns(self) -> None:
|
|
864
1015
|
"""Clear table and setup columns.
|
|
865
1016
|
|
|
866
1017
|
Column keys are header names from the dataframe.
|
|
867
1018
|
Column labels contain column names from the dataframe, with sort indicators if applicable.
|
|
868
1019
|
"""
|
|
869
|
-
self.loaded_rows = 0
|
|
870
1020
|
self.clear(columns=True)
|
|
871
|
-
|
|
1021
|
+
|
|
1022
|
+
# Get optimal column widths
|
|
1023
|
+
column_widths = self._determine_column_widths()
|
|
872
1024
|
|
|
873
1025
|
# Add columns with justified headers
|
|
874
1026
|
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
@@ -886,43 +1038,120 @@ class DataFrameTable(DataTable):
|
|
|
886
1038
|
else: # No break occurred, so column is not sorted
|
|
887
1039
|
cell_value = col
|
|
888
1040
|
|
|
889
|
-
|
|
1041
|
+
# Get the width for this column (None means auto-size)
|
|
1042
|
+
width = column_widths.get(col)
|
|
890
1043
|
|
|
891
|
-
|
|
892
|
-
"""Load a batch of rows into the table.
|
|
1044
|
+
self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
|
|
893
1045
|
|
|
894
|
-
|
|
895
|
-
|
|
1046
|
+
def _load_rows(self, stop: int | None = None, move_to_end: bool = False) -> None:
|
|
1047
|
+
"""Load a batch of rows into the table (synchronous wrapper).
|
|
896
1048
|
|
|
897
1049
|
Args:
|
|
898
|
-
stop: Stop loading rows when this index is reached.
|
|
1050
|
+
stop: Stop loading rows when this index is reached.
|
|
1051
|
+
If None, load until the end of the dataframe.
|
|
899
1052
|
"""
|
|
900
1053
|
if stop is None or stop > len(self.df):
|
|
901
1054
|
stop = len(self.df)
|
|
902
1055
|
|
|
1056
|
+
# If already loaded enough rows, just move cursor if needed
|
|
903
1057
|
if stop <= self.loaded_rows:
|
|
1058
|
+
if move_to_end:
|
|
1059
|
+
self.move_cursor(row=self.row_count - 1)
|
|
1060
|
+
|
|
904
1061
|
return
|
|
905
1062
|
|
|
906
|
-
|
|
907
|
-
|
|
1063
|
+
# Warn user if loading a large number of rows
|
|
1064
|
+
elif (nrows := stop - self.loaded_rows) >= WARN_ROWS_THRESHOLD:
|
|
908
1065
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
vals, dtypes = [], []
|
|
913
|
-
for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
|
|
914
|
-
if col in self.hidden_columns:
|
|
915
|
-
continue # Skip hidden columns
|
|
916
|
-
vals.append(val)
|
|
917
|
-
dtypes.append(dtype)
|
|
918
|
-
formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
|
|
919
|
-
# Always add labels so they can be shown/hidden via CSS
|
|
920
|
-
self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
|
|
1066
|
+
def _continue(result: bool) -> None:
|
|
1067
|
+
if result:
|
|
1068
|
+
self._load_rows_async(stop, move_to_end=move_to_end)
|
|
921
1069
|
|
|
922
|
-
|
|
923
|
-
|
|
1070
|
+
self.app.push_screen(
|
|
1071
|
+
ConfirmScreen(
|
|
1072
|
+
f"Load {nrows} Rows",
|
|
1073
|
+
label="Loading a large number of rows may cause the application to become unresponsive. Do you want to continue?",
|
|
1074
|
+
),
|
|
1075
|
+
callback=_continue,
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
return
|
|
1079
|
+
|
|
1080
|
+
# Load rows asynchronously
|
|
1081
|
+
self._load_rows_async(stop, move_to_end=move_to_end)
|
|
924
1082
|
|
|
925
|
-
|
|
1083
|
+
@work(exclusive=True, description="Loading rows...")
|
|
1084
|
+
async def _load_rows_async(self, stop: int, move_to_end: bool = False) -> None:
|
|
1085
|
+
"""Perform loading with async to avoid blocking.
|
|
1086
|
+
|
|
1087
|
+
Args:
|
|
1088
|
+
stop: Stop loading rows when this index is reached.
|
|
1089
|
+
move_to_end: If True, move cursor to the last loaded row after loading completes.
|
|
1090
|
+
"""
|
|
1091
|
+
# Load rows in smaller chunks to avoid blocking
|
|
1092
|
+
if stop > self.loaded_rows:
|
|
1093
|
+
self.log(f"Async loading up to row {self.loaded_rows = }, {stop = }")
|
|
1094
|
+
# Load incrementally to avoid one big block
|
|
1095
|
+
# Load max BATCH_SIZE rows at a time
|
|
1096
|
+
chunk_size = min(self.BATCH_SIZE, stop - self.loaded_rows)
|
|
1097
|
+
next_stop = min(self.loaded_rows + chunk_size, stop)
|
|
1098
|
+
self._load_rows_batch(next_stop)
|
|
1099
|
+
|
|
1100
|
+
# If there's more to load, yield to event loop with delay
|
|
1101
|
+
if next_stop < stop:
|
|
1102
|
+
await sleep_async(0.05) # 50ms delay to allow UI updates
|
|
1103
|
+
self._load_rows_async(stop, move_to_end=move_to_end)
|
|
1104
|
+
return
|
|
1105
|
+
|
|
1106
|
+
# After loading completes, move cursor to end if requested
|
|
1107
|
+
if move_to_end:
|
|
1108
|
+
self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
|
|
1109
|
+
|
|
1110
|
+
def _load_rows_batch(self, stop: int) -> None:
|
|
1111
|
+
"""Load a batch of rows into the table.
|
|
1112
|
+
|
|
1113
|
+
Row keys are 0-based indices as strings, which map directly to dataframe row indices.
|
|
1114
|
+
Row labels are 1-based indices as strings.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
stop: Stop loading rows when this index is reached.
|
|
1118
|
+
"""
|
|
1119
|
+
try:
|
|
1120
|
+
start = self.loaded_rows
|
|
1121
|
+
df_slice = self.df.slice(start, stop - start)
|
|
1122
|
+
|
|
1123
|
+
for ridx, row in enumerate(df_slice.rows(), start):
|
|
1124
|
+
if not self.visible_rows[ridx]:
|
|
1125
|
+
continue # Skip hidden rows
|
|
1126
|
+
|
|
1127
|
+
is_selected = self.selected_rows[ridx]
|
|
1128
|
+
match_cols = self.matches.get(ridx, set())
|
|
1129
|
+
|
|
1130
|
+
vals, dtypes, styles = [], [], []
|
|
1131
|
+
for cidx, (val, col, dtype) in enumerate(zip(row, self.df.columns, self.df.dtypes)):
|
|
1132
|
+
if col in self.hidden_columns:
|
|
1133
|
+
continue # Skip hidden columns
|
|
1134
|
+
|
|
1135
|
+
vals.append(val)
|
|
1136
|
+
dtypes.append(dtype)
|
|
1137
|
+
|
|
1138
|
+
# Highlight entire row with selection or cells with matches
|
|
1139
|
+
styles.append(HIGHLIGHT_COLOR if is_selected or cidx in match_cols else None)
|
|
1140
|
+
|
|
1141
|
+
formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
|
|
1142
|
+
|
|
1143
|
+
# Always add labels so they can be shown/hidden via CSS
|
|
1144
|
+
self.add_row(*formatted_row, key=str(ridx), label=str(ridx + 1))
|
|
1145
|
+
|
|
1146
|
+
# Update loaded rows count
|
|
1147
|
+
self.loaded_rows = stop
|
|
1148
|
+
|
|
1149
|
+
# self.notify(f"Loaded [$accent]{self.loaded_rows}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
|
|
1150
|
+
self.log(f"Loaded {self.loaded_rows}/{len(self.df)} rows from `{self.filename or self.name}`")
|
|
1151
|
+
|
|
1152
|
+
except Exception as e:
|
|
1153
|
+
self.notify("Error loading rows", title="Load", severity="error")
|
|
1154
|
+
self.log(f"Error loading rows: {str(e)}")
|
|
926
1155
|
|
|
927
1156
|
def _check_and_load_more(self) -> None:
|
|
928
1157
|
"""Check if we need to load more rows and load them."""
|
|
@@ -937,51 +1166,60 @@ class DataFrameTable(DataTable):
|
|
|
937
1166
|
if bottom_visible_row >= self.loaded_rows - 10:
|
|
938
1167
|
self._load_rows(self.loaded_rows + self.BATCH_SIZE)
|
|
939
1168
|
|
|
940
|
-
|
|
1169
|
+
# Highlighting
|
|
1170
|
+
def _do_highlight(self, force: bool = False) -> None:
|
|
941
1171
|
"""Update all rows, highlighting selected ones and restoring others to default.
|
|
942
1172
|
|
|
943
1173
|
Args:
|
|
944
|
-
|
|
1174
|
+
force: If True, clear all highlights and restore default styles.
|
|
945
1175
|
"""
|
|
946
|
-
if clear:
|
|
947
|
-
self.selected_rows = [False] * len(self.df)
|
|
948
|
-
self.matches = defaultdict(set)
|
|
949
|
-
|
|
950
1176
|
# Ensure all selected rows or matches are loaded
|
|
951
1177
|
stop = rindex(self.selected_rows, True) + 1
|
|
952
1178
|
stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
953
1179
|
|
|
954
1180
|
self._load_rows(stop)
|
|
955
|
-
self._highlight_table()
|
|
1181
|
+
self._highlight_table(force)
|
|
956
1182
|
|
|
957
|
-
def _highlight_table(self) -> None:
|
|
1183
|
+
def _highlight_table(self, force: bool = False) -> None:
|
|
958
1184
|
"""Highlight selected rows/cells in red."""
|
|
1185
|
+
if not force and not any(self.selected_rows) and not self.matches:
|
|
1186
|
+
return # Nothing to highlight
|
|
1187
|
+
|
|
959
1188
|
# Update all rows based on selected state
|
|
960
1189
|
for row in self.ordered_rows:
|
|
961
|
-
|
|
962
|
-
is_selected = self.selected_rows[
|
|
963
|
-
match_cols = self.matches.get(
|
|
1190
|
+
ridx = int(row.key.value) # 0-based index
|
|
1191
|
+
is_selected = self.selected_rows[ridx]
|
|
1192
|
+
match_cols = self.matches.get(ridx, set())
|
|
1193
|
+
|
|
1194
|
+
if not force and not is_selected and not match_cols:
|
|
1195
|
+
continue # No highlight needed for this row
|
|
964
1196
|
|
|
965
1197
|
# Update all cells in this row
|
|
966
1198
|
for col_idx, col in enumerate(self.ordered_columns):
|
|
967
|
-
|
|
1199
|
+
if not force and not is_selected and col_idx not in match_cols:
|
|
1200
|
+
continue # No highlight needed for this cell
|
|
968
1201
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1202
|
+
cell_text: Text = self.get_cell(row.key, col.key)
|
|
1203
|
+
need_update = False
|
|
1204
|
+
|
|
1205
|
+
if is_selected or col_idx in match_cols:
|
|
1206
|
+
cell_text.style = HIGHLIGHT_COLOR
|
|
1207
|
+
need_update = True
|
|
1208
|
+
elif force:
|
|
1209
|
+
# Restore original style based on dtype
|
|
1210
|
+
dtype = self.df.schema[col.key.value]
|
|
1211
|
+
dc = DtypeConfig(dtype)
|
|
1212
|
+
cell_text.style = dc.style
|
|
1213
|
+
need_update = True
|
|
973
1214
|
|
|
974
1215
|
# Update the cell in the table
|
|
975
|
-
|
|
1216
|
+
if need_update:
|
|
1217
|
+
self.update_cell(row.key, col.key, cell_text)
|
|
976
1218
|
|
|
977
1219
|
# History & Undo
|
|
978
|
-
def
|
|
979
|
-
"""
|
|
980
|
-
|
|
981
|
-
Args:
|
|
982
|
-
description: Description of the action for this history entry.
|
|
983
|
-
"""
|
|
984
|
-
history = History(
|
|
1220
|
+
def _create_history(self, description: str) -> None:
|
|
1221
|
+
"""Create the initial history state."""
|
|
1222
|
+
return History(
|
|
985
1223
|
description=description,
|
|
986
1224
|
df=self.df,
|
|
987
1225
|
filename=self.filename,
|
|
@@ -995,16 +1233,12 @@ class DataFrameTable(DataTable):
|
|
|
995
1233
|
cursor_coordinate=self.cursor_coordinate,
|
|
996
1234
|
matches={k: v.copy() for k, v in self.matches.items()},
|
|
997
1235
|
)
|
|
998
|
-
self.histories.append(history)
|
|
999
1236
|
|
|
1000
|
-
def
|
|
1001
|
-
"""
|
|
1002
|
-
if
|
|
1003
|
-
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1237
|
+
def _apply_history(self, history: History) -> None:
|
|
1238
|
+
"""Apply the current history state to the table."""
|
|
1239
|
+
if history is None:
|
|
1004
1240
|
return
|
|
1005
1241
|
|
|
1006
|
-
history = self.histories.pop()
|
|
1007
|
-
|
|
1008
1242
|
# Restore state
|
|
1009
1243
|
self.df = history.df
|
|
1010
1244
|
self.filename = history.filename
|
|
@@ -1016,14 +1250,64 @@ class DataFrameTable(DataTable):
|
|
|
1016
1250
|
self.fixed_rows = history.fixed_rows
|
|
1017
1251
|
self.fixed_columns = history.fixed_columns
|
|
1018
1252
|
self.cursor_coordinate = history.cursor_coordinate
|
|
1019
|
-
self.matches = {k: v.copy() for k, v in history.matches.items()}
|
|
1253
|
+
self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
|
|
1020
1254
|
|
|
1021
|
-
# Recreate
|
|
1255
|
+
# Recreate table for display
|
|
1022
1256
|
self._setup_table()
|
|
1023
1257
|
|
|
1024
|
-
|
|
1258
|
+
def _add_history(self, description: str) -> None:
|
|
1259
|
+
"""Add the current state to the history stack.
|
|
1260
|
+
|
|
1261
|
+
Args:
|
|
1262
|
+
description: Description of the action for this history entry.
|
|
1263
|
+
"""
|
|
1264
|
+
history = self._create_history(description)
|
|
1265
|
+
self.histories.append(history)
|
|
1266
|
+
|
|
1267
|
+
def _undo(self) -> None:
|
|
1268
|
+
"""Undo the last action."""
|
|
1269
|
+
if not self.histories:
|
|
1270
|
+
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
# Pop the last history state for undo
|
|
1274
|
+
history = self.histories.pop()
|
|
1275
|
+
|
|
1276
|
+
# Save current state for redo
|
|
1277
|
+
self.history = self._create_history(history.description)
|
|
1278
|
+
|
|
1279
|
+
# Restore state
|
|
1280
|
+
self._apply_history(history)
|
|
1281
|
+
|
|
1282
|
+
self.notify(f"Reverted: {history.description}", title="Undo")
|
|
1283
|
+
|
|
1284
|
+
def _redo(self) -> None:
|
|
1285
|
+
"""Redo the last undone action."""
|
|
1286
|
+
if self.history is None:
|
|
1287
|
+
self.notify("No actions to redo", title="Redo", severity="warning")
|
|
1288
|
+
return
|
|
1289
|
+
|
|
1290
|
+
description = self.history.description
|
|
1291
|
+
|
|
1292
|
+
# Save current state for undo
|
|
1293
|
+
self._add_history(description)
|
|
1294
|
+
|
|
1295
|
+
# Restore state
|
|
1296
|
+
self._apply_history(self.history)
|
|
1297
|
+
|
|
1298
|
+
# Clear redo state
|
|
1299
|
+
self.history = None
|
|
1300
|
+
|
|
1301
|
+
self.notify(f"Reapplied: {description}", title="Redo")
|
|
1302
|
+
|
|
1303
|
+
# Display
|
|
1304
|
+
def _cycle_cursor_type(self) -> None:
|
|
1305
|
+
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
1306
|
+
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
1307
|
+
self.cursor_type = next_type
|
|
1308
|
+
|
|
1309
|
+
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
1025
1310
|
|
|
1026
|
-
# View
|
|
1027
1311
|
def _view_row_detail(self) -> None:
|
|
1028
1312
|
"""Open a modal screen to view the selected row's details."""
|
|
1029
1313
|
ridx = self.cursor_row_idx
|
|
@@ -1071,49 +1355,12 @@ class DataFrameTable(DataTable):
|
|
|
1071
1355
|
self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
|
|
1072
1356
|
|
|
1073
1357
|
# Apply the pin settings to the table
|
|
1074
|
-
if fixed_rows
|
|
1358
|
+
if fixed_rows >= 0:
|
|
1075
1359
|
self.fixed_rows = fixed_rows
|
|
1076
|
-
if fixed_columns
|
|
1360
|
+
if fixed_columns >= 0:
|
|
1077
1361
|
self.fixed_columns = fixed_columns
|
|
1078
1362
|
|
|
1079
|
-
self.notify(
|
|
1080
|
-
f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
|
|
1081
|
-
title="Pin",
|
|
1082
|
-
)
|
|
1083
|
-
|
|
1084
|
-
# Delete & Move
|
|
1085
|
-
def _delete_column(self) -> None:
|
|
1086
|
-
"""Remove the currently selected column from the table."""
|
|
1087
|
-
# Get the column to remove
|
|
1088
|
-
col_idx = self.cursor_column
|
|
1089
|
-
col_name = self.cursor_col_name
|
|
1090
|
-
col_key = self.cursor_col_key
|
|
1091
|
-
|
|
1092
|
-
# Add to history
|
|
1093
|
-
self._add_history(f"Removed column [$success]{col_name}[/]")
|
|
1094
|
-
|
|
1095
|
-
# Remove the column from the table display using the column name as key
|
|
1096
|
-
self.remove_column(col_key)
|
|
1097
|
-
|
|
1098
|
-
# Move cursor left if we deleted the last column
|
|
1099
|
-
if col_idx >= len(self.columns):
|
|
1100
|
-
self.move_cursor(column=len(self.columns) - 1)
|
|
1101
|
-
|
|
1102
|
-
# Remove from sorted columns if present
|
|
1103
|
-
if col_name in self.sorted_columns:
|
|
1104
|
-
del self.sorted_columns[col_name]
|
|
1105
|
-
|
|
1106
|
-
# Remove from matches
|
|
1107
|
-
for row_idx in list(self.matches.keys()):
|
|
1108
|
-
self.matches[row_idx].discard(col_idx)
|
|
1109
|
-
# Remove empty entries
|
|
1110
|
-
if not self.matches[row_idx]:
|
|
1111
|
-
del self.matches[row_idx]
|
|
1112
|
-
|
|
1113
|
-
# Remove from dataframe
|
|
1114
|
-
self.df = self.df.drop(col_name)
|
|
1115
|
-
|
|
1116
|
-
self.notify(f"Removed column [$success]{col_name}[/]", title="Delete")
|
|
1363
|
+
# self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
|
|
1117
1364
|
|
|
1118
1365
|
def _hide_column(self) -> None:
|
|
1119
1366
|
"""Hide the currently selected column from the table display."""
|
|
@@ -1136,28 +1383,168 @@ class DataFrameTable(DataTable):
|
|
|
1136
1383
|
|
|
1137
1384
|
# self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
|
|
1138
1385
|
|
|
1139
|
-
def
|
|
1140
|
-
"""
|
|
1386
|
+
def _expand_column(self) -> None:
|
|
1387
|
+
"""Expand the current column to show the widest cell in the loaded data."""
|
|
1388
|
+
col_idx = self.cursor_col_idx
|
|
1389
|
+
col_key = self.cursor_col_key
|
|
1390
|
+
col_name = col_key.value
|
|
1391
|
+
dtype = self.df.dtypes[col_idx]
|
|
1392
|
+
|
|
1393
|
+
# Only expand string columns
|
|
1394
|
+
if dtype != pl.String:
|
|
1395
|
+
return
|
|
1396
|
+
|
|
1397
|
+
# Calculate the maximum width across all loaded rows
|
|
1398
|
+
max_width = len(col_name) + 2 # Start with column name width + padding
|
|
1399
|
+
|
|
1400
|
+
try:
|
|
1401
|
+
# Scan through all loaded rows that are visible to find max width
|
|
1402
|
+
for row_idx in range(self.loaded_rows):
|
|
1403
|
+
if not self.visible_rows[row_idx]:
|
|
1404
|
+
continue # Skip hidden rows
|
|
1405
|
+
cell_value = str(self.df.item(row_idx, col_idx))
|
|
1406
|
+
cell_width = measure(self.app.console, cell_value, 1)
|
|
1407
|
+
max_width = max(max_width, cell_width)
|
|
1408
|
+
|
|
1409
|
+
# Update the column width
|
|
1410
|
+
col = self.columns[col_key]
|
|
1411
|
+
col.width = max_width
|
|
1412
|
+
|
|
1413
|
+
# Force a refresh
|
|
1414
|
+
self._update_count += 1
|
|
1415
|
+
self._require_update_dimensions = True
|
|
1416
|
+
self.refresh(layout=True)
|
|
1417
|
+
|
|
1418
|
+
# self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
|
|
1419
|
+
except Exception as e:
|
|
1420
|
+
self.notify("Error expanding column", title="Expand", severity="error")
|
|
1421
|
+
self.log(f"Error expanding column `{col_name}`: {str(e)}")
|
|
1422
|
+
|
|
1423
|
+
def _show_hidden_rows_columns(self) -> None:
|
|
1424
|
+
"""Show all hidden rows/columns by recreating the table."""
|
|
1141
1425
|
# Get currently visible columns
|
|
1142
1426
|
visible_cols = set(col.key for col in self.ordered_columns)
|
|
1143
1427
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1428
|
+
hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
|
|
1429
|
+
hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
|
|
1146
1430
|
|
|
1147
|
-
if not
|
|
1148
|
-
self.notify("No hidden columns to show", title="
|
|
1431
|
+
if not hidden_row_count and not hidden_col_count:
|
|
1432
|
+
self.notify("No hidden columns or rows to show", title="Show", severity="warning")
|
|
1149
1433
|
return
|
|
1150
1434
|
|
|
1151
1435
|
# Add to history
|
|
1152
|
-
self._add_history(
|
|
1436
|
+
self._add_history("Showed hidden rows/columns")
|
|
1153
1437
|
|
|
1154
|
-
# Clear hidden columns tracking
|
|
1438
|
+
# Clear hidden rows/columns tracking
|
|
1439
|
+
self.visible_rows = [True] * len(self.df)
|
|
1155
1440
|
self.hidden_columns.clear()
|
|
1156
1441
|
|
|
1157
|
-
# Recreate table
|
|
1442
|
+
# Recreate table for display
|
|
1158
1443
|
self._setup_table()
|
|
1159
1444
|
|
|
1160
|
-
self.notify(
|
|
1445
|
+
self.notify(
|
|
1446
|
+
f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
|
|
1447
|
+
title="Show",
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
def _make_cell_clickable(self) -> None:
|
|
1451
|
+
"""Make cells with URLs in the current column clickable.
|
|
1452
|
+
|
|
1453
|
+
Scans all loaded rows in the current column for cells containing URLs
|
|
1454
|
+
(starting with 'http://' or 'https://') and applies Textual link styling
|
|
1455
|
+
to make them clickable. Does not modify the dataframe.
|
|
1456
|
+
|
|
1457
|
+
Returns:
|
|
1458
|
+
None
|
|
1459
|
+
"""
|
|
1460
|
+
cidx = self.cursor_col_idx
|
|
1461
|
+
col_key = self.cursor_col_key
|
|
1462
|
+
dtype = self.df.dtypes[cidx]
|
|
1463
|
+
|
|
1464
|
+
# Only process string columns
|
|
1465
|
+
if dtype != pl.String:
|
|
1466
|
+
return
|
|
1467
|
+
|
|
1468
|
+
# Count how many URLs were made clickable
|
|
1469
|
+
url_count = 0
|
|
1470
|
+
|
|
1471
|
+
# Iterate through all loaded rows and make URLs clickable
|
|
1472
|
+
for row in self.ordered_rows:
|
|
1473
|
+
cell_text: Text = self.get_cell(row.key, col_key)
|
|
1474
|
+
if cell_text.plain.startswith(("http://", "https://")):
|
|
1475
|
+
cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
|
|
1476
|
+
self.update_cell(row.key, col_key, cell_text)
|
|
1477
|
+
url_count += 1
|
|
1478
|
+
|
|
1479
|
+
if url_count:
|
|
1480
|
+
self.notify(
|
|
1481
|
+
f"Use Ctrl/Cmd click to open the links in column [$success]{col_key.value}[/]", title="Hyperlink"
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
# Delete & Move
|
|
1485
|
+
def _delete_column(self, more: str = None) -> None:
|
|
1486
|
+
"""Remove the currently selected column from the table."""
|
|
1487
|
+
# Get the column to remove
|
|
1488
|
+
col_idx = self.cursor_column
|
|
1489
|
+
col_name = self.cursor_col_name
|
|
1490
|
+
col_key = self.cursor_col_key
|
|
1491
|
+
|
|
1492
|
+
col_names_to_remove = []
|
|
1493
|
+
col_keys_to_remove = []
|
|
1494
|
+
|
|
1495
|
+
# Remove all columns before the current column
|
|
1496
|
+
if more == "before":
|
|
1497
|
+
for i in range(col_idx + 1):
|
|
1498
|
+
col_key = self.get_column_key(i)
|
|
1499
|
+
col_names_to_remove.append(col_key.value)
|
|
1500
|
+
col_keys_to_remove.append(col_key)
|
|
1501
|
+
|
|
1502
|
+
message = f"Removed column [$success]{col_name}[/] and all columns before"
|
|
1503
|
+
|
|
1504
|
+
# Remove all columns after the current column
|
|
1505
|
+
elif more == "after":
|
|
1506
|
+
for i in range(col_idx, len(self.columns)):
|
|
1507
|
+
col_key = self.get_column_key(i)
|
|
1508
|
+
col_names_to_remove.append(col_key.value)
|
|
1509
|
+
col_keys_to_remove.append(col_key)
|
|
1510
|
+
|
|
1511
|
+
message = f"Removed column [$success]{col_name}[/] and all columns after"
|
|
1512
|
+
|
|
1513
|
+
# Remove only the current column
|
|
1514
|
+
else:
|
|
1515
|
+
col_names_to_remove.append(col_name)
|
|
1516
|
+
col_keys_to_remove.append(col_key)
|
|
1517
|
+
message = f"Removed column [$success]{col_name}[/]"
|
|
1518
|
+
|
|
1519
|
+
# Add to history
|
|
1520
|
+
self._add_history(message)
|
|
1521
|
+
|
|
1522
|
+
# Remove the columns from the table display using the column names as keys
|
|
1523
|
+
for ck in col_keys_to_remove:
|
|
1524
|
+
self.remove_column(ck)
|
|
1525
|
+
|
|
1526
|
+
# Move cursor left if we deleted the last column(s)
|
|
1527
|
+
last_col_idx = len(self.columns) - 1
|
|
1528
|
+
if col_idx > last_col_idx:
|
|
1529
|
+
self.move_cursor(column=last_col_idx)
|
|
1530
|
+
|
|
1531
|
+
# Remove from sorted columns if present
|
|
1532
|
+
for col_name in col_names_to_remove:
|
|
1533
|
+
if col_name in self.sorted_columns:
|
|
1534
|
+
del self.sorted_columns[col_name]
|
|
1535
|
+
|
|
1536
|
+
# Remove from matches
|
|
1537
|
+
col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
|
|
1538
|
+
for row_idx in list(self.matches.keys()):
|
|
1539
|
+
self.matches[row_idx].difference_update(col_indices_to_remove)
|
|
1540
|
+
# Remove empty entries
|
|
1541
|
+
if not self.matches[row_idx]:
|
|
1542
|
+
del self.matches[row_idx]
|
|
1543
|
+
|
|
1544
|
+
# Remove from dataframe
|
|
1545
|
+
self.df = self.df.drop(col_names_to_remove)
|
|
1546
|
+
|
|
1547
|
+
self.notify(message, title="Delete")
|
|
1161
1548
|
|
|
1162
1549
|
def _duplicate_column(self) -> None:
|
|
1163
1550
|
"""Duplicate the currently selected column, inserting it right after the current column."""
|
|
@@ -1179,18 +1566,27 @@ class DataFrameTable(DataTable):
|
|
|
1179
1566
|
list(cols_before) + [new_col_name] + list(cols_after)
|
|
1180
1567
|
)
|
|
1181
1568
|
|
|
1182
|
-
#
|
|
1569
|
+
# Update matches to account for new column
|
|
1570
|
+
new_matches = defaultdict(set)
|
|
1571
|
+
for row_idx, cols in self.matches.items():
|
|
1572
|
+
new_cols = set()
|
|
1573
|
+
for col_idx_in_set in cols:
|
|
1574
|
+
if col_idx_in_set <= cidx:
|
|
1575
|
+
new_cols.add(col_idx_in_set)
|
|
1576
|
+
else:
|
|
1577
|
+
new_cols.add(col_idx_in_set + 1)
|
|
1578
|
+
new_matches[row_idx] = new_cols
|
|
1579
|
+
self.matches = new_matches
|
|
1580
|
+
|
|
1581
|
+
# Recreate table for display
|
|
1183
1582
|
self._setup_table()
|
|
1184
1583
|
|
|
1185
1584
|
# Move cursor to the new duplicated column
|
|
1186
1585
|
self.move_cursor(column=col_idx + 1)
|
|
1187
1586
|
|
|
1188
|
-
self.notify(
|
|
1189
|
-
f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
|
|
1190
|
-
title="Duplicate",
|
|
1191
|
-
)
|
|
1587
|
+
# self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
|
|
1192
1588
|
|
|
1193
|
-
def _delete_row(self) -> None:
|
|
1589
|
+
def _delete_row(self, more: str = None) -> None:
|
|
1194
1590
|
"""Delete rows from the table and dataframe.
|
|
1195
1591
|
|
|
1196
1592
|
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
@@ -1206,11 +1602,27 @@ class DataFrameTable(DataTable):
|
|
|
1206
1602
|
if selected:
|
|
1207
1603
|
predicates[ridx] = False
|
|
1208
1604
|
|
|
1605
|
+
# Delete current row and those above
|
|
1606
|
+
elif more == "above":
|
|
1607
|
+
ridx = self.cursor_row_idx
|
|
1608
|
+
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
|
|
1609
|
+
for i in range(ridx + 1):
|
|
1610
|
+
predicates[i] = False
|
|
1611
|
+
|
|
1612
|
+
# Delete current row and those below
|
|
1613
|
+
elif more == "below":
|
|
1614
|
+
ridx = self.cursor_row_idx
|
|
1615
|
+
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
|
|
1616
|
+
for i in range(ridx, len(self.df)):
|
|
1617
|
+
if self.visible_rows[i]:
|
|
1618
|
+
predicates[i] = False
|
|
1619
|
+
|
|
1209
1620
|
# Delete the row at the cursor
|
|
1210
1621
|
else:
|
|
1211
1622
|
ridx = self.cursor_row_idx
|
|
1212
1623
|
history_desc = f"Deleted row [$success]{ridx + 1}[/]"
|
|
1213
|
-
|
|
1624
|
+
if self.visible_rows[ridx]:
|
|
1625
|
+
predicates[ridx] = False
|
|
1214
1626
|
|
|
1215
1627
|
# Add to history
|
|
1216
1628
|
self._add_history(history_desc)
|
|
@@ -1233,12 +1645,12 @@ class DataFrameTable(DataTable):
|
|
|
1233
1645
|
# Clear all matches since row indices have changed
|
|
1234
1646
|
self.matches = defaultdict(set)
|
|
1235
1647
|
|
|
1236
|
-
# Recreate
|
|
1648
|
+
# Recreate table for display
|
|
1237
1649
|
self._setup_table()
|
|
1238
1650
|
|
|
1239
1651
|
deleted_count = old_count - len(self.df)
|
|
1240
|
-
if deleted_count >
|
|
1241
|
-
self.notify(f"Deleted {deleted_count} row(s)", title="Delete")
|
|
1652
|
+
if deleted_count > 0:
|
|
1653
|
+
self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
|
|
1242
1654
|
|
|
1243
1655
|
def _duplicate_row(self) -> None:
|
|
1244
1656
|
"""Duplicate the currently selected row, inserting it right after the current row."""
|
|
@@ -1263,10 +1675,16 @@ class DataFrameTable(DataTable):
|
|
|
1263
1675
|
self.selected_rows = new_selected_rows
|
|
1264
1676
|
self.visible_rows = new_visible_rows
|
|
1265
1677
|
|
|
1266
|
-
#
|
|
1267
|
-
|
|
1678
|
+
# Update matches to account for new row
|
|
1679
|
+
new_matches = defaultdict(set)
|
|
1680
|
+
for row_idx, cols in self.matches.items():
|
|
1681
|
+
if row_idx <= ridx:
|
|
1682
|
+
new_matches[row_idx] = cols
|
|
1683
|
+
else:
|
|
1684
|
+
new_matches[row_idx + 1] = cols
|
|
1685
|
+
self.matches = new_matches
|
|
1268
1686
|
|
|
1269
|
-
# Recreate
|
|
1687
|
+
# Recreate table for display
|
|
1270
1688
|
self._setup_table()
|
|
1271
1689
|
|
|
1272
1690
|
# Move cursor to the new duplicated row
|
|
@@ -1349,7 +1767,7 @@ class DataFrameTable(DataTable):
|
|
|
1349
1767
|
return
|
|
1350
1768
|
swap_idx = row_idx + 1
|
|
1351
1769
|
else:
|
|
1352
|
-
|
|
1770
|
+
# Invalid direction
|
|
1353
1771
|
return
|
|
1354
1772
|
|
|
1355
1773
|
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
@@ -1440,7 +1858,7 @@ class DataFrameTable(DataTable):
|
|
|
1440
1858
|
# Update the dataframe
|
|
1441
1859
|
self.df = df_sorted.drop(RIDX)
|
|
1442
1860
|
|
|
1443
|
-
# Recreate
|
|
1861
|
+
# Recreate table for display
|
|
1444
1862
|
self._setup_table()
|
|
1445
1863
|
|
|
1446
1864
|
# Restore cursor position on the sorted column
|
|
@@ -1453,7 +1871,7 @@ class DataFrameTable(DataTable):
|
|
|
1453
1871
|
cidx = self.cursor_col_idx if cidx is None else cidx
|
|
1454
1872
|
col_name = self.df.columns[cidx]
|
|
1455
1873
|
|
|
1456
|
-
#
|
|
1874
|
+
# Add to history
|
|
1457
1875
|
self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
|
|
1458
1876
|
|
|
1459
1877
|
# Push the edit modal screen
|
|
@@ -1499,9 +1917,10 @@ class DataFrameTable(DataTable):
|
|
|
1499
1917
|
col_key = col_name
|
|
1500
1918
|
self.update_cell(row_key, col_key, formatted_value, update_width=True)
|
|
1501
1919
|
|
|
1502
|
-
self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1920
|
+
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1503
1921
|
except Exception as e:
|
|
1504
|
-
self.notify(
|
|
1922
|
+
self.notify("Error updating cell", title="Edit", severity="error")
|
|
1923
|
+
self.log(f"Error updating cell: {str(e)}")
|
|
1505
1924
|
|
|
1506
1925
|
def _edit_column(self) -> None:
|
|
1507
1926
|
"""Open modal to edit the entire column with an expression."""
|
|
@@ -1528,9 +1947,10 @@ class DataFrameTable(DataTable):
|
|
|
1528
1947
|
# Check if term is a valid expression
|
|
1529
1948
|
elif tentative_expr(term):
|
|
1530
1949
|
try:
|
|
1531
|
-
expr = validate_expr(term, self.df, cidx)
|
|
1950
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
1532
1951
|
except Exception as e:
|
|
1533
|
-
self.notify(f"Error validating expression [$error]{term}[/]
|
|
1952
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Edit", severity="error")
|
|
1953
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1534
1954
|
return
|
|
1535
1955
|
|
|
1536
1956
|
# Otherwise, treat term as a literal value
|
|
@@ -1541,7 +1961,7 @@ class DataFrameTable(DataTable):
|
|
|
1541
1961
|
expr = pl.lit(value)
|
|
1542
1962
|
except Exception:
|
|
1543
1963
|
self.notify(
|
|
1544
|
-
f"
|
|
1964
|
+
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1545
1965
|
title="Edit",
|
|
1546
1966
|
severity="error",
|
|
1547
1967
|
)
|
|
@@ -1554,16 +1974,18 @@ class DataFrameTable(DataTable):
|
|
|
1554
1974
|
# Apply the expression to the column
|
|
1555
1975
|
self.df = self.df.with_columns(expr.alias(col_name))
|
|
1556
1976
|
except Exception as e:
|
|
1557
|
-
self.notify(
|
|
1977
|
+
self.notify(
|
|
1978
|
+
f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
|
|
1979
|
+
title="Edit",
|
|
1980
|
+
severity="error",
|
|
1981
|
+
)
|
|
1982
|
+
self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
|
|
1558
1983
|
return
|
|
1559
1984
|
|
|
1560
|
-
# Recreate
|
|
1985
|
+
# Recreate table for display
|
|
1561
1986
|
self._setup_table()
|
|
1562
1987
|
|
|
1563
|
-
self.notify(
|
|
1564
|
-
f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
|
|
1565
|
-
title="Edit",
|
|
1566
|
-
)
|
|
1988
|
+
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
|
|
1567
1989
|
|
|
1568
1990
|
def _rename_column(self) -> None:
|
|
1569
1991
|
"""Open modal to rename the selected column."""
|
|
@@ -1604,16 +2026,13 @@ class DataFrameTable(DataTable):
|
|
|
1604
2026
|
self.hidden_columns.remove(col_name)
|
|
1605
2027
|
self.hidden_columns.add(new_name)
|
|
1606
2028
|
|
|
1607
|
-
# Recreate
|
|
2029
|
+
# Recreate table for display
|
|
1608
2030
|
self._setup_table()
|
|
1609
2031
|
|
|
1610
2032
|
# Move cursor to the renamed column
|
|
1611
2033
|
self.move_cursor(column=col_idx)
|
|
1612
2034
|
|
|
1613
|
-
self.notify(
|
|
1614
|
-
f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
|
|
1615
|
-
title="Column",
|
|
1616
|
-
)
|
|
2035
|
+
# self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
|
|
1617
2036
|
|
|
1618
2037
|
def _clear_cell(self) -> None:
|
|
1619
2038
|
"""Clear the current cell by setting its value to None."""
|
|
@@ -1641,9 +2060,10 @@ class DataFrameTable(DataTable):
|
|
|
1641
2060
|
|
|
1642
2061
|
self.update_cell(row_key, col_key, formatted_value)
|
|
1643
2062
|
|
|
1644
|
-
self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
2063
|
+
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
1645
2064
|
except Exception as e:
|
|
1646
|
-
self.notify(
|
|
2065
|
+
self.notify("Error clearing cell", title="Clear", severity="error")
|
|
2066
|
+
self.log(f"Error clearing cell: {str(e)}")
|
|
1647
2067
|
raise e
|
|
1648
2068
|
|
|
1649
2069
|
def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
|
|
@@ -1680,15 +2100,16 @@ class DataFrameTable(DataTable):
|
|
|
1680
2100
|
select_cols = cols_before + [new_name] + cols_after
|
|
1681
2101
|
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
1682
2102
|
|
|
1683
|
-
# Recreate
|
|
2103
|
+
# Recreate table for display
|
|
1684
2104
|
self._setup_table()
|
|
1685
2105
|
|
|
1686
2106
|
# Move cursor to the new column
|
|
1687
2107
|
self.move_cursor(column=cidx + 1)
|
|
1688
2108
|
|
|
1689
|
-
self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
2109
|
+
# self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1690
2110
|
except Exception as e:
|
|
1691
|
-
self.notify(
|
|
2111
|
+
self.notify("Error adding column", title="Add Column", severity="error")
|
|
2112
|
+
self.log(f"Error adding column: {str(e)}")
|
|
1692
2113
|
raise e
|
|
1693
2114
|
|
|
1694
2115
|
def _add_column_expr(self) -> None:
|
|
@@ -1722,7 +2143,7 @@ class DataFrameTable(DataTable):
|
|
|
1722
2143
|
select_cols = cols_before + [col_name] + cols_after
|
|
1723
2144
|
self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
|
|
1724
2145
|
|
|
1725
|
-
# Recreate
|
|
2146
|
+
# Recreate table for display
|
|
1726
2147
|
self._setup_table()
|
|
1727
2148
|
|
|
1728
2149
|
# Move cursor to the new column
|
|
@@ -1730,53 +2151,33 @@ class DataFrameTable(DataTable):
|
|
|
1730
2151
|
|
|
1731
2152
|
# self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
|
|
1732
2153
|
except Exception as e:
|
|
1733
|
-
self.notify(
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
def _string_to_polars_dtype(self, dtype_str: str) -> pl.DataType:
|
|
1737
|
-
"""Convert string type name to Polars DataType.
|
|
1738
|
-
|
|
1739
|
-
Args:
|
|
1740
|
-
dtype_str: String representation of the type ("string", "int", "float", "bool")
|
|
1741
|
-
|
|
1742
|
-
Returns:
|
|
1743
|
-
Corresponding Polars DataType
|
|
1744
|
-
|
|
1745
|
-
Raises:
|
|
1746
|
-
ValueError: If the type string is not recognized
|
|
1747
|
-
"""
|
|
1748
|
-
dtype_map = {
|
|
1749
|
-
"string": pl.String,
|
|
1750
|
-
"int": pl.Int64,
|
|
1751
|
-
"float": pl.Float64,
|
|
1752
|
-
"bool": pl.Boolean,
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
dtype_lower = dtype_str.lower().strip()
|
|
1756
|
-
return dtype_map.get(dtype_lower)
|
|
2154
|
+
self.notify("Error adding column", title="Add Column", severity="error")
|
|
2155
|
+
self.log(f"Error adding column `{col_name}`: {str(e)}")
|
|
1757
2156
|
|
|
1758
|
-
|
|
2157
|
+
# Type Casting
|
|
2158
|
+
def _cast_column_dtype(self, dtype: str) -> None:
|
|
1759
2159
|
"""Cast the current column to a different data type.
|
|
1760
2160
|
|
|
1761
2161
|
Args:
|
|
1762
|
-
dtype: Target data type (string
|
|
2162
|
+
dtype: Target data type (string representation, e.g., "pl.String", "pl.Int64")
|
|
1763
2163
|
"""
|
|
1764
2164
|
cidx = self.cursor_col_idx
|
|
1765
2165
|
col_name = self.cursor_col_name
|
|
1766
2166
|
current_dtype = self.df.dtypes[cidx]
|
|
1767
2167
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
2168
|
+
try:
|
|
2169
|
+
target_dtype = eval(dtype)
|
|
2170
|
+
except Exception:
|
|
2171
|
+
self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
|
|
2172
|
+
return
|
|
2173
|
+
|
|
2174
|
+
if current_dtype == target_dtype:
|
|
2175
|
+
self.notify(
|
|
2176
|
+
f"Column [$accent]{col_name}[/] is already of type [$success]{target_dtype}[/]",
|
|
2177
|
+
title="Cast",
|
|
2178
|
+
severity="warning",
|
|
2179
|
+
)
|
|
2180
|
+
return # No change needed
|
|
1780
2181
|
|
|
1781
2182
|
# Add to history
|
|
1782
2183
|
self._add_history(
|
|
@@ -1787,17 +2188,19 @@ class DataFrameTable(DataTable):
|
|
|
1787
2188
|
# Cast the column using Polars
|
|
1788
2189
|
self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
|
|
1789
2190
|
|
|
1790
|
-
# Recreate
|
|
2191
|
+
# Recreate table for display
|
|
1791
2192
|
self._setup_table()
|
|
1792
2193
|
|
|
2194
|
+
self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
|
|
2195
|
+
except Exception as e:
|
|
1793
2196
|
self.notify(
|
|
1794
|
-
f"
|
|
2197
|
+
f"Error casting column [$accent]{col_name}[/] to [$error]{target_dtype}[/]",
|
|
1795
2198
|
title="Cast",
|
|
2199
|
+
severity="error",
|
|
1796
2200
|
)
|
|
1797
|
-
|
|
1798
|
-
self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
|
|
1799
|
-
raise e
|
|
2201
|
+
self.log(f"Error casting column `{col_name}`: {str(e)}")
|
|
1800
2202
|
|
|
2203
|
+
# Search
|
|
1801
2204
|
def _search_cursor_value(self) -> None:
|
|
1802
2205
|
"""Search with cursor value in current column."""
|
|
1803
2206
|
cidx = self.cursor_col_idx
|
|
@@ -1805,7 +2208,7 @@ class DataFrameTable(DataTable):
|
|
|
1805
2208
|
# Get the value of the currently selected cell
|
|
1806
2209
|
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1807
2210
|
|
|
1808
|
-
self._do_search((term, cidx, False,
|
|
2211
|
+
self._do_search((term, cidx, False, True))
|
|
1809
2212
|
|
|
1810
2213
|
def _search_expr(self) -> None:
|
|
1811
2214
|
"""Search by expression."""
|
|
@@ -1824,6 +2227,7 @@ class DataFrameTable(DataTable):
|
|
|
1824
2227
|
"""Search for a term."""
|
|
1825
2228
|
if result is None:
|
|
1826
2229
|
return
|
|
2230
|
+
|
|
1827
2231
|
term, cidx, match_nocase, match_whole = result
|
|
1828
2232
|
col_name = self.df.columns[cidx]
|
|
1829
2233
|
|
|
@@ -1833,13 +2237,10 @@ class DataFrameTable(DataTable):
|
|
|
1833
2237
|
# Support for polars expressions
|
|
1834
2238
|
elif tentative_expr(term):
|
|
1835
2239
|
try:
|
|
1836
|
-
expr = validate_expr(term, self.df, cidx)
|
|
2240
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
1837
2241
|
except Exception as e:
|
|
1838
|
-
self.notify(
|
|
1839
|
-
|
|
1840
|
-
title="Search",
|
|
1841
|
-
severity="error",
|
|
1842
|
-
)
|
|
2242
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Search", severity="error")
|
|
2243
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1843
2244
|
return
|
|
1844
2245
|
|
|
1845
2246
|
# Perform type-aware search based on column dtype
|
|
@@ -1862,7 +2263,7 @@ class DataFrameTable(DataTable):
|
|
|
1862
2263
|
term = f"(?i){term}"
|
|
1863
2264
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
1864
2265
|
self.notify(
|
|
1865
|
-
f"
|
|
2266
|
+
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1866
2267
|
title="Search",
|
|
1867
2268
|
severity="warning",
|
|
1868
2269
|
)
|
|
@@ -1876,17 +2277,14 @@ class DataFrameTable(DataTable):
|
|
|
1876
2277
|
try:
|
|
1877
2278
|
matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
|
|
1878
2279
|
except Exception as e:
|
|
1879
|
-
self.notify(
|
|
1880
|
-
|
|
1881
|
-
title="Search",
|
|
1882
|
-
severity="error",
|
|
1883
|
-
)
|
|
2280
|
+
self.notify(f"Error applying search filter [$error]{term}[/]", title="Search", severity="error")
|
|
2281
|
+
self.log(f"Error applying search filter `{term}`: {str(e)}")
|
|
1884
2282
|
return
|
|
1885
2283
|
|
|
1886
2284
|
match_count = len(matches)
|
|
1887
2285
|
if match_count == 0:
|
|
1888
2286
|
self.notify(
|
|
1889
|
-
f"No matches found for [$
|
|
2287
|
+
f"No matches found for [$accent]{term}[/]. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
1890
2288
|
title="Search",
|
|
1891
2289
|
severity="warning",
|
|
1892
2290
|
)
|
|
@@ -1899,11 +2297,13 @@ class DataFrameTable(DataTable):
|
|
|
1899
2297
|
for m in matches:
|
|
1900
2298
|
self.selected_rows[m] = True
|
|
1901
2299
|
|
|
1902
|
-
#
|
|
1903
|
-
self._do_highlight()
|
|
1904
|
-
|
|
2300
|
+
# Show notification immediately, then start highlighting
|
|
1905
2301
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
|
|
1906
2302
|
|
|
2303
|
+
# Recreate table for display
|
|
2304
|
+
self._setup_table()
|
|
2305
|
+
|
|
2306
|
+
# Find
|
|
1907
2307
|
def _find_matches(
|
|
1908
2308
|
self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
|
|
1909
2309
|
) -> dict[int, set[int]]:
|
|
@@ -1941,9 +2341,11 @@ class DataFrameTable(DataTable):
|
|
|
1941
2341
|
expr = pl.col(col_name).is_null()
|
|
1942
2342
|
elif tentative_expr(term):
|
|
1943
2343
|
try:
|
|
1944
|
-
expr = validate_expr(term, self.df, col_idx)
|
|
2344
|
+
expr = validate_expr(term, self.df.columns, col_idx)
|
|
1945
2345
|
except Exception as e:
|
|
1946
|
-
|
|
2346
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Find", severity="error")
|
|
2347
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2348
|
+
return matches
|
|
1947
2349
|
else:
|
|
1948
2350
|
if match_whole:
|
|
1949
2351
|
term = f"^{term}$"
|
|
@@ -1955,7 +2357,9 @@ class DataFrameTable(DataTable):
|
|
|
1955
2357
|
try:
|
|
1956
2358
|
matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
|
|
1957
2359
|
except Exception as e:
|
|
1958
|
-
|
|
2360
|
+
self.notify(f"Error applying filter: {expr}", title="Find", severity="error")
|
|
2361
|
+
self.log(f"Error applying filter: {str(e)}")
|
|
2362
|
+
return matches
|
|
1959
2363
|
|
|
1960
2364
|
for ridx in matched_ridxs:
|
|
1961
2365
|
matches[ridx].add(col_idx)
|
|
@@ -1973,9 +2377,9 @@ class DataFrameTable(DataTable):
|
|
|
1973
2377
|
|
|
1974
2378
|
if scope == "column":
|
|
1975
2379
|
cidx = self.cursor_col_idx
|
|
1976
|
-
self._do_find((term, cidx, False,
|
|
2380
|
+
self._do_find((term, cidx, False, True))
|
|
1977
2381
|
else:
|
|
1978
|
-
self._do_find_global((term, None, False,
|
|
2382
|
+
self._do_find_global((term, None, False, True))
|
|
1979
2383
|
|
|
1980
2384
|
def _find_expr(self, scope="column") -> None:
|
|
1981
2385
|
"""Open screen to find by expression.
|
|
@@ -2004,16 +2408,13 @@ class DataFrameTable(DataTable):
|
|
|
2004
2408
|
try:
|
|
2005
2409
|
matches = self._find_matches(term, cidx, match_nocase, match_whole)
|
|
2006
2410
|
except Exception as e:
|
|
2007
|
-
self.notify(
|
|
2008
|
-
|
|
2009
|
-
title="Find",
|
|
2010
|
-
severity="error",
|
|
2011
|
-
)
|
|
2411
|
+
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
2412
|
+
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
2012
2413
|
return
|
|
2013
2414
|
|
|
2014
2415
|
if not matches:
|
|
2015
2416
|
self.notify(
|
|
2016
|
-
f"No matches found for [$
|
|
2417
|
+
f"No matches found for [$accent]{term}[/] in current column. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
2017
2418
|
title="Find",
|
|
2018
2419
|
severity="warning",
|
|
2019
2420
|
)
|
|
@@ -2027,11 +2428,11 @@ class DataFrameTable(DataTable):
|
|
|
2027
2428
|
for ridx, col_idxs in matches.items():
|
|
2028
2429
|
self.matches[ridx].update(col_idxs)
|
|
2029
2430
|
|
|
2030
|
-
# Highlight matches
|
|
2031
|
-
self._do_highlight()
|
|
2032
|
-
|
|
2033
2431
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
|
|
2034
2432
|
|
|
2433
|
+
# Recreate table for display
|
|
2434
|
+
self._setup_table()
|
|
2435
|
+
|
|
2035
2436
|
def _do_find_global(self, result) -> None:
|
|
2036
2437
|
"""Global find a term across all columns."""
|
|
2037
2438
|
if result is None:
|
|
@@ -2041,16 +2442,13 @@ class DataFrameTable(DataTable):
|
|
|
2041
2442
|
try:
|
|
2042
2443
|
matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2043
2444
|
except Exception as e:
|
|
2044
|
-
self.notify(
|
|
2045
|
-
|
|
2046
|
-
title="Find",
|
|
2047
|
-
severity="error",
|
|
2048
|
-
)
|
|
2445
|
+
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
2446
|
+
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
2049
2447
|
return
|
|
2050
2448
|
|
|
2051
2449
|
if not matches:
|
|
2052
2450
|
self.notify(
|
|
2053
|
-
f"No matches found for [$
|
|
2451
|
+
f"No matches found for [$accent]{term}[/] in any column. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
2054
2452
|
title="Global Find",
|
|
2055
2453
|
severity="warning",
|
|
2056
2454
|
)
|
|
@@ -2064,25 +2462,12 @@ class DataFrameTable(DataTable):
|
|
|
2064
2462
|
for ridx, col_idxs in matches.items():
|
|
2065
2463
|
self.matches[ridx].update(col_idxs)
|
|
2066
2464
|
|
|
2067
|
-
# Highlight matches
|
|
2068
|
-
self._do_highlight()
|
|
2069
|
-
|
|
2070
2465
|
self.notify(
|
|
2071
|
-
f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
|
|
2072
|
-
title="Global Find",
|
|
2466
|
+
f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
|
|
2073
2467
|
)
|
|
2074
2468
|
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
Args:
|
|
2079
|
-
ridx: Row index (0-based) in the dataframe.
|
|
2080
|
-
cidx: Column index (0-based) in the dataframe.
|
|
2081
|
-
"""
|
|
2082
|
-
row_key = str(ridx)
|
|
2083
|
-
col_key = self.df.columns[cidx]
|
|
2084
|
-
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
2085
|
-
self.move_cursor(row=row_idx, column=col_idx)
|
|
2469
|
+
# Recreate table for display
|
|
2470
|
+
self._setup_table()
|
|
2086
2471
|
|
|
2087
2472
|
def _next_match(self) -> None:
|
|
2088
2473
|
"""Move cursor to the next match."""
|
|
@@ -2099,12 +2484,12 @@ class DataFrameTable(DataTable):
|
|
|
2099
2484
|
# Find the next match after current position
|
|
2100
2485
|
for ridx, cidx in ordered_matches:
|
|
2101
2486
|
if (ridx, cidx) > current_pos:
|
|
2102
|
-
self.
|
|
2487
|
+
self.move_cursor_to(ridx, cidx)
|
|
2103
2488
|
return
|
|
2104
2489
|
|
|
2105
2490
|
# If no next match, wrap around to the first match
|
|
2106
2491
|
first_ridx, first_cidx = ordered_matches[0]
|
|
2107
|
-
self.
|
|
2492
|
+
self.move_cursor_to(first_ridx, first_cidx)
|
|
2108
2493
|
|
|
2109
2494
|
def _previous_match(self) -> None:
|
|
2110
2495
|
"""Move cursor to the previous match."""
|
|
@@ -2149,12 +2534,12 @@ class DataFrameTable(DataTable):
|
|
|
2149
2534
|
# Find the next selected row after current position
|
|
2150
2535
|
for ridx in selected_row_indices:
|
|
2151
2536
|
if ridx > current_ridx:
|
|
2152
|
-
self.
|
|
2537
|
+
self.move_cursor_to(ridx, self.cursor_col_idx)
|
|
2153
2538
|
return
|
|
2154
2539
|
|
|
2155
2540
|
# If no next selected row, wrap around to the first selected row
|
|
2156
2541
|
first_ridx = selected_row_indices[0]
|
|
2157
|
-
self.
|
|
2542
|
+
self.move_cursor_to(first_ridx, self.cursor_col_idx)
|
|
2158
2543
|
|
|
2159
2544
|
def _previous_selected_row(self) -> None:
|
|
2160
2545
|
"""Move cursor to the previous selected row."""
|
|
@@ -2171,18 +2556,19 @@ class DataFrameTable(DataTable):
|
|
|
2171
2556
|
# Find the previous selected row before current position
|
|
2172
2557
|
for ridx in reversed(selected_row_indices):
|
|
2173
2558
|
if ridx < current_ridx:
|
|
2174
|
-
self.
|
|
2559
|
+
self.move_cursor_to(ridx, self.cursor_col_idx)
|
|
2175
2560
|
return
|
|
2176
2561
|
|
|
2177
2562
|
# If no previous selected row, wrap around to the last selected row
|
|
2178
2563
|
last_ridx = selected_row_indices[-1]
|
|
2179
|
-
self.
|
|
2564
|
+
self.move_cursor_to(last_ridx, self.cursor_col_idx)
|
|
2180
2565
|
|
|
2566
|
+
# Replace
|
|
2181
2567
|
def _replace(self) -> None:
|
|
2182
2568
|
"""Open replace screen for current column."""
|
|
2183
2569
|
# Push the replace modal screen
|
|
2184
2570
|
self.app.push_screen(
|
|
2185
|
-
FindReplaceScreen(self),
|
|
2571
|
+
FindReplaceScreen(self, title="Find and Replace in Current Column"),
|
|
2186
2572
|
callback=self._do_replace,
|
|
2187
2573
|
)
|
|
2188
2574
|
|
|
@@ -2194,7 +2580,7 @@ class DataFrameTable(DataTable):
|
|
|
2194
2580
|
"""Open replace screen for all columns."""
|
|
2195
2581
|
# Push the replace modal screen
|
|
2196
2582
|
self.app.push_screen(
|
|
2197
|
-
FindReplaceScreen(self),
|
|
2583
|
+
FindReplaceScreen(self, title="Global Find and Replace"),
|
|
2198
2584
|
callback=self._do_replace_global,
|
|
2199
2585
|
)
|
|
2200
2586
|
|
|
@@ -2222,23 +2608,19 @@ class DataFrameTable(DataTable):
|
|
|
2222
2608
|
matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
|
|
2223
2609
|
|
|
2224
2610
|
if not matches:
|
|
2225
|
-
self.notify(
|
|
2226
|
-
f"No matches found for [$warning]{term_find}[/]",
|
|
2227
|
-
title="Replace",
|
|
2228
|
-
severity="warning",
|
|
2229
|
-
)
|
|
2611
|
+
self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
|
|
2230
2612
|
return
|
|
2231
2613
|
|
|
2232
2614
|
# Add to history
|
|
2233
2615
|
self._add_history(
|
|
2234
|
-
f"
|
|
2616
|
+
f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
|
|
2235
2617
|
)
|
|
2236
2618
|
|
|
2237
2619
|
# Update matches
|
|
2238
|
-
self.matches = {ridx:
|
|
2620
|
+
self.matches = {ridx: col_idxs.copy() for ridx, col_idxs in matches.items()}
|
|
2239
2621
|
|
|
2240
|
-
#
|
|
2241
|
-
self.
|
|
2622
|
+
# Recreate table for display
|
|
2623
|
+
self._setup_table()
|
|
2242
2624
|
|
|
2243
2625
|
# Store state for interactive replacement using dataclass
|
|
2244
2626
|
self._replace_state = ReplaceState(
|
|
@@ -2268,10 +2650,11 @@ class DataFrameTable(DataTable):
|
|
|
2268
2650
|
|
|
2269
2651
|
except Exception as e:
|
|
2270
2652
|
self.notify(
|
|
2271
|
-
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]
|
|
2653
|
+
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
|
|
2272
2654
|
title="Replace",
|
|
2273
2655
|
severity="error",
|
|
2274
2656
|
)
|
|
2657
|
+
self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
|
|
2275
2658
|
|
|
2276
2659
|
def _do_replace_all(self, term_find: str, term_replace: str) -> None:
|
|
2277
2660
|
"""Replace all occurrences."""
|
|
@@ -2324,7 +2707,7 @@ class DataFrameTable(DataTable):
|
|
|
2324
2707
|
|
|
2325
2708
|
state.replaced_occurrence += 1
|
|
2326
2709
|
|
|
2327
|
-
# Recreate
|
|
2710
|
+
# Recreate table for display
|
|
2328
2711
|
self._setup_table()
|
|
2329
2712
|
|
|
2330
2713
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
@@ -2340,10 +2723,11 @@ class DataFrameTable(DataTable):
|
|
|
2340
2723
|
self._show_next_replace_confirmation()
|
|
2341
2724
|
except Exception as e:
|
|
2342
2725
|
self.notify(
|
|
2343
|
-
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]
|
|
2726
|
+
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
|
|
2344
2727
|
title="Replace",
|
|
2345
2728
|
severity="error",
|
|
2346
2729
|
)
|
|
2730
|
+
self.log(f"Error in interactive replace: {str(e)}")
|
|
2347
2731
|
|
|
2348
2732
|
def _show_next_replace_confirmation(self) -> None:
|
|
2349
2733
|
"""Show confirmation for next replacement."""
|
|
@@ -2360,12 +2744,12 @@ class DataFrameTable(DataTable):
|
|
|
2360
2744
|
# Move cursor to next match
|
|
2361
2745
|
ridx = state.rows[state.current_rpos]
|
|
2362
2746
|
cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
|
|
2363
|
-
self.
|
|
2747
|
+
self.move_cursor_to(ridx, cidx)
|
|
2364
2748
|
|
|
2365
2749
|
state.current_occurrence += 1
|
|
2366
2750
|
|
|
2367
2751
|
# Show confirmation
|
|
2368
|
-
label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] (
|
|
2752
|
+
label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] ({state.current_occurrence} of {state.total_occurrence})?"
|
|
2369
2753
|
|
|
2370
2754
|
self.app.push_screen(
|
|
2371
2755
|
ConfirmScreen("Replace", label=label, maybe="Skip"),
|
|
@@ -2430,15 +2814,16 @@ class DataFrameTable(DataTable):
|
|
|
2430
2814
|
if state.current_rpos >= len(state.rows):
|
|
2431
2815
|
state.done = True
|
|
2432
2816
|
|
|
2433
|
-
# Recreate
|
|
2817
|
+
# Recreate table for display
|
|
2434
2818
|
self._setup_table()
|
|
2435
2819
|
|
|
2436
2820
|
# Show next confirmation
|
|
2437
2821
|
self._show_next_replace_confirmation()
|
|
2438
2822
|
|
|
2823
|
+
# Selection & Match
|
|
2439
2824
|
def _toggle_selections(self) -> None:
|
|
2440
2825
|
"""Toggle selected rows highlighting on/off."""
|
|
2441
|
-
#
|
|
2826
|
+
# Add to history
|
|
2442
2827
|
self._add_history("Toggled row selection")
|
|
2443
2828
|
|
|
2444
2829
|
if False in self.visible_rows:
|
|
@@ -2454,37 +2839,37 @@ class DataFrameTable(DataTable):
|
|
|
2454
2839
|
|
|
2455
2840
|
# Check if we're highlighting or un-highlighting
|
|
2456
2841
|
if new_selected_count := self.selected_rows.count(True):
|
|
2457
|
-
self.notify(
|
|
2458
|
-
f"Toggled selection for [$accent]{new_selected_count}[/] rows",
|
|
2459
|
-
title="Toggle",
|
|
2460
|
-
)
|
|
2842
|
+
self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
|
|
2461
2843
|
|
|
2462
|
-
#
|
|
2463
|
-
self.
|
|
2844
|
+
# Recreate table for display
|
|
2845
|
+
self._setup_table()
|
|
2464
2846
|
|
|
2465
|
-
def
|
|
2466
|
-
"""
|
|
2467
|
-
#
|
|
2847
|
+
def _toggle_row_selection(self) -> None:
|
|
2848
|
+
"""Select/deselect current row."""
|
|
2849
|
+
# Add to history
|
|
2468
2850
|
self._add_history("Toggled row selection")
|
|
2469
2851
|
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
for ridx in self.matches.keys():
|
|
2473
|
-
self.selected_rows[ridx] = True
|
|
2474
|
-
else:
|
|
2475
|
-
# No matched cells - select/deselect the current row
|
|
2476
|
-
ridx = self.cursor_row_idx
|
|
2477
|
-
self.selected_rows[ridx] = not self.selected_rows[ridx]
|
|
2852
|
+
ridx = self.cursor_row_idx
|
|
2853
|
+
self.selected_rows[ridx] = not self.selected_rows[ridx]
|
|
2478
2854
|
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2855
|
+
row_key = str(ridx)
|
|
2856
|
+
match_cols = self.matches.get(ridx, set())
|
|
2857
|
+
for col_idx, col in enumerate(self.ordered_columns):
|
|
2858
|
+
col_key = col.key
|
|
2859
|
+
cell_text: Text = self.get_cell(row_key, col_key)
|
|
2482
2860
|
|
|
2483
|
-
|
|
2484
|
-
|
|
2861
|
+
if self.selected_rows[ridx] or (col_idx in match_cols):
|
|
2862
|
+
cell_text.style = HIGHLIGHT_COLOR
|
|
2863
|
+
else:
|
|
2864
|
+
# Reset to default style based on dtype
|
|
2865
|
+
dtype = self.df.dtypes[col_idx]
|
|
2866
|
+
dc = DtypeConfig(dtype)
|
|
2867
|
+
cell_text.style = dc.style
|
|
2485
2868
|
|
|
2486
|
-
|
|
2487
|
-
|
|
2869
|
+
self.update_cell(row_key, col_key, cell_text)
|
|
2870
|
+
|
|
2871
|
+
def _clear_selections_and_matches(self) -> None:
|
|
2872
|
+
"""Clear all selected rows and matches without removing them from the dataframe."""
|
|
2488
2873
|
# Check if any selected rows or matches
|
|
2489
2874
|
if not any(self.selected_rows) and not self.matches:
|
|
2490
2875
|
self.notify("No selections to clear", title="Clear", severity="warning")
|
|
@@ -2494,46 +2879,61 @@ class DataFrameTable(DataTable):
|
|
|
2494
2879
|
1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
|
|
2495
2880
|
)
|
|
2496
2881
|
|
|
2497
|
-
#
|
|
2882
|
+
# Add to history
|
|
2498
2883
|
self._add_history("Cleared all selected rows")
|
|
2499
2884
|
|
|
2500
|
-
# Clear all selections
|
|
2501
|
-
self.
|
|
2885
|
+
# Clear all selections
|
|
2886
|
+
self.selected_rows = [False] * len(self.df)
|
|
2887
|
+
self.matches = defaultdict(set)
|
|
2888
|
+
|
|
2889
|
+
# Recreate table for display
|
|
2890
|
+
self._setup_table()
|
|
2502
2891
|
|
|
2503
2892
|
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
2504
2893
|
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
if
|
|
2509
|
-
self.notify("No rows
|
|
2894
|
+
# Filter & View
|
|
2895
|
+
def _filter_rows(self) -> None:
|
|
2896
|
+
"""Keep only the rows with selections and matches, and remove others."""
|
|
2897
|
+
if not any(self.selected_rows) and not self.matches:
|
|
2898
|
+
self.notify("No rows to filter", title="Filter", severity="warning")
|
|
2510
2899
|
return
|
|
2511
2900
|
|
|
2512
|
-
|
|
2513
|
-
|
|
2901
|
+
filter_expr = [
|
|
2902
|
+
True if (selected or ridx in self.matches) else False for ridx, selected in enumerate(self.selected_rows)
|
|
2903
|
+
]
|
|
2904
|
+
|
|
2905
|
+
# Add to history
|
|
2906
|
+
self._add_history("Filtered to selections and matches")
|
|
2907
|
+
|
|
2908
|
+
# Apply filter to dataframe with row indices
|
|
2909
|
+
df_filtered = self.df.with_row_index(RIDX).filter(filter_expr)
|
|
2910
|
+
|
|
2911
|
+
# Update selections and matches
|
|
2912
|
+
self.selected_rows = [self.selected_rows[ridx] for ridx in df_filtered[RIDX]]
|
|
2913
|
+
self.matches = {
|
|
2914
|
+
idx: self.matches[ridx].copy() for idx, ridx in enumerate(df_filtered[RIDX]) if ridx in self.matches
|
|
2915
|
+
}
|
|
2514
2916
|
|
|
2515
|
-
# Update dataframe
|
|
2516
|
-
self.df =
|
|
2517
|
-
self.selected_rows = [True] * len(self.df)
|
|
2917
|
+
# Update dataframe
|
|
2918
|
+
self.df = df_filtered.drop(RIDX)
|
|
2518
2919
|
|
|
2519
|
-
# Recreate
|
|
2920
|
+
# Recreate table for display
|
|
2520
2921
|
self._setup_table()
|
|
2521
2922
|
|
|
2522
2923
|
self.notify(
|
|
2523
|
-
f"Removed
|
|
2524
|
-
title="Filter",
|
|
2924
|
+
f"Removed rows without selections or matches. Now showing [$accent]{len(self.df)}[/] rows", title="Filter"
|
|
2525
2925
|
)
|
|
2526
2926
|
|
|
2527
2927
|
def _view_rows(self) -> None:
|
|
2528
2928
|
"""View rows.
|
|
2529
2929
|
|
|
2530
|
-
If there are selected rows, view those rows.
|
|
2930
|
+
If there are selected rows or matches, view those rows.
|
|
2531
2931
|
Otherwise, view based on the value of the currently selected cell.
|
|
2532
2932
|
"""
|
|
2533
2933
|
|
|
2534
2934
|
cidx = self.cursor_col_idx
|
|
2535
2935
|
|
|
2536
|
-
# If there are
|
|
2936
|
+
# If there are rows with selections or matches, use those
|
|
2537
2937
|
if any(self.selected_rows) or self.matches:
|
|
2538
2938
|
term = [
|
|
2539
2939
|
True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
|
|
@@ -2543,7 +2943,7 @@ class DataFrameTable(DataTable):
|
|
|
2543
2943
|
ridx = self.cursor_row_idx
|
|
2544
2944
|
term = str(self.df.item(ridx, cidx))
|
|
2545
2945
|
|
|
2546
|
-
self._do_view_rows((term, cidx, False,
|
|
2946
|
+
self._do_view_rows((term, cidx, False, True))
|
|
2547
2947
|
|
|
2548
2948
|
def _view_rows_expr(self) -> None:
|
|
2549
2949
|
"""Open the filter screen to enter an expression."""
|
|
@@ -2557,7 +2957,7 @@ class DataFrameTable(DataTable):
|
|
|
2557
2957
|
)
|
|
2558
2958
|
|
|
2559
2959
|
def _do_view_rows(self, result) -> None:
|
|
2560
|
-
"""Show only
|
|
2960
|
+
"""Show only rows with selections or matches, and do hide others. Do not modify the dataframe."""
|
|
2561
2961
|
if result is None:
|
|
2562
2962
|
return
|
|
2563
2963
|
term, cidx, match_nocase, match_whole = result
|
|
@@ -2572,11 +2972,10 @@ class DataFrameTable(DataTable):
|
|
|
2572
2972
|
elif tentative_expr(term):
|
|
2573
2973
|
# Support for polars expressions
|
|
2574
2974
|
try:
|
|
2575
|
-
expr = validate_expr(term, self.df, cidx)
|
|
2975
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
2576
2976
|
except Exception as e:
|
|
2577
|
-
self.notify(
|
|
2578
|
-
|
|
2579
|
-
)
|
|
2977
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Filter", severity="error")
|
|
2978
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2580
2979
|
return
|
|
2581
2980
|
else:
|
|
2582
2981
|
dtype = self.df.dtypes[cidx]
|
|
@@ -2597,9 +2996,7 @@ class DataFrameTable(DataTable):
|
|
|
2597
2996
|
term = f"(?i){term}"
|
|
2598
2997
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
2599
2998
|
self.notify(
|
|
2600
|
-
f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
|
|
2601
|
-
title="Filter",
|
|
2602
|
-
severity="warning",
|
|
2999
|
+
f"Unknown column type [$warning]{dtype}[/]. Cast to string.", title="Filter", severity="warning"
|
|
2603
3000
|
)
|
|
2604
3001
|
|
|
2605
3002
|
# Lazyframe with row indices
|
|
@@ -2613,17 +3010,14 @@ class DataFrameTable(DataTable):
|
|
|
2613
3010
|
try:
|
|
2614
3011
|
df_filtered = lf.filter(expr).collect()
|
|
2615
3012
|
except Exception as e:
|
|
2616
|
-
self.notify(f"Failed to apply filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
|
|
2617
3013
|
self.histories.pop() # Remove last history entry
|
|
3014
|
+
self.notify(f"Error applying filter [$error]{expr}[/]", title="Filter", severity="error")
|
|
3015
|
+
self.log(f"Error applying filter `{expr}`: {str(e)}")
|
|
2618
3016
|
return
|
|
2619
3017
|
|
|
2620
3018
|
matched_count = len(df_filtered)
|
|
2621
3019
|
if not matched_count:
|
|
2622
|
-
self.notify(
|
|
2623
|
-
f"No rows match the expression: [$success]{expr}[/]",
|
|
2624
|
-
title="Filter",
|
|
2625
|
-
severity="warning",
|
|
2626
|
-
)
|
|
3020
|
+
self.notify(f"No rows match the expression: [$success]{expr}[/]", title="Filter", severity="warning")
|
|
2627
3021
|
return
|
|
2628
3022
|
|
|
2629
3023
|
# Add to history
|
|
@@ -2636,21 +3030,12 @@ class DataFrameTable(DataTable):
|
|
|
2636
3030
|
if ridx not in filtered_row_indices:
|
|
2637
3031
|
self.visible_rows[ridx] = False
|
|
2638
3032
|
|
|
2639
|
-
# Recreate
|
|
3033
|
+
# Recreate table for display
|
|
2640
3034
|
self._setup_table()
|
|
2641
3035
|
|
|
2642
|
-
self.notify(
|
|
2643
|
-
f"Filtered to [$accent]{matched_count}[/] matching rows",
|
|
2644
|
-
title="Filter",
|
|
2645
|
-
)
|
|
2646
|
-
|
|
2647
|
-
def _cycle_cursor_type(self) -> None:
|
|
2648
|
-
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
2649
|
-
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
2650
|
-
self.cursor_type = next_type
|
|
2651
|
-
|
|
2652
|
-
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
3036
|
+
self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
|
|
2653
3037
|
|
|
3038
|
+
# Copy & Save
|
|
2654
3039
|
def _copy_to_clipboard(self, content: str, message: str) -> None:
|
|
2655
3040
|
"""Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
|
|
2656
3041
|
|
|
@@ -2716,6 +3101,9 @@ class DataFrameTable(DataTable):
|
|
|
2716
3101
|
filepath = Path(filename)
|
|
2717
3102
|
ext = filepath.suffix.lower()
|
|
2718
3103
|
|
|
3104
|
+
# Add to history
|
|
3105
|
+
self._add_history(f"Saved dataframe to [$success]{filename}[/]")
|
|
3106
|
+
|
|
2719
3107
|
try:
|
|
2720
3108
|
if ext in (".xlsx", ".xls"):
|
|
2721
3109
|
self._do_save_excel(filename)
|
|
@@ -2728,17 +3116,14 @@ class DataFrameTable(DataTable):
|
|
|
2728
3116
|
else:
|
|
2729
3117
|
self.df.write_csv(filename)
|
|
2730
3118
|
|
|
2731
|
-
self.
|
|
3119
|
+
self.dataframe = self.df # Update original dataframe
|
|
2732
3120
|
self.filename = filename # Update current filename
|
|
2733
3121
|
if not self._all_tabs:
|
|
2734
3122
|
extra = "current tab with " if len(self.app.tabs) > 1 else ""
|
|
2735
|
-
self.notify(
|
|
2736
|
-
f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
|
|
2737
|
-
title="Save",
|
|
2738
|
-
)
|
|
3123
|
+
self.notify(f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save")
|
|
2739
3124
|
except Exception as e:
|
|
2740
|
-
self.notify(f"
|
|
2741
|
-
|
|
3125
|
+
self.notify(f"Error saving [$error]{filename}[/]", title="Save", severity="error")
|
|
3126
|
+
self.log(f"Error saving file `{filename}`: {str(e)}")
|
|
2742
3127
|
|
|
2743
3128
|
def _do_save_excel(self, filename: str) -> None:
|
|
2744
3129
|
"""Save to an Excel file."""
|
|
@@ -2757,12 +3142,71 @@ class DataFrameTable(DataTable):
|
|
|
2757
3142
|
|
|
2758
3143
|
# From ConfirmScreen callback, so notify accordingly
|
|
2759
3144
|
if self._all_tabs is True:
|
|
2760
|
-
self.notify(
|
|
2761
|
-
f"Saved all tabs to [$success]{filename}[/]",
|
|
2762
|
-
title="Save",
|
|
2763
|
-
)
|
|
3145
|
+
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save")
|
|
2764
3146
|
else:
|
|
2765
3147
|
self.notify(
|
|
2766
|
-
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
|
|
2767
|
-
title="Save",
|
|
3148
|
+
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save"
|
|
2768
3149
|
)
|
|
3150
|
+
|
|
3151
|
+
# SQL Interface
|
|
3152
|
+
def _simple_sql(self) -> None:
|
|
3153
|
+
"""Open the SQL interface screen."""
|
|
3154
|
+
self.app.push_screen(
|
|
3155
|
+
SimpleSqlScreen(self),
|
|
3156
|
+
callback=self._do_simple_sql,
|
|
3157
|
+
)
|
|
3158
|
+
|
|
3159
|
+
def _do_simple_sql(self, result) -> None:
|
|
3160
|
+
"""Handle SQL result result from SimpleSqlScreen."""
|
|
3161
|
+
if result is None:
|
|
3162
|
+
return
|
|
3163
|
+
columns, where = result
|
|
3164
|
+
|
|
3165
|
+
sql = f"SELECT {columns} FROM self"
|
|
3166
|
+
if where:
|
|
3167
|
+
sql += f" WHERE {where}"
|
|
3168
|
+
|
|
3169
|
+
self._do_sql(sql)
|
|
3170
|
+
|
|
3171
|
+
def _advanced_sql(self) -> None:
|
|
3172
|
+
"""Open the advanced SQL interface screen."""
|
|
3173
|
+
self.app.push_screen(
|
|
3174
|
+
AdvancedSqlScreen(self),
|
|
3175
|
+
callback=self._do_advanced_sql,
|
|
3176
|
+
)
|
|
3177
|
+
|
|
3178
|
+
def _do_advanced_sql(self, result) -> None:
|
|
3179
|
+
"""Handle SQL result result from AdvancedSqlScreen."""
|
|
3180
|
+
if result is None:
|
|
3181
|
+
return
|
|
3182
|
+
|
|
3183
|
+
self._do_sql(result)
|
|
3184
|
+
|
|
3185
|
+
def _do_sql(self, sql: str) -> None:
|
|
3186
|
+
"""Execute a SQL query directly.
|
|
3187
|
+
|
|
3188
|
+
Args:
|
|
3189
|
+
sql: The SQL query string to execute.
|
|
3190
|
+
"""
|
|
3191
|
+
# Add to history
|
|
3192
|
+
self._add_history(f"SQL Query:\n[$accent]{sql}[/]")
|
|
3193
|
+
|
|
3194
|
+
# Execute the SQL query
|
|
3195
|
+
try:
|
|
3196
|
+
self.df = self.df.sql(sql)
|
|
3197
|
+
except Exception as e:
|
|
3198
|
+
self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error")
|
|
3199
|
+
self.log(f"Error executing SQL query `{sql}`: {str(e)}")
|
|
3200
|
+
return
|
|
3201
|
+
|
|
3202
|
+
if not len(self.df):
|
|
3203
|
+
self.notify(f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning")
|
|
3204
|
+
return
|
|
3205
|
+
|
|
3206
|
+
# Recreate table for display
|
|
3207
|
+
self._setup_table()
|
|
3208
|
+
|
|
3209
|
+
self.notify(
|
|
3210
|
+
f"SQL query executed successfully. Now showing [$accent]{len(self.df)}[/] rows and [$accent]{len(self.df.columns)}[/] columns.",
|
|
3211
|
+
title="SQL Query",
|
|
3212
|
+
)
|