dataframe-textual 1.1.5__py3-none-any.whl → 1.3.9__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/common.py +33 -12
- dataframe_textual/data_frame_help_panel.py +6 -4
- dataframe_textual/data_frame_table.py +574 -309
- dataframe_textual/data_frame_viewer.py +19 -27
- dataframe_textual/sql_screen.py +202 -0
- dataframe_textual/table_screen.py +20 -18
- dataframe_textual/yes_no_screen.py +9 -5
- {dataframe_textual-1.1.5.dist-info → dataframe_textual-1.3.9.dist-info}/METADATA +88 -19
- dataframe_textual-1.3.9.dist-info/RECORD +14 -0
- {dataframe_textual-1.1.5.dist-info → dataframe_textual-1.3.9.dist-info}/entry_points.txt +1 -0
- dataframe_textual-1.1.5.dist-info/RECORD +0 -13
- {dataframe_textual-1.1.5.dist-info → dataframe_textual-1.3.9.dist-info}/WHEEL +0 -0
- {dataframe_textual-1.1.5.dist-info → dataframe_textual-1.3.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,6 +9,7 @@ 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
|
|
14
15
|
from textual.widgets import DataTable, TabPane
|
|
@@ -30,9 +31,11 @@ from .common import (
|
|
|
30
31
|
format_row,
|
|
31
32
|
get_next_item,
|
|
32
33
|
rindex,
|
|
34
|
+
sleep_async,
|
|
33
35
|
tentative_expr,
|
|
34
36
|
validate_expr,
|
|
35
37
|
)
|
|
38
|
+
from .sql_screen import AdvancedSqlScreen, SimpleSqlScreen
|
|
36
39
|
from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
|
|
37
40
|
from .yes_no_screen import (
|
|
38
41
|
AddColumnScreen,
|
|
@@ -104,8 +107,12 @@ class DataFrameTable(DataTable):
|
|
|
104
107
|
- **F** - 📊 Show frequency distribution
|
|
105
108
|
- **s** - 📈 Show statistics for current column
|
|
106
109
|
- **S** - 📊 Show statistics for entire dataframe
|
|
107
|
-
- **
|
|
110
|
+
- **h** - 👁️ Hide current column
|
|
111
|
+
- **H** - 👀 Show all hidden rows/columns
|
|
112
|
+
- **z** - 📌 Freeze rows and columns
|
|
108
113
|
- **~** - 🏷️ Toggle row labels
|
|
114
|
+
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
115
|
+
- **K** - 🔄 Cycle cursor (cell → row → column → cell)
|
|
109
116
|
|
|
110
117
|
## ↕️ Sorting
|
|
111
118
|
- **[** - 🔼 Sort column ascending
|
|
@@ -136,7 +143,11 @@ class DataFrameTable(DataTable):
|
|
|
136
143
|
- **{** - ⬆️ Go to previous selected row
|
|
137
144
|
- **}** - ⬇️ Go to next selected row
|
|
138
145
|
- **"** - 📍 Filter to show only selected rows
|
|
139
|
-
- **T** - 🧹 Clear all selections
|
|
146
|
+
- **T** - 🧹 Clear all selections and matches
|
|
147
|
+
|
|
148
|
+
## 🔍 SQL Interface
|
|
149
|
+
- **l** - 💬 Open simple SQL interface (select columns & WHERE clause)
|
|
150
|
+
- **L** - 🔎 Open advanced SQL interface (full SQL queries)
|
|
140
151
|
|
|
141
152
|
## ✏️ Edit & Modify
|
|
142
153
|
- **Double-click** - ✍️ Edit cell or rename column header
|
|
@@ -144,13 +155,15 @@ class DataFrameTable(DataTable):
|
|
|
144
155
|
- **E** - 📊 Edit entire column with expression
|
|
145
156
|
- **a** - ➕ Add empty column after current
|
|
146
157
|
- **A** - ➕ Add column with name and optional expression
|
|
147
|
-
- **x** -
|
|
148
|
-
- **X** -
|
|
149
|
-
- **
|
|
158
|
+
- **x** - ❌ Delete current row
|
|
159
|
+
- **X** - ❌ Delete row and those below
|
|
160
|
+
- **Ctrl+X** - ❌ Delete row and those above
|
|
161
|
+
- **delete** - ❌ Clear current cell (set to NULL)
|
|
150
162
|
- **-** - ❌ Delete current column
|
|
163
|
+
- **_** - ❌ Delete column and those after
|
|
164
|
+
- **Ctrl+_** - ❌ Delete column and those before
|
|
151
165
|
- **d** - 📋 Duplicate current column
|
|
152
|
-
- **
|
|
153
|
-
- **H** - 👀 Show all hidden columns
|
|
166
|
+
- **D** - 📋 Duplicate current row
|
|
154
167
|
|
|
155
168
|
## 🎯 Reorder
|
|
156
169
|
- **Shift+↑↓** - ⬆️⬇️ Move row up/down
|
|
@@ -166,32 +179,39 @@ class DataFrameTable(DataTable):
|
|
|
166
179
|
- **@** - 🔗 Make URLs in current column clickable with Ctrl/Cmd
|
|
167
180
|
|
|
168
181
|
## 💾 Data Management
|
|
169
|
-
- **z** - 📌 Freeze rows and columns
|
|
170
|
-
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
171
182
|
- **c** - 📋 Copy cell to clipboard
|
|
172
183
|
- **Ctrl+c** - 📊 Copy column to clipboard
|
|
173
184
|
- **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
|
|
174
185
|
- **Ctrl+s** - 💾 Save current tab to file
|
|
175
186
|
- **u** - ↩️ Undo last action
|
|
176
|
-
- **U** - 🔄
|
|
187
|
+
- **U** - 🔄 Redo last undone action
|
|
188
|
+
- **Ctrl+U** - 🔁 Reset to initial state
|
|
177
189
|
""").strip()
|
|
178
190
|
|
|
179
191
|
# fmt: off
|
|
180
192
|
BINDINGS = [
|
|
193
|
+
# Navigation
|
|
181
194
|
("g", "jump_top", "Jump to top"),
|
|
182
195
|
("G", "jump_bottom", "Jump to bottom"),
|
|
196
|
+
# Display
|
|
183
197
|
("h", "hide_column", "Hide column"),
|
|
184
|
-
("H", "
|
|
198
|
+
("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
|
|
199
|
+
("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
|
|
200
|
+
("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
|
|
201
|
+
("z", "freeze_row_column", "Freeze rows/columns"),
|
|
202
|
+
("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
|
|
203
|
+
# Copy
|
|
185
204
|
("c", "copy_cell", "Copy cell to clipboard"),
|
|
186
205
|
("ctrl+c", "copy_column", "Copy column to clipboard"),
|
|
187
206
|
("ctrl+r", "copy_row", "Copy row to clipboard"),
|
|
207
|
+
# Save
|
|
188
208
|
("ctrl+s", "save_to_file", "Save to file"),
|
|
209
|
+
# Detail, Frequency, and Statistics
|
|
189
210
|
("enter", "view_row_detail", "View row details"),
|
|
190
|
-
# Frequency & Statistics
|
|
191
211
|
("F", "show_frequency", "Show frequency"),
|
|
192
212
|
("s", "show_statistics", "Show statistics for column"),
|
|
193
213
|
("S", "show_statistics('dataframe')", "Show statistics for dataframe"),
|
|
194
|
-
#
|
|
214
|
+
# Sort
|
|
195
215
|
("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
|
|
196
216
|
("right_square_bracket", "sort_descending", "Sort descending"), # `]`
|
|
197
217
|
# View
|
|
@@ -215,16 +235,23 @@ class DataFrameTable(DataTable):
|
|
|
215
235
|
# Selection
|
|
216
236
|
("apostrophe", "make_selections", "Toggle row selection"), # `'`
|
|
217
237
|
("t", "toggle_selections", "Toggle all row selections"),
|
|
218
|
-
("T", "
|
|
238
|
+
("T", "clear_selections_and_matches", "Clear selections"),
|
|
219
239
|
("quotation_mark", "filter_selected_rows", "Filter selected"), # `"`
|
|
220
|
-
#
|
|
240
|
+
# Delete
|
|
241
|
+
("delete", "clear_cell", "Clear cell"),
|
|
221
242
|
("minus", "delete_column", "Delete column"), # `-`
|
|
243
|
+
("underscore", "delete_column_and_after", "Delete column and those after"), # `_`
|
|
244
|
+
("ctrl+underscore", "delete_column_and_before", "Delete column and those before"), # `Ctrl+_`
|
|
222
245
|
("x", "delete_row", "Delete row"),
|
|
223
|
-
("X", "
|
|
246
|
+
("X", "delete_row_and_below", "Delete row and those below"),
|
|
247
|
+
("ctrl+x", "delete_row_and_up", "Delete row and those up"),
|
|
248
|
+
# Duplicate
|
|
224
249
|
("d", "duplicate_column", "Duplicate column"),
|
|
225
250
|
("D", "duplicate_row", "Duplicate row"),
|
|
251
|
+
# Edit
|
|
226
252
|
("e", "edit_cell", "Edit cell"),
|
|
227
253
|
("E", "edit_column", "Edit column"),
|
|
254
|
+
# Add
|
|
228
255
|
("a", "add_column", "Add column"),
|
|
229
256
|
("A", "add_column_expr", "Add column with expression"),
|
|
230
257
|
# Reorder
|
|
@@ -233,19 +260,18 @@ class DataFrameTable(DataTable):
|
|
|
233
260
|
("shift+up", "move_row_up", "Move row up"),
|
|
234
261
|
("shift+down", "move_row_down", "Move row down"),
|
|
235
262
|
# 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('
|
|
263
|
+
("number_sign", "cast_column_dtype('pl.Int64')", "Cast column dtype to integer"), # `#`
|
|
264
|
+
("percent_sign", "cast_column_dtype('pl.Float64')", "Cast column dtype to float"), # `%`
|
|
265
|
+
("exclamation_mark", "cast_column_dtype('pl.Boolean')", "Cast column dtype to bool"), # `!`
|
|
266
|
+
("dollar_sign", "cast_column_dtype('pl.String')", "Cast column dtype to string"), # `$`
|
|
240
267
|
("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"), # `,`
|
|
268
|
+
# Sql
|
|
269
|
+
("l", "simple_sql", "Simple SQL interface"),
|
|
270
|
+
("L", "advanced_sql", "Advanced SQL interface"),
|
|
246
271
|
# Undo/Redo
|
|
247
272
|
("u", "undo", "Undo"),
|
|
248
|
-
("U", "
|
|
273
|
+
("U", "redo", "Redo"),
|
|
274
|
+
("ctrl+u", "reset", "Reset to initial state"),
|
|
249
275
|
]
|
|
250
276
|
# fmt: on
|
|
251
277
|
|
|
@@ -287,8 +313,10 @@ class DataFrameTable(DataTable):
|
|
|
287
313
|
self.fixed_rows = 0 # Number of fixed rows
|
|
288
314
|
self.fixed_columns = 0 # Number of fixed columns
|
|
289
315
|
|
|
290
|
-
# History stack for undo
|
|
316
|
+
# History stack for undo
|
|
291
317
|
self.histories: deque[History] = deque()
|
|
318
|
+
# Current history state for redo
|
|
319
|
+
self.history: History = None
|
|
292
320
|
|
|
293
321
|
# Pending filename for save operations
|
|
294
322
|
self._pending_filename = ""
|
|
@@ -391,17 +419,27 @@ class DataFrameTable(DataTable):
|
|
|
391
419
|
matches.append((ridx, cidx))
|
|
392
420
|
return matches
|
|
393
421
|
|
|
394
|
-
def
|
|
395
|
-
"""
|
|
422
|
+
def get_row_key(self, row_idx: int) -> RowKey:
|
|
423
|
+
"""Get the row key for a given table row index.
|
|
396
424
|
|
|
397
|
-
|
|
398
|
-
|
|
425
|
+
Args:
|
|
426
|
+
row_idx: Row index in the table display.
|
|
399
427
|
|
|
400
428
|
Returns:
|
|
401
|
-
|
|
429
|
+
Corresponding row key as string.
|
|
402
430
|
"""
|
|
403
|
-
|
|
404
|
-
|
|
431
|
+
return self._row_locations.get_key(row_idx)
|
|
432
|
+
|
|
433
|
+
def get_column_key(self, col_idx: int) -> ColumnKey:
|
|
434
|
+
"""Get the column key for a given table column index.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
col_idx: Column index in the table display.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Corresponding column key as string.
|
|
441
|
+
"""
|
|
442
|
+
return self._column_locations.get_key(col_idx)
|
|
405
443
|
|
|
406
444
|
def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
|
|
407
445
|
"""Determine if the given cell should be highlighted because of the cursor.
|
|
@@ -486,6 +524,30 @@ class DataFrameTable(DataTable):
|
|
|
486
524
|
else:
|
|
487
525
|
self._scroll_cursor_into_view()
|
|
488
526
|
|
|
527
|
+
def move_cursor_to(self, ridx: int, cidx: int) -> None:
|
|
528
|
+
"""Move cursor based on the dataframe indices.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
ridx: Row index (0-based) in the dataframe.
|
|
532
|
+
cidx: Column index (0-based) in the dataframe.
|
|
533
|
+
"""
|
|
534
|
+
row_key = str(ridx)
|
|
535
|
+
col_key = self.df.columns[cidx]
|
|
536
|
+
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
537
|
+
self.move_cursor(row=row_idx, column=col_idx)
|
|
538
|
+
|
|
539
|
+
def on_mount(self) -> None:
|
|
540
|
+
"""Initialize table display when the widget is mounted.
|
|
541
|
+
|
|
542
|
+
Called by Textual when the widget is first added to the display tree.
|
|
543
|
+
Currently a placeholder as table setup is deferred until first use.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
None
|
|
547
|
+
"""
|
|
548
|
+
# self._setup_table()
|
|
549
|
+
pass
|
|
550
|
+
|
|
489
551
|
def on_key(self, event) -> None:
|
|
490
552
|
"""Handle key press events for pagination.
|
|
491
553
|
|
|
@@ -544,13 +606,21 @@ class DataFrameTable(DataTable):
|
|
|
544
606
|
"""Delete the current column."""
|
|
545
607
|
self._delete_column()
|
|
546
608
|
|
|
609
|
+
def action_delete_column_and_after(self) -> None:
|
|
610
|
+
"""Delete the current column and those after."""
|
|
611
|
+
self._delete_column(more="after")
|
|
612
|
+
|
|
613
|
+
def action_delete_column_and_before(self) -> None:
|
|
614
|
+
"""Delete the current column and those before."""
|
|
615
|
+
self._delete_column(more="before")
|
|
616
|
+
|
|
547
617
|
def action_hide_column(self) -> None:
|
|
548
618
|
"""Hide the current column."""
|
|
549
619
|
self._hide_column()
|
|
550
620
|
|
|
551
|
-
def
|
|
552
|
-
"""Show all hidden columns."""
|
|
553
|
-
self.
|
|
621
|
+
def action_show_hidden_rows_columns(self) -> None:
|
|
622
|
+
"""Show all hidden rows/columns."""
|
|
623
|
+
self._show_hidden_rows_columns()
|
|
554
624
|
|
|
555
625
|
def action_sort_ascending(self) -> None:
|
|
556
626
|
"""Sort by current column in ascending order."""
|
|
@@ -656,6 +726,14 @@ class DataFrameTable(DataTable):
|
|
|
656
726
|
"""Delete the current row."""
|
|
657
727
|
self._delete_row()
|
|
658
728
|
|
|
729
|
+
def action_delete_row_and_below(self) -> None:
|
|
730
|
+
"""Delete the current row and those below."""
|
|
731
|
+
self._delete_row(more="below")
|
|
732
|
+
|
|
733
|
+
def action_delete_row_and_up(self) -> None:
|
|
734
|
+
"""Delete the current row and those above."""
|
|
735
|
+
self._delete_row(more="above")
|
|
736
|
+
|
|
659
737
|
def action_duplicate_column(self) -> None:
|
|
660
738
|
"""Duplicate the current column."""
|
|
661
739
|
self._duplicate_column()
|
|
@@ -668,10 +746,14 @@ class DataFrameTable(DataTable):
|
|
|
668
746
|
"""Undo the last action."""
|
|
669
747
|
self._undo()
|
|
670
748
|
|
|
749
|
+
def action_redo(self) -> None:
|
|
750
|
+
"""Redo the last undone action."""
|
|
751
|
+
self._redo()
|
|
752
|
+
|
|
671
753
|
def action_reset(self) -> None:
|
|
672
|
-
"""Reset to the
|
|
754
|
+
"""Reset to the initial state."""
|
|
673
755
|
self._setup_table(reset=True)
|
|
674
|
-
self.notify("Restored
|
|
756
|
+
self.notify("Restored initial state", title="Reset")
|
|
675
757
|
|
|
676
758
|
def action_move_column_left(self) -> None:
|
|
677
759
|
"""Move the current column to the left."""
|
|
@@ -689,9 +771,9 @@ class DataFrameTable(DataTable):
|
|
|
689
771
|
"""Move the current row down."""
|
|
690
772
|
self._move_row("down")
|
|
691
773
|
|
|
692
|
-
def
|
|
693
|
-
"""Clear all row selections."""
|
|
694
|
-
self.
|
|
774
|
+
def action_clear_selections_and_matches(self) -> None:
|
|
775
|
+
"""Clear all row selections and matches."""
|
|
776
|
+
self._clear_selections_and_matches()
|
|
695
777
|
|
|
696
778
|
def action_cycle_cursor_type(self) -> None:
|
|
697
779
|
"""Cycle through cursor types."""
|
|
@@ -781,40 +863,13 @@ class DataFrameTable(DataTable):
|
|
|
781
863
|
"""Go to the previous selected row."""
|
|
782
864
|
self._previous_selected_row()
|
|
783
865
|
|
|
784
|
-
def
|
|
785
|
-
"""
|
|
786
|
-
|
|
787
|
-
Scans all loaded rows in the current column for cells containing URLs
|
|
788
|
-
(starting with 'http://' or 'https://') and applies Textual link styling
|
|
789
|
-
to make them clickable. Does not modify the dataframe.
|
|
866
|
+
def action_simple_sql(self) -> None:
|
|
867
|
+
"""Open the SQL interface screen."""
|
|
868
|
+
self._simple_sql()
|
|
790
869
|
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
)
|
|
870
|
+
def action_advanced_sql(self) -> None:
|
|
871
|
+
"""Open the advanced SQL interface screen."""
|
|
872
|
+
self._advanced_sql()
|
|
818
873
|
|
|
819
874
|
def on_mouse_scroll_down(self, event) -> None:
|
|
820
875
|
"""Load more rows when scrolling down with mouse."""
|
|
@@ -827,6 +882,9 @@ class DataFrameTable(DataTable):
|
|
|
827
882
|
Row keys are 0-based indices, which map directly to dataframe row indices.
|
|
828
883
|
Column keys are header names from the dataframe.
|
|
829
884
|
"""
|
|
885
|
+
self.loaded_rows = 0
|
|
886
|
+
self.show_row_labels = True
|
|
887
|
+
|
|
830
888
|
# Reset to original dataframe
|
|
831
889
|
if reset:
|
|
832
890
|
self.df = self.lazyframe.collect()
|
|
@@ -849,12 +907,15 @@ class DataFrameTable(DataTable):
|
|
|
849
907
|
stop = row_idx + 1
|
|
850
908
|
break
|
|
851
909
|
|
|
910
|
+
# Ensure all selected rows or matches are loaded
|
|
911
|
+
stop = max(stop, rindex(self.selected_rows, True) + 1)
|
|
912
|
+
stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
913
|
+
|
|
852
914
|
# Save current cursor position before clearing
|
|
853
915
|
row_idx, col_idx = self.cursor_coordinate
|
|
854
916
|
|
|
855
917
|
self._setup_columns()
|
|
856
918
|
self._load_rows(stop)
|
|
857
|
-
self._do_highlight()
|
|
858
919
|
|
|
859
920
|
# Restore cursor position
|
|
860
921
|
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
@@ -866,9 +927,7 @@ class DataFrameTable(DataTable):
|
|
|
866
927
|
Column keys are header names from the dataframe.
|
|
867
928
|
Column labels contain column names from the dataframe, with sort indicators if applicable.
|
|
868
929
|
"""
|
|
869
|
-
self.loaded_rows = 0
|
|
870
930
|
self.clear(columns=True)
|
|
871
|
-
self.show_row_labels = True
|
|
872
931
|
|
|
873
932
|
# Add columns with justified headers
|
|
874
933
|
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
@@ -906,23 +965,33 @@ class DataFrameTable(DataTable):
|
|
|
906
965
|
start = self.loaded_rows
|
|
907
966
|
df_slice = self.df.slice(start, stop - start)
|
|
908
967
|
|
|
909
|
-
for
|
|
910
|
-
if not self.visible_rows[
|
|
968
|
+
for ridx, row in enumerate(df_slice.rows(), start):
|
|
969
|
+
if not self.visible_rows[ridx]:
|
|
911
970
|
continue # Skip hidden rows
|
|
912
|
-
|
|
971
|
+
|
|
972
|
+
is_selected = self.selected_rows[ridx]
|
|
973
|
+
match_cols = self.matches.get(ridx, set())
|
|
974
|
+
|
|
975
|
+
vals, dtypes, styles = [], [], []
|
|
913
976
|
for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
|
|
914
977
|
if col in self.hidden_columns:
|
|
915
978
|
continue # Skip hidden columns
|
|
979
|
+
|
|
916
980
|
vals.append(val)
|
|
917
981
|
dtypes.append(dtype)
|
|
918
|
-
|
|
982
|
+
# Highlight entire row if selected or has matches
|
|
983
|
+
styles.append("red" if is_selected or col in match_cols else None)
|
|
984
|
+
|
|
985
|
+
formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
|
|
986
|
+
|
|
919
987
|
# Always add labels so they can be shown/hidden via CSS
|
|
920
|
-
self.add_row(*formatted_row, key=str(
|
|
988
|
+
self.add_row(*formatted_row, key=str(ridx), label=str(ridx + 1))
|
|
921
989
|
|
|
922
990
|
# Update loaded rows count
|
|
923
991
|
self.loaded_rows = stop
|
|
924
992
|
|
|
925
993
|
# self.notify(f"Loaded [$accent]{stop}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
|
|
994
|
+
# self.log(f"Loaded {stop}/{len(self.df)} rows from {self.name}")
|
|
926
995
|
|
|
927
996
|
def _check_and_load_more(self) -> None:
|
|
928
997
|
"""Check if we need to load more rows and load them."""
|
|
@@ -937,51 +1006,116 @@ class DataFrameTable(DataTable):
|
|
|
937
1006
|
if bottom_visible_row >= self.loaded_rows - 10:
|
|
938
1007
|
self._load_rows(self.loaded_rows + self.BATCH_SIZE)
|
|
939
1008
|
|
|
940
|
-
def _do_highlight(self,
|
|
1009
|
+
def _do_highlight(self, force: bool = False) -> None:
|
|
941
1010
|
"""Update all rows, highlighting selected ones and restoring others to default.
|
|
942
1011
|
|
|
943
1012
|
Args:
|
|
944
|
-
|
|
1013
|
+
force: If True, clear all highlights and restore default styles.
|
|
945
1014
|
"""
|
|
946
|
-
if clear:
|
|
947
|
-
self.selected_rows = [False] * len(self.df)
|
|
948
|
-
self.matches = defaultdict(set)
|
|
949
|
-
|
|
950
1015
|
# Ensure all selected rows or matches are loaded
|
|
951
1016
|
stop = rindex(self.selected_rows, True) + 1
|
|
952
1017
|
stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
953
1018
|
|
|
954
1019
|
self._load_rows(stop)
|
|
955
|
-
self._highlight_table()
|
|
1020
|
+
self._highlight_table(force)
|
|
956
1021
|
|
|
957
|
-
def _highlight_table(self) -> None:
|
|
1022
|
+
def _highlight_table(self, force: bool = False) -> None:
|
|
958
1023
|
"""Highlight selected rows/cells in red."""
|
|
1024
|
+
if not force and not any(self.selected_rows) and not self.matches:
|
|
1025
|
+
return # Nothing to highlight
|
|
1026
|
+
|
|
959
1027
|
# Update all rows based on selected state
|
|
960
1028
|
for row in self.ordered_rows:
|
|
961
|
-
|
|
962
|
-
is_selected = self.selected_rows[
|
|
963
|
-
match_cols = self.matches.get(
|
|
1029
|
+
ridx = int(row.key.value) # 0-based index
|
|
1030
|
+
is_selected = self.selected_rows[ridx]
|
|
1031
|
+
match_cols = self.matches.get(ridx, set())
|
|
1032
|
+
|
|
1033
|
+
if not force and not is_selected and not match_cols:
|
|
1034
|
+
continue # No highlight needed for this row
|
|
964
1035
|
|
|
965
1036
|
# Update all cells in this row
|
|
966
1037
|
for col_idx, col in enumerate(self.ordered_columns):
|
|
967
|
-
|
|
1038
|
+
if not force and not is_selected and col_idx not in match_cols:
|
|
1039
|
+
continue # No highlight needed for this cell
|
|
968
1040
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1041
|
+
cell_text: Text = self.get_cell(row.key, col.key)
|
|
1042
|
+
need_update = False
|
|
1043
|
+
|
|
1044
|
+
if is_selected or col_idx in match_cols:
|
|
1045
|
+
cell_text.style = "red"
|
|
1046
|
+
need_update = True
|
|
1047
|
+
elif force:
|
|
1048
|
+
# Restore original style based on dtype
|
|
1049
|
+
dtype = self.df.schema[col.key.value]
|
|
1050
|
+
dc = DtypeConfig(dtype)
|
|
1051
|
+
cell_text.style = dc.style
|
|
1052
|
+
need_update = True
|
|
973
1053
|
|
|
974
1054
|
# Update the cell in the table
|
|
975
|
-
|
|
1055
|
+
if need_update:
|
|
1056
|
+
self.update_cell(row.key, col.key, cell_text)
|
|
976
1057
|
|
|
977
|
-
|
|
978
|
-
def
|
|
979
|
-
"""
|
|
1058
|
+
@work(exclusive=True, description="Loading rows asynchronously...")
|
|
1059
|
+
async def _load_rows_async(self, stop: int | None = None) -> None:
|
|
1060
|
+
"""Asynchronously load a batch of rows into the table.
|
|
980
1061
|
|
|
981
1062
|
Args:
|
|
982
|
-
|
|
1063
|
+
stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
|
|
983
1064
|
"""
|
|
984
|
-
|
|
1065
|
+
if stop >= (total := len(self.df)):
|
|
1066
|
+
stop = total
|
|
1067
|
+
|
|
1068
|
+
if stop > self.loaded_rows:
|
|
1069
|
+
# Load incrementally with smaller chunks to prevent UI freezing
|
|
1070
|
+
chunk_size = min(100, stop - self.loaded_rows) # Load max 100 rows at a time
|
|
1071
|
+
next_stop = min(self.loaded_rows + chunk_size, stop)
|
|
1072
|
+
self._load_rows(next_stop)
|
|
1073
|
+
|
|
1074
|
+
# If there's more to load, schedule the next chunk with longer delay
|
|
1075
|
+
if next_stop < stop:
|
|
1076
|
+
# Use longer delay and call work method instead of set_timer
|
|
1077
|
+
await sleep_async(0.1) # 100ms delay to yield to UI
|
|
1078
|
+
self._load_rows_async(stop) # Recursive call within work context
|
|
1079
|
+
|
|
1080
|
+
# self.log(f"Async loaded {stop}/{len(self.df)} rows from {self.name}")
|
|
1081
|
+
|
|
1082
|
+
@work(exclusive=True, description="Doing highlight...")
|
|
1083
|
+
async def _do_highlight_async(self) -> None:
|
|
1084
|
+
"""Perform the highlighting preparation in a worker."""
|
|
1085
|
+
try:
|
|
1086
|
+
# Calculate what needs to be loaded without actually loading
|
|
1087
|
+
stop = rindex(self.selected_rows, True) + 1
|
|
1088
|
+
stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
1089
|
+
|
|
1090
|
+
# Call the highlighting method (runs in background worker)
|
|
1091
|
+
self._highlight_async(stop)
|
|
1092
|
+
|
|
1093
|
+
except Exception as e:
|
|
1094
|
+
self.notify(f"Error preparing highlight: {str(e)}", title="Search", severity="error")
|
|
1095
|
+
|
|
1096
|
+
@work(exclusive=True, description="Highlighting matches...")
|
|
1097
|
+
async def _highlight_async(self, stop: int) -> None:
|
|
1098
|
+
"""Perform highlighting with async loading to avoid blocking."""
|
|
1099
|
+
# Load rows in smaller chunks to avoid blocking
|
|
1100
|
+
if stop > self.loaded_rows:
|
|
1101
|
+
# Load incrementally to avoid one big block
|
|
1102
|
+
chunk_size = min(100, stop - self.loaded_rows) # Load max 100 rows at a time
|
|
1103
|
+
next_stop = min(self.loaded_rows + chunk_size, stop)
|
|
1104
|
+
self._load_rows(next_stop)
|
|
1105
|
+
|
|
1106
|
+
# If there's more to load, yield to event loop with delay
|
|
1107
|
+
if next_stop < stop:
|
|
1108
|
+
await sleep_async(0.05) # 50ms delay to allow UI updates
|
|
1109
|
+
self._highlight_async(stop)
|
|
1110
|
+
return
|
|
1111
|
+
|
|
1112
|
+
# Now do the actual highlighting
|
|
1113
|
+
self._highlight_table(force=False)
|
|
1114
|
+
|
|
1115
|
+
# History & Undo
|
|
1116
|
+
def _create_history(self, description: str) -> None:
|
|
1117
|
+
"""Create the initial history state."""
|
|
1118
|
+
return History(
|
|
985
1119
|
description=description,
|
|
986
1120
|
df=self.df,
|
|
987
1121
|
filename=self.filename,
|
|
@@ -995,16 +1129,12 @@ class DataFrameTable(DataTable):
|
|
|
995
1129
|
cursor_coordinate=self.cursor_coordinate,
|
|
996
1130
|
matches={k: v.copy() for k, v in self.matches.items()},
|
|
997
1131
|
)
|
|
998
|
-
self.histories.append(history)
|
|
999
1132
|
|
|
1000
|
-
def
|
|
1001
|
-
"""
|
|
1002
|
-
if
|
|
1003
|
-
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1133
|
+
def _apply_history(self, history: History) -> None:
|
|
1134
|
+
"""Apply the current history state to the table."""
|
|
1135
|
+
if history is None:
|
|
1004
1136
|
return
|
|
1005
1137
|
|
|
1006
|
-
history = self.histories.pop()
|
|
1007
|
-
|
|
1008
1138
|
# Restore state
|
|
1009
1139
|
self.df = history.df
|
|
1010
1140
|
self.filename = history.filename
|
|
@@ -1016,12 +1146,55 @@ class DataFrameTable(DataTable):
|
|
|
1016
1146
|
self.fixed_rows = history.fixed_rows
|
|
1017
1147
|
self.fixed_columns = history.fixed_columns
|
|
1018
1148
|
self.cursor_coordinate = history.cursor_coordinate
|
|
1019
|
-
self.matches = {k: v.copy() for k, v in history.matches.items()}
|
|
1149
|
+
self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
|
|
1020
1150
|
|
|
1021
1151
|
# Recreate the table for display
|
|
1022
1152
|
self._setup_table()
|
|
1023
1153
|
|
|
1024
|
-
|
|
1154
|
+
def _add_history(self, description: str) -> None:
|
|
1155
|
+
"""Add the current state to the history stack.
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
description: Description of the action for this history entry.
|
|
1159
|
+
"""
|
|
1160
|
+
history = self._create_history(description)
|
|
1161
|
+
self.histories.append(history)
|
|
1162
|
+
|
|
1163
|
+
def _undo(self) -> None:
|
|
1164
|
+
"""Undo the last action."""
|
|
1165
|
+
if not self.histories:
|
|
1166
|
+
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1167
|
+
return
|
|
1168
|
+
|
|
1169
|
+
# Pop the last history state for undo
|
|
1170
|
+
history = self.histories.pop()
|
|
1171
|
+
|
|
1172
|
+
# Save current state for redo
|
|
1173
|
+
self.history = self._create_history(history.description)
|
|
1174
|
+
|
|
1175
|
+
# Restore state
|
|
1176
|
+
self._apply_history(history)
|
|
1177
|
+
|
|
1178
|
+
self.notify(f"Reverted: {history.description}", title="Undo")
|
|
1179
|
+
|
|
1180
|
+
def _redo(self) -> None:
|
|
1181
|
+
"""Redo the last undone action."""
|
|
1182
|
+
if self.history is None:
|
|
1183
|
+
self.notify("No actions to redo", title="Redo", severity="warning")
|
|
1184
|
+
return
|
|
1185
|
+
|
|
1186
|
+
description = self.history.description
|
|
1187
|
+
|
|
1188
|
+
# Save current state for undo
|
|
1189
|
+
self._add_history(description)
|
|
1190
|
+
|
|
1191
|
+
# Restore state
|
|
1192
|
+
self._apply_history(self.history)
|
|
1193
|
+
|
|
1194
|
+
# Clear redo state
|
|
1195
|
+
self.history = None
|
|
1196
|
+
|
|
1197
|
+
self.notify(f"Reapplied: {description}", title="Redo")
|
|
1025
1198
|
|
|
1026
1199
|
# View
|
|
1027
1200
|
def _view_row_detail(self) -> None:
|
|
@@ -1071,49 +1244,77 @@ class DataFrameTable(DataTable):
|
|
|
1071
1244
|
self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
|
|
1072
1245
|
|
|
1073
1246
|
# Apply the pin settings to the table
|
|
1074
|
-
if fixed_rows
|
|
1247
|
+
if fixed_rows >= 0:
|
|
1075
1248
|
self.fixed_rows = fixed_rows
|
|
1076
|
-
if fixed_columns
|
|
1249
|
+
if fixed_columns >= 0:
|
|
1077
1250
|
self.fixed_columns = fixed_columns
|
|
1078
1251
|
|
|
1079
|
-
self.notify(
|
|
1080
|
-
f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
|
|
1081
|
-
title="Pin",
|
|
1082
|
-
)
|
|
1252
|
+
# self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
|
|
1083
1253
|
|
|
1084
1254
|
# Delete & Move
|
|
1085
|
-
def _delete_column(self) -> None:
|
|
1255
|
+
def _delete_column(self, more: str = None) -> None:
|
|
1086
1256
|
"""Remove the currently selected column from the table."""
|
|
1087
1257
|
# Get the column to remove
|
|
1088
1258
|
col_idx = self.cursor_column
|
|
1089
1259
|
col_name = self.cursor_col_name
|
|
1090
1260
|
col_key = self.cursor_col_key
|
|
1091
1261
|
|
|
1262
|
+
col_names_to_remove = []
|
|
1263
|
+
col_keys_to_remove = []
|
|
1264
|
+
|
|
1265
|
+
# Remove all columns before the current column
|
|
1266
|
+
if more == "before":
|
|
1267
|
+
for i in range(col_idx + 1):
|
|
1268
|
+
col_key = self.get_column_key(i)
|
|
1269
|
+
col_names_to_remove.append(col_key.value)
|
|
1270
|
+
col_keys_to_remove.append(col_key)
|
|
1271
|
+
|
|
1272
|
+
message = f"Removed column [$success]{col_name}[/] and all columns before"
|
|
1273
|
+
|
|
1274
|
+
# Remove all columns after the current column
|
|
1275
|
+
elif more == "after":
|
|
1276
|
+
for i in range(col_idx, len(self.columns)):
|
|
1277
|
+
col_key = self.get_column_key(i)
|
|
1278
|
+
col_names_to_remove.append(col_key.value)
|
|
1279
|
+
col_keys_to_remove.append(col_key)
|
|
1280
|
+
|
|
1281
|
+
message = f"Removed column [$success]{col_name}[/] and all columns after"
|
|
1282
|
+
|
|
1283
|
+
# Remove only the current column
|
|
1284
|
+
else:
|
|
1285
|
+
col_names_to_remove.append(col_name)
|
|
1286
|
+
col_keys_to_remove.append(col_key)
|
|
1287
|
+
message = f"Removed column [$success]{col_name}[/]"
|
|
1288
|
+
|
|
1092
1289
|
# Add to history
|
|
1093
|
-
self._add_history(
|
|
1290
|
+
self._add_history(message)
|
|
1094
1291
|
|
|
1095
|
-
# Remove the
|
|
1096
|
-
|
|
1292
|
+
# Remove the columns from the table display using the column names as keys
|
|
1293
|
+
for ck in col_keys_to_remove:
|
|
1294
|
+
self.remove_column(ck)
|
|
1097
1295
|
|
|
1098
|
-
# Move cursor left if we deleted the last column
|
|
1099
|
-
|
|
1100
|
-
|
|
1296
|
+
# Move cursor left if we deleted the last column(s)
|
|
1297
|
+
last_col_idx = len(self.columns) - 1
|
|
1298
|
+
if col_idx > last_col_idx:
|
|
1299
|
+
self.move_cursor(column=last_col_idx)
|
|
1101
1300
|
|
|
1102
1301
|
# Remove from sorted columns if present
|
|
1103
|
-
|
|
1104
|
-
|
|
1302
|
+
for col_name in col_names_to_remove:
|
|
1303
|
+
if col_name in self.sorted_columns:
|
|
1304
|
+
del self.sorted_columns[col_name]
|
|
1105
1305
|
|
|
1106
1306
|
# Remove from matches
|
|
1307
|
+
col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
|
|
1107
1308
|
for row_idx in list(self.matches.keys()):
|
|
1108
|
-
self.matches[row_idx].
|
|
1309
|
+
self.matches[row_idx].difference_update(col_indices_to_remove)
|
|
1109
1310
|
# Remove empty entries
|
|
1110
1311
|
if not self.matches[row_idx]:
|
|
1111
1312
|
del self.matches[row_idx]
|
|
1112
1313
|
|
|
1113
1314
|
# Remove from dataframe
|
|
1114
|
-
self.df = self.df.drop(
|
|
1315
|
+
self.df = self.df.drop(col_names_to_remove)
|
|
1115
1316
|
|
|
1116
|
-
self.notify(
|
|
1317
|
+
self.notify(message, title="Delete")
|
|
1117
1318
|
|
|
1118
1319
|
def _hide_column(self) -> None:
|
|
1119
1320
|
"""Hide the currently selected column from the table display."""
|
|
@@ -1136,28 +1337,32 @@ class DataFrameTable(DataTable):
|
|
|
1136
1337
|
|
|
1137
1338
|
# self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
|
|
1138
1339
|
|
|
1139
|
-
def
|
|
1140
|
-
"""Show all hidden columns by recreating the table
|
|
1340
|
+
def _show_hidden_rows_columns(self) -> None:
|
|
1341
|
+
"""Show all hidden rows/columns by recreating the table."""
|
|
1141
1342
|
# Get currently visible columns
|
|
1142
1343
|
visible_cols = set(col.key for col in self.ordered_columns)
|
|
1143
1344
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1345
|
+
hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
|
|
1346
|
+
hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
|
|
1146
1347
|
|
|
1147
|
-
if not
|
|
1148
|
-
self.notify("No hidden columns to show", title="
|
|
1348
|
+
if not hidden_row_count and not hidden_col_count:
|
|
1349
|
+
self.notify("No hidden columns or rows to show", title="Show", severity="warning")
|
|
1149
1350
|
return
|
|
1150
1351
|
|
|
1151
1352
|
# Add to history
|
|
1152
|
-
self._add_history(
|
|
1353
|
+
self._add_history("Showed hidden rows/columns")
|
|
1153
1354
|
|
|
1154
|
-
# Clear hidden columns tracking
|
|
1355
|
+
# Clear hidden rows/columns tracking
|
|
1356
|
+
self.visible_rows = [True] * len(self.df)
|
|
1155
1357
|
self.hidden_columns.clear()
|
|
1156
1358
|
|
|
1157
|
-
# Recreate table
|
|
1359
|
+
# Recreate table for display
|
|
1158
1360
|
self._setup_table()
|
|
1159
1361
|
|
|
1160
|
-
self.notify(
|
|
1362
|
+
self.notify(
|
|
1363
|
+
f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
|
|
1364
|
+
title="Show",
|
|
1365
|
+
)
|
|
1161
1366
|
|
|
1162
1367
|
def _duplicate_column(self) -> None:
|
|
1163
1368
|
"""Duplicate the currently selected column, inserting it right after the current column."""
|
|
@@ -1179,18 +1384,27 @@ class DataFrameTable(DataTable):
|
|
|
1179
1384
|
list(cols_before) + [new_col_name] + list(cols_after)
|
|
1180
1385
|
)
|
|
1181
1386
|
|
|
1387
|
+
# Update matches to account for new column
|
|
1388
|
+
new_matches = defaultdict(set)
|
|
1389
|
+
for row_idx, cols in self.matches.items():
|
|
1390
|
+
new_cols = set()
|
|
1391
|
+
for col_idx_in_set in cols:
|
|
1392
|
+
if col_idx_in_set <= cidx:
|
|
1393
|
+
new_cols.add(col_idx_in_set)
|
|
1394
|
+
else:
|
|
1395
|
+
new_cols.add(col_idx_in_set + 1)
|
|
1396
|
+
new_matches[row_idx] = new_cols
|
|
1397
|
+
self.matches = new_matches
|
|
1398
|
+
|
|
1182
1399
|
# Recreate the table for display
|
|
1183
1400
|
self._setup_table()
|
|
1184
1401
|
|
|
1185
1402
|
# Move cursor to the new duplicated column
|
|
1186
1403
|
self.move_cursor(column=col_idx + 1)
|
|
1187
1404
|
|
|
1188
|
-
self.notify(
|
|
1189
|
-
f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
|
|
1190
|
-
title="Duplicate",
|
|
1191
|
-
)
|
|
1405
|
+
# self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
|
|
1192
1406
|
|
|
1193
|
-
def _delete_row(self) -> None:
|
|
1407
|
+
def _delete_row(self, more: str = None) -> None:
|
|
1194
1408
|
"""Delete rows from the table and dataframe.
|
|
1195
1409
|
|
|
1196
1410
|
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
@@ -1206,11 +1420,27 @@ class DataFrameTable(DataTable):
|
|
|
1206
1420
|
if selected:
|
|
1207
1421
|
predicates[ridx] = False
|
|
1208
1422
|
|
|
1423
|
+
# Delete current row and those above
|
|
1424
|
+
elif more == "above":
|
|
1425
|
+
ridx = self.cursor_row_idx
|
|
1426
|
+
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
|
|
1427
|
+
for i in range(ridx + 1):
|
|
1428
|
+
predicates[i] = False
|
|
1429
|
+
|
|
1430
|
+
# Delete current row and those below
|
|
1431
|
+
elif more == "below":
|
|
1432
|
+
ridx = self.cursor_row_idx
|
|
1433
|
+
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
|
|
1434
|
+
for i in range(ridx, len(self.df)):
|
|
1435
|
+
if self.visible_rows[i]:
|
|
1436
|
+
predicates[i] = False
|
|
1437
|
+
|
|
1209
1438
|
# Delete the row at the cursor
|
|
1210
1439
|
else:
|
|
1211
1440
|
ridx = self.cursor_row_idx
|
|
1212
1441
|
history_desc = f"Deleted row [$success]{ridx + 1}[/]"
|
|
1213
|
-
|
|
1442
|
+
if self.visible_rows[ridx]:
|
|
1443
|
+
predicates[ridx] = False
|
|
1214
1444
|
|
|
1215
1445
|
# Add to history
|
|
1216
1446
|
self._add_history(history_desc)
|
|
@@ -1237,8 +1467,8 @@ class DataFrameTable(DataTable):
|
|
|
1237
1467
|
self._setup_table()
|
|
1238
1468
|
|
|
1239
1469
|
deleted_count = old_count - len(self.df)
|
|
1240
|
-
if deleted_count >
|
|
1241
|
-
self.notify(f"Deleted {deleted_count} row(s)", title="Delete")
|
|
1470
|
+
if deleted_count > 0:
|
|
1471
|
+
self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
|
|
1242
1472
|
|
|
1243
1473
|
def _duplicate_row(self) -> None:
|
|
1244
1474
|
"""Duplicate the currently selected row, inserting it right after the current row."""
|
|
@@ -1263,8 +1493,14 @@ class DataFrameTable(DataTable):
|
|
|
1263
1493
|
self.selected_rows = new_selected_rows
|
|
1264
1494
|
self.visible_rows = new_visible_rows
|
|
1265
1495
|
|
|
1266
|
-
#
|
|
1267
|
-
|
|
1496
|
+
# Update matches to account for new row
|
|
1497
|
+
new_matches = defaultdict(set)
|
|
1498
|
+
for row_idx, cols in self.matches.items():
|
|
1499
|
+
if row_idx <= ridx:
|
|
1500
|
+
new_matches[row_idx] = cols
|
|
1501
|
+
else:
|
|
1502
|
+
new_matches[row_idx + 1] = cols
|
|
1503
|
+
self.matches = new_matches
|
|
1268
1504
|
|
|
1269
1505
|
# Recreate the table display
|
|
1270
1506
|
self._setup_table()
|
|
@@ -1349,7 +1585,7 @@ class DataFrameTable(DataTable):
|
|
|
1349
1585
|
return
|
|
1350
1586
|
swap_idx = row_idx + 1
|
|
1351
1587
|
else:
|
|
1352
|
-
|
|
1588
|
+
# Invalid direction
|
|
1353
1589
|
return
|
|
1354
1590
|
|
|
1355
1591
|
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
@@ -1499,7 +1735,7 @@ class DataFrameTable(DataTable):
|
|
|
1499
1735
|
col_key = col_name
|
|
1500
1736
|
self.update_cell(row_key, col_key, formatted_value, update_width=True)
|
|
1501
1737
|
|
|
1502
|
-
self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1738
|
+
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1503
1739
|
except Exception as e:
|
|
1504
1740
|
self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
|
|
1505
1741
|
|
|
@@ -1528,7 +1764,7 @@ class DataFrameTable(DataTable):
|
|
|
1528
1764
|
# Check if term is a valid expression
|
|
1529
1765
|
elif tentative_expr(term):
|
|
1530
1766
|
try:
|
|
1531
|
-
expr = validate_expr(term, self.df, cidx)
|
|
1767
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
1532
1768
|
except Exception as e:
|
|
1533
1769
|
self.notify(f"Error validating expression [$error]{term}[/]: {str(e)}", title="Edit", severity="error")
|
|
1534
1770
|
return
|
|
@@ -1541,7 +1777,7 @@ class DataFrameTable(DataTable):
|
|
|
1541
1777
|
expr = pl.lit(value)
|
|
1542
1778
|
except Exception:
|
|
1543
1779
|
self.notify(
|
|
1544
|
-
f"
|
|
1780
|
+
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1545
1781
|
title="Edit",
|
|
1546
1782
|
severity="error",
|
|
1547
1783
|
)
|
|
@@ -1554,16 +1790,13 @@ class DataFrameTable(DataTable):
|
|
|
1554
1790
|
# Apply the expression to the column
|
|
1555
1791
|
self.df = self.df.with_columns(expr.alias(col_name))
|
|
1556
1792
|
except Exception as e:
|
|
1557
|
-
self.notify(f"
|
|
1793
|
+
self.notify(f"Error applying expression: [$error]{str(e)}[/]", title="Edit", severity="error")
|
|
1558
1794
|
return
|
|
1559
1795
|
|
|
1560
1796
|
# Recreate the table for display
|
|
1561
1797
|
self._setup_table()
|
|
1562
1798
|
|
|
1563
|
-
self.notify(
|
|
1564
|
-
f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
|
|
1565
|
-
title="Edit",
|
|
1566
|
-
)
|
|
1799
|
+
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
|
|
1567
1800
|
|
|
1568
1801
|
def _rename_column(self) -> None:
|
|
1569
1802
|
"""Open modal to rename the selected column."""
|
|
@@ -1610,10 +1843,7 @@ class DataFrameTable(DataTable):
|
|
|
1610
1843
|
# Move cursor to the renamed column
|
|
1611
1844
|
self.move_cursor(column=col_idx)
|
|
1612
1845
|
|
|
1613
|
-
self.notify(
|
|
1614
|
-
f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
|
|
1615
|
-
title="Column",
|
|
1616
|
-
)
|
|
1846
|
+
# self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
|
|
1617
1847
|
|
|
1618
1848
|
def _clear_cell(self) -> None:
|
|
1619
1849
|
"""Clear the current cell by setting its value to None."""
|
|
@@ -1641,9 +1871,9 @@ class DataFrameTable(DataTable):
|
|
|
1641
1871
|
|
|
1642
1872
|
self.update_cell(row_key, col_key, formatted_value)
|
|
1643
1873
|
|
|
1644
|
-
self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
1874
|
+
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
1645
1875
|
except Exception as e:
|
|
1646
|
-
self.notify(f"
|
|
1876
|
+
self.notify(f"Error clearing cell: {str(e)}", title="Clear", severity="error")
|
|
1647
1877
|
raise e
|
|
1648
1878
|
|
|
1649
1879
|
def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
|
|
@@ -1686,9 +1916,9 @@ class DataFrameTable(DataTable):
|
|
|
1686
1916
|
# Move cursor to the new column
|
|
1687
1917
|
self.move_cursor(column=cidx + 1)
|
|
1688
1918
|
|
|
1689
|
-
self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1919
|
+
# self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1690
1920
|
except Exception as e:
|
|
1691
|
-
self.notify(f"
|
|
1921
|
+
self.notify(f"Error adding column: {str(e)}", title="Add Column", severity="error")
|
|
1692
1922
|
raise e
|
|
1693
1923
|
|
|
1694
1924
|
def _add_column_expr(self) -> None:
|
|
@@ -1730,53 +1960,32 @@ class DataFrameTable(DataTable):
|
|
|
1730
1960
|
|
|
1731
1961
|
# self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
|
|
1732
1962
|
except Exception as e:
|
|
1733
|
-
self.notify(f"
|
|
1963
|
+
self.notify(f"Error adding column: [$error]{str(e)}[/]", title="Add Column", severity="error")
|
|
1734
1964
|
raise e
|
|
1735
1965
|
|
|
1736
|
-
def
|
|
1737
|
-
"""Convert string type name to Polars DataType.
|
|
1738
|
-
|
|
1739
|
-
Args:
|
|
1740
|
-
dtype_str: String representation of the type ("string", "int", "float", "bool")
|
|
1741
|
-
|
|
1742
|
-
Returns:
|
|
1743
|
-
Corresponding Polars DataType
|
|
1744
|
-
|
|
1745
|
-
Raises:
|
|
1746
|
-
ValueError: If the type string is not recognized
|
|
1747
|
-
"""
|
|
1748
|
-
dtype_map = {
|
|
1749
|
-
"string": pl.String,
|
|
1750
|
-
"int": pl.Int64,
|
|
1751
|
-
"float": pl.Float64,
|
|
1752
|
-
"bool": pl.Boolean,
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
dtype_lower = dtype_str.lower().strip()
|
|
1756
|
-
return dtype_map.get(dtype_lower)
|
|
1757
|
-
|
|
1758
|
-
def _cast_column_dtype(self, dtype: str | pl.DataType = pl.String) -> None:
|
|
1966
|
+
def _cast_column_dtype(self, dtype: str) -> None:
|
|
1759
1967
|
"""Cast the current column to a different data type.
|
|
1760
1968
|
|
|
1761
1969
|
Args:
|
|
1762
|
-
dtype: Target data type (string
|
|
1970
|
+
dtype: Target data type (string representation, e.g., "pl.String", "pl.Int64")
|
|
1763
1971
|
"""
|
|
1764
1972
|
cidx = self.cursor_col_idx
|
|
1765
1973
|
col_name = self.cursor_col_name
|
|
1766
1974
|
current_dtype = self.df.dtypes[cidx]
|
|
1767
1975
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1976
|
+
try:
|
|
1977
|
+
target_dtype = eval(dtype)
|
|
1978
|
+
except Exception:
|
|
1979
|
+
self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
|
|
1980
|
+
return
|
|
1981
|
+
|
|
1982
|
+
if current_dtype == target_dtype:
|
|
1983
|
+
self.notify(
|
|
1984
|
+
f"Column [$accent]{col_name}[/] is already of type [$success]{target_dtype}[/]",
|
|
1985
|
+
title="Cast",
|
|
1986
|
+
severity="warning",
|
|
1987
|
+
)
|
|
1988
|
+
return # No change needed
|
|
1780
1989
|
|
|
1781
1990
|
# Add to history
|
|
1782
1991
|
self._add_history(
|
|
@@ -1790,13 +1999,13 @@ class DataFrameTable(DataTable):
|
|
|
1790
1999
|
# Recreate the table display
|
|
1791
2000
|
self._setup_table()
|
|
1792
2001
|
|
|
2002
|
+
self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
|
|
2003
|
+
except Exception as e:
|
|
1793
2004
|
self.notify(
|
|
1794
|
-
f"
|
|
2005
|
+
f"Error casting column [$accent]{col_name}[/] to [$success]{target_dtype}[/]: {str(e)}",
|
|
1795
2006
|
title="Cast",
|
|
2007
|
+
severity="error",
|
|
1796
2008
|
)
|
|
1797
|
-
except Exception as e:
|
|
1798
|
-
self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
|
|
1799
|
-
raise e
|
|
1800
2009
|
|
|
1801
2010
|
def _search_cursor_value(self) -> None:
|
|
1802
2011
|
"""Search with cursor value in current column."""
|
|
@@ -1805,7 +2014,7 @@ class DataFrameTable(DataTable):
|
|
|
1805
2014
|
# Get the value of the currently selected cell
|
|
1806
2015
|
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1807
2016
|
|
|
1808
|
-
self._do_search((term, cidx, False,
|
|
2017
|
+
self._do_search((term, cidx, False, True))
|
|
1809
2018
|
|
|
1810
2019
|
def _search_expr(self) -> None:
|
|
1811
2020
|
"""Search by expression."""
|
|
@@ -1824,6 +2033,7 @@ class DataFrameTable(DataTable):
|
|
|
1824
2033
|
"""Search for a term."""
|
|
1825
2034
|
if result is None:
|
|
1826
2035
|
return
|
|
2036
|
+
|
|
1827
2037
|
term, cidx, match_nocase, match_whole = result
|
|
1828
2038
|
col_name = self.df.columns[cidx]
|
|
1829
2039
|
|
|
@@ -1833,12 +2043,10 @@ class DataFrameTable(DataTable):
|
|
|
1833
2043
|
# Support for polars expressions
|
|
1834
2044
|
elif tentative_expr(term):
|
|
1835
2045
|
try:
|
|
1836
|
-
expr = validate_expr(term, self.df, cidx)
|
|
2046
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
1837
2047
|
except Exception as e:
|
|
1838
2048
|
self.notify(
|
|
1839
|
-
f"
|
|
1840
|
-
title="Search",
|
|
1841
|
-
severity="error",
|
|
2049
|
+
f"Error validating expression [$error]{term}[/]: {str(e)}", title="Search", severity="error"
|
|
1842
2050
|
)
|
|
1843
2051
|
return
|
|
1844
2052
|
|
|
@@ -1862,7 +2070,7 @@ class DataFrameTable(DataTable):
|
|
|
1862
2070
|
term = f"(?i){term}"
|
|
1863
2071
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
1864
2072
|
self.notify(
|
|
1865
|
-
f"
|
|
2073
|
+
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1866
2074
|
title="Search",
|
|
1867
2075
|
severity="warning",
|
|
1868
2076
|
)
|
|
@@ -1877,7 +2085,7 @@ class DataFrameTable(DataTable):
|
|
|
1877
2085
|
matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
|
|
1878
2086
|
except Exception as e:
|
|
1879
2087
|
self.notify(
|
|
1880
|
-
f"Error applying search filter: [$error]{str(e)}[/]",
|
|
2088
|
+
f"Error applying search filter [$accent]{term}[/]: [$error]{str(e)}[/]",
|
|
1881
2089
|
title="Search",
|
|
1882
2090
|
severity="error",
|
|
1883
2091
|
)
|
|
@@ -1886,7 +2094,7 @@ class DataFrameTable(DataTable):
|
|
|
1886
2094
|
match_count = len(matches)
|
|
1887
2095
|
if match_count == 0:
|
|
1888
2096
|
self.notify(
|
|
1889
|
-
f"No matches found for [$
|
|
2097
|
+
f"No matches found for [$accent]{term}[/]. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
1890
2098
|
title="Search",
|
|
1891
2099
|
severity="warning",
|
|
1892
2100
|
)
|
|
@@ -1899,11 +2107,12 @@ class DataFrameTable(DataTable):
|
|
|
1899
2107
|
for m in matches:
|
|
1900
2108
|
self.selected_rows[m] = True
|
|
1901
2109
|
|
|
1902
|
-
#
|
|
1903
|
-
self._do_highlight()
|
|
1904
|
-
|
|
2110
|
+
# Show notification immediately, then start highlighting
|
|
1905
2111
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
|
|
1906
2112
|
|
|
2113
|
+
# Start highlighting in a worker to avoid blocking the UI
|
|
2114
|
+
self._do_highlight_async()
|
|
2115
|
+
|
|
1907
2116
|
def _find_matches(
|
|
1908
2117
|
self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
|
|
1909
2118
|
) -> dict[int, set[int]]:
|
|
@@ -1941,7 +2150,7 @@ class DataFrameTable(DataTable):
|
|
|
1941
2150
|
expr = pl.col(col_name).is_null()
|
|
1942
2151
|
elif tentative_expr(term):
|
|
1943
2152
|
try:
|
|
1944
|
-
expr = validate_expr(term, self.df, col_idx)
|
|
2153
|
+
expr = validate_expr(term, self.df.columns, col_idx)
|
|
1945
2154
|
except Exception as e:
|
|
1946
2155
|
raise Exception(f"Error validating Polars expression: {str(e)}")
|
|
1947
2156
|
else:
|
|
@@ -1973,9 +2182,9 @@ class DataFrameTable(DataTable):
|
|
|
1973
2182
|
|
|
1974
2183
|
if scope == "column":
|
|
1975
2184
|
cidx = self.cursor_col_idx
|
|
1976
|
-
self._do_find((term, cidx, False,
|
|
2185
|
+
self._do_find((term, cidx, False, True))
|
|
1977
2186
|
else:
|
|
1978
|
-
self._do_find_global((term, None, False,
|
|
2187
|
+
self._do_find_global((term, None, False, True))
|
|
1979
2188
|
|
|
1980
2189
|
def _find_expr(self, scope="column") -> None:
|
|
1981
2190
|
"""Open screen to find by expression.
|
|
@@ -2004,16 +2213,12 @@ class DataFrameTable(DataTable):
|
|
|
2004
2213
|
try:
|
|
2005
2214
|
matches = self._find_matches(term, cidx, match_nocase, match_whole)
|
|
2006
2215
|
except Exception as e:
|
|
2007
|
-
self.notify(
|
|
2008
|
-
f"Error finding matches: [$error]{str(e)}[/]",
|
|
2009
|
-
title="Find",
|
|
2010
|
-
severity="error",
|
|
2011
|
-
)
|
|
2216
|
+
self.notify(f"Error finding matches for [$error]{term}[/]: {str(e)}", title="Find", severity="error")
|
|
2012
2217
|
return
|
|
2013
2218
|
|
|
2014
2219
|
if not matches:
|
|
2015
2220
|
self.notify(
|
|
2016
|
-
f"No matches found for [$
|
|
2221
|
+
f"No matches found for [$accent]{term}[/] in current column. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
2017
2222
|
title="Find",
|
|
2018
2223
|
severity="warning",
|
|
2019
2224
|
)
|
|
@@ -2027,11 +2232,11 @@ class DataFrameTable(DataTable):
|
|
|
2027
2232
|
for ridx, col_idxs in matches.items():
|
|
2028
2233
|
self.matches[ridx].update(col_idxs)
|
|
2029
2234
|
|
|
2030
|
-
# Highlight matches
|
|
2031
|
-
self._do_highlight()
|
|
2032
|
-
|
|
2033
2235
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
|
|
2034
2236
|
|
|
2237
|
+
# Start highlighting in a worker to avoid blocking the UI
|
|
2238
|
+
self._do_highlight_async()
|
|
2239
|
+
|
|
2035
2240
|
def _do_find_global(self, result) -> None:
|
|
2036
2241
|
"""Global find a term across all columns."""
|
|
2037
2242
|
if result is None:
|
|
@@ -2041,16 +2246,12 @@ class DataFrameTable(DataTable):
|
|
|
2041
2246
|
try:
|
|
2042
2247
|
matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2043
2248
|
except Exception as e:
|
|
2044
|
-
self.notify(
|
|
2045
|
-
f"Error finding matches: [$error]{str(e)}[/]",
|
|
2046
|
-
title="Find",
|
|
2047
|
-
severity="error",
|
|
2048
|
-
)
|
|
2249
|
+
self.notify(f"Error finding matches for [$error]{term}[/]: {str(e)}", title="Find", severity="error")
|
|
2049
2250
|
return
|
|
2050
2251
|
|
|
2051
2252
|
if not matches:
|
|
2052
2253
|
self.notify(
|
|
2053
|
-
f"No matches found for [$
|
|
2254
|
+
f"No matches found for [$accent]{term}[/] in any column. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
2054
2255
|
title="Global Find",
|
|
2055
2256
|
severity="warning",
|
|
2056
2257
|
)
|
|
@@ -2064,25 +2265,12 @@ class DataFrameTable(DataTable):
|
|
|
2064
2265
|
for ridx, col_idxs in matches.items():
|
|
2065
2266
|
self.matches[ridx].update(col_idxs)
|
|
2066
2267
|
|
|
2067
|
-
# Highlight matches
|
|
2068
|
-
self._do_highlight()
|
|
2069
|
-
|
|
2070
2268
|
self.notify(
|
|
2071
|
-
f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
|
|
2072
|
-
title="Global Find",
|
|
2269
|
+
f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
|
|
2073
2270
|
)
|
|
2074
2271
|
|
|
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)
|
|
2272
|
+
# Start highlighting in a worker to avoid blocking the UI
|
|
2273
|
+
self._do_highlight_async()
|
|
2086
2274
|
|
|
2087
2275
|
def _next_match(self) -> None:
|
|
2088
2276
|
"""Move cursor to the next match."""
|
|
@@ -2099,12 +2287,12 @@ class DataFrameTable(DataTable):
|
|
|
2099
2287
|
# Find the next match after current position
|
|
2100
2288
|
for ridx, cidx in ordered_matches:
|
|
2101
2289
|
if (ridx, cidx) > current_pos:
|
|
2102
|
-
self.
|
|
2290
|
+
self.move_cursor_to(ridx, cidx)
|
|
2103
2291
|
return
|
|
2104
2292
|
|
|
2105
2293
|
# If no next match, wrap around to the first match
|
|
2106
2294
|
first_ridx, first_cidx = ordered_matches[0]
|
|
2107
|
-
self.
|
|
2295
|
+
self.move_cursor_to(first_ridx, first_cidx)
|
|
2108
2296
|
|
|
2109
2297
|
def _previous_match(self) -> None:
|
|
2110
2298
|
"""Move cursor to the previous match."""
|
|
@@ -2149,12 +2337,12 @@ class DataFrameTable(DataTable):
|
|
|
2149
2337
|
# Find the next selected row after current position
|
|
2150
2338
|
for ridx in selected_row_indices:
|
|
2151
2339
|
if ridx > current_ridx:
|
|
2152
|
-
self.
|
|
2340
|
+
self.move_cursor_to(ridx, self.cursor_col_idx)
|
|
2153
2341
|
return
|
|
2154
2342
|
|
|
2155
2343
|
# If no next selected row, wrap around to the first selected row
|
|
2156
2344
|
first_ridx = selected_row_indices[0]
|
|
2157
|
-
self.
|
|
2345
|
+
self.move_cursor_to(first_ridx, self.cursor_col_idx)
|
|
2158
2346
|
|
|
2159
2347
|
def _previous_selected_row(self) -> None:
|
|
2160
2348
|
"""Move cursor to the previous selected row."""
|
|
@@ -2171,12 +2359,12 @@ class DataFrameTable(DataTable):
|
|
|
2171
2359
|
# Find the previous selected row before current position
|
|
2172
2360
|
for ridx in reversed(selected_row_indices):
|
|
2173
2361
|
if ridx < current_ridx:
|
|
2174
|
-
self.
|
|
2362
|
+
self.move_cursor_to(ridx, self.cursor_col_idx)
|
|
2175
2363
|
return
|
|
2176
2364
|
|
|
2177
2365
|
# If no previous selected row, wrap around to the last selected row
|
|
2178
2366
|
last_ridx = selected_row_indices[-1]
|
|
2179
|
-
self.
|
|
2367
|
+
self.move_cursor_to(last_ridx, self.cursor_col_idx)
|
|
2180
2368
|
|
|
2181
2369
|
def _replace(self) -> None:
|
|
2182
2370
|
"""Open replace screen for current column."""
|
|
@@ -2222,16 +2410,12 @@ class DataFrameTable(DataTable):
|
|
|
2222
2410
|
matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
|
|
2223
2411
|
|
|
2224
2412
|
if not matches:
|
|
2225
|
-
self.notify(
|
|
2226
|
-
f"No matches found for [$warning]{term_find}[/]",
|
|
2227
|
-
title="Replace",
|
|
2228
|
-
severity="warning",
|
|
2229
|
-
)
|
|
2413
|
+
self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
|
|
2230
2414
|
return
|
|
2231
2415
|
|
|
2232
2416
|
# Add to history
|
|
2233
2417
|
self._add_history(
|
|
2234
|
-
f"
|
|
2418
|
+
f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
|
|
2235
2419
|
)
|
|
2236
2420
|
|
|
2237
2421
|
# Update matches
|
|
@@ -2454,13 +2638,10 @@ class DataFrameTable(DataTable):
|
|
|
2454
2638
|
|
|
2455
2639
|
# Check if we're highlighting or un-highlighting
|
|
2456
2640
|
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
|
-
)
|
|
2641
|
+
self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
|
|
2461
2642
|
|
|
2462
|
-
# Refresh the highlighting
|
|
2463
|
-
self._do_highlight()
|
|
2643
|
+
# Refresh the highlighting
|
|
2644
|
+
self._do_highlight(force=True)
|
|
2464
2645
|
|
|
2465
2646
|
def _make_selections(self) -> None:
|
|
2466
2647
|
"""Make selections based on current matches or toggle current row selection."""
|
|
@@ -2481,10 +2662,10 @@ class DataFrameTable(DataTable):
|
|
|
2481
2662
|
self.notify(f"Selected [$accent]{new_selected_count}[/] rows", title="Toggle")
|
|
2482
2663
|
|
|
2483
2664
|
# Refresh the highlighting (also restores default styles for unselected rows)
|
|
2484
|
-
self._do_highlight()
|
|
2665
|
+
self._do_highlight(force=True)
|
|
2485
2666
|
|
|
2486
|
-
def
|
|
2487
|
-
"""Clear all selected rows without removing them from the dataframe."""
|
|
2667
|
+
def _clear_selections_and_matches(self) -> None:
|
|
2668
|
+
"""Clear all selected rows and matches without removing them from the dataframe."""
|
|
2488
2669
|
# Check if any selected rows or matches
|
|
2489
2670
|
if not any(self.selected_rows) and not self.matches:
|
|
2490
2671
|
self.notify("No selections to clear", title="Clear", severity="warning")
|
|
@@ -2497,8 +2678,12 @@ class DataFrameTable(DataTable):
|
|
|
2497
2678
|
# Save current state to history
|
|
2498
2679
|
self._add_history("Cleared all selected rows")
|
|
2499
2680
|
|
|
2500
|
-
# Clear all selections
|
|
2501
|
-
self.
|
|
2681
|
+
# Clear all selections
|
|
2682
|
+
self.selected_rows = [False] * len(self.df)
|
|
2683
|
+
self.matches = defaultdict(set)
|
|
2684
|
+
|
|
2685
|
+
# Refresh the highlighting to remove all highlights
|
|
2686
|
+
self._do_highlight(force=True)
|
|
2502
2687
|
|
|
2503
2688
|
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
2504
2689
|
|
|
@@ -2519,15 +2704,12 @@ class DataFrameTable(DataTable):
|
|
|
2519
2704
|
# Recreate the table for display
|
|
2520
2705
|
self._setup_table()
|
|
2521
2706
|
|
|
2522
|
-
self.notify(
|
|
2523
|
-
f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
|
|
2524
|
-
title="Filter",
|
|
2525
|
-
)
|
|
2707
|
+
self.notify(f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows", title="Filter")
|
|
2526
2708
|
|
|
2527
2709
|
def _view_rows(self) -> None:
|
|
2528
2710
|
"""View rows.
|
|
2529
2711
|
|
|
2530
|
-
If there are selected rows, view those rows.
|
|
2712
|
+
If there are selected rows or matches, view those rows.
|
|
2531
2713
|
Otherwise, view based on the value of the currently selected cell.
|
|
2532
2714
|
"""
|
|
2533
2715
|
|
|
@@ -2543,7 +2725,7 @@ class DataFrameTable(DataTable):
|
|
|
2543
2725
|
ridx = self.cursor_row_idx
|
|
2544
2726
|
term = str(self.df.item(ridx, cidx))
|
|
2545
2727
|
|
|
2546
|
-
self._do_view_rows((term, cidx, False,
|
|
2728
|
+
self._do_view_rows((term, cidx, False, True))
|
|
2547
2729
|
|
|
2548
2730
|
def _view_rows_expr(self) -> None:
|
|
2549
2731
|
"""Open the filter screen to enter an expression."""
|
|
@@ -2572,10 +2754,10 @@ class DataFrameTable(DataTable):
|
|
|
2572
2754
|
elif tentative_expr(term):
|
|
2573
2755
|
# Support for polars expressions
|
|
2574
2756
|
try:
|
|
2575
|
-
expr = validate_expr(term, self.df, cidx)
|
|
2757
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
2576
2758
|
except Exception as e:
|
|
2577
2759
|
self.notify(
|
|
2578
|
-
f"Error validating
|
|
2760
|
+
f"Error validating expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
|
|
2579
2761
|
)
|
|
2580
2762
|
return
|
|
2581
2763
|
else:
|
|
@@ -2597,9 +2779,7 @@ class DataFrameTable(DataTable):
|
|
|
2597
2779
|
term = f"(?i){term}"
|
|
2598
2780
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
2599
2781
|
self.notify(
|
|
2600
|
-
f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
|
|
2601
|
-
title="Filter",
|
|
2602
|
-
severity="warning",
|
|
2782
|
+
f"Unknown column type [$warning]{dtype}[/]. Cast to string.", title="Filter", severity="warning"
|
|
2603
2783
|
)
|
|
2604
2784
|
|
|
2605
2785
|
# Lazyframe with row indices
|
|
@@ -2613,17 +2793,13 @@ class DataFrameTable(DataTable):
|
|
|
2613
2793
|
try:
|
|
2614
2794
|
df_filtered = lf.filter(expr).collect()
|
|
2615
2795
|
except Exception as e:
|
|
2616
|
-
self.notify(f"
|
|
2796
|
+
self.notify(f"Error applying filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
|
|
2617
2797
|
self.histories.pop() # Remove last history entry
|
|
2618
2798
|
return
|
|
2619
2799
|
|
|
2620
2800
|
matched_count = len(df_filtered)
|
|
2621
2801
|
if not matched_count:
|
|
2622
|
-
self.notify(
|
|
2623
|
-
f"No rows match the expression: [$success]{expr}[/]",
|
|
2624
|
-
title="Filter",
|
|
2625
|
-
severity="warning",
|
|
2626
|
-
)
|
|
2802
|
+
self.notify(f"No rows match the expression: [$success]{expr}[/]", title="Filter", severity="warning")
|
|
2627
2803
|
return
|
|
2628
2804
|
|
|
2629
2805
|
# Add to history
|
|
@@ -2638,11 +2814,9 @@ class DataFrameTable(DataTable):
|
|
|
2638
2814
|
|
|
2639
2815
|
# Recreate the table for display
|
|
2640
2816
|
self._setup_table()
|
|
2817
|
+
self._do_highlight()
|
|
2641
2818
|
|
|
2642
|
-
self.notify(
|
|
2643
|
-
f"Filtered to [$accent]{matched_count}[/] matching rows",
|
|
2644
|
-
title="Filter",
|
|
2645
|
-
)
|
|
2819
|
+
self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
|
|
2646
2820
|
|
|
2647
2821
|
def _cycle_cursor_type(self) -> None:
|
|
2648
2822
|
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
@@ -2716,6 +2890,9 @@ class DataFrameTable(DataTable):
|
|
|
2716
2890
|
filepath = Path(filename)
|
|
2717
2891
|
ext = filepath.suffix.lower()
|
|
2718
2892
|
|
|
2893
|
+
# Add to history
|
|
2894
|
+
self._add_history(f"Saved dataframe to [$success]{filename}[/]")
|
|
2895
|
+
|
|
2719
2896
|
try:
|
|
2720
2897
|
if ext in (".xlsx", ".xls"):
|
|
2721
2898
|
self._do_save_excel(filename)
|
|
@@ -2732,12 +2909,9 @@ class DataFrameTable(DataTable):
|
|
|
2732
2909
|
self.filename = filename # Update current filename
|
|
2733
2910
|
if not self._all_tabs:
|
|
2734
2911
|
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
|
-
)
|
|
2912
|
+
self.notify(f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save")
|
|
2739
2913
|
except Exception as e:
|
|
2740
|
-
self.notify(f"
|
|
2914
|
+
self.notify(f"Error saving [$error]{filename}[/]: {str(e)}", title="Save", severity="error")
|
|
2741
2915
|
raise e
|
|
2742
2916
|
|
|
2743
2917
|
def _do_save_excel(self, filename: str) -> None:
|
|
@@ -2757,12 +2931,103 @@ class DataFrameTable(DataTable):
|
|
|
2757
2931
|
|
|
2758
2932
|
# From ConfirmScreen callback, so notify accordingly
|
|
2759
2933
|
if self._all_tabs is True:
|
|
2934
|
+
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save")
|
|
2935
|
+
else:
|
|
2760
2936
|
self.notify(
|
|
2761
|
-
f"Saved
|
|
2762
|
-
title="Save",
|
|
2937
|
+
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save"
|
|
2763
2938
|
)
|
|
2764
|
-
|
|
2939
|
+
|
|
2940
|
+
def _make_cell_clickable(self) -> None:
|
|
2941
|
+
"""Make cells with URLs in the current column clickable.
|
|
2942
|
+
|
|
2943
|
+
Scans all loaded rows in the current column for cells containing URLs
|
|
2944
|
+
(starting with 'http://' or 'https://') and applies Textual link styling
|
|
2945
|
+
to make them clickable. Does not modify the dataframe.
|
|
2946
|
+
|
|
2947
|
+
Returns:
|
|
2948
|
+
None
|
|
2949
|
+
"""
|
|
2950
|
+
cidx = self.cursor_col_idx
|
|
2951
|
+
col_key = self.cursor_col_key
|
|
2952
|
+
dtype = self.df.dtypes[cidx]
|
|
2953
|
+
|
|
2954
|
+
# Only process string columns
|
|
2955
|
+
if dtype != pl.String:
|
|
2956
|
+
return
|
|
2957
|
+
|
|
2958
|
+
# Count how many URLs were made clickable
|
|
2959
|
+
url_count = 0
|
|
2960
|
+
|
|
2961
|
+
# Iterate through all loaded rows and make URLs clickable
|
|
2962
|
+
for row in self.ordered_rows:
|
|
2963
|
+
cell_text: Text = self.get_cell(row.key, col_key)
|
|
2964
|
+
if cell_text.plain.startswith(("http://", "https://")):
|
|
2965
|
+
cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
|
|
2966
|
+
self.update_cell(row.key, col_key, cell_text)
|
|
2967
|
+
url_count += 1
|
|
2968
|
+
|
|
2969
|
+
if url_count:
|
|
2765
2970
|
self.notify(
|
|
2766
|
-
f"
|
|
2767
|
-
title="Save",
|
|
2971
|
+
f"Use Ctrl/Cmd click to open the links in column [$success]{col_key.value}[/]", title="Hyperlink"
|
|
2768
2972
|
)
|
|
2973
|
+
|
|
2974
|
+
def _simple_sql(self) -> None:
|
|
2975
|
+
"""Open the SQL interface screen."""
|
|
2976
|
+
self.app.push_screen(
|
|
2977
|
+
SimpleSqlScreen(self),
|
|
2978
|
+
callback=self._do_simple_sql,
|
|
2979
|
+
)
|
|
2980
|
+
|
|
2981
|
+
def _do_simple_sql(self, result) -> None:
|
|
2982
|
+
"""Handle SQL result result from SimpleSqlScreen."""
|
|
2983
|
+
if result is None:
|
|
2984
|
+
return
|
|
2985
|
+
columns, where = result
|
|
2986
|
+
|
|
2987
|
+
sql = f"SELECT {columns} FROM self"
|
|
2988
|
+
if where:
|
|
2989
|
+
sql += f" WHERE {where}"
|
|
2990
|
+
|
|
2991
|
+
self._do_sql(sql)
|
|
2992
|
+
|
|
2993
|
+
def _advanced_sql(self) -> None:
|
|
2994
|
+
"""Open the advanced SQL interface screen."""
|
|
2995
|
+
self.app.push_screen(
|
|
2996
|
+
AdvancedSqlScreen(self),
|
|
2997
|
+
callback=self._do_advanced_sql,
|
|
2998
|
+
)
|
|
2999
|
+
|
|
3000
|
+
def _do_advanced_sql(self, result) -> None:
|
|
3001
|
+
"""Handle SQL result result from AdvancedSqlScreen."""
|
|
3002
|
+
if result is None:
|
|
3003
|
+
return
|
|
3004
|
+
|
|
3005
|
+
self._do_sql(result)
|
|
3006
|
+
|
|
3007
|
+
def _do_sql(self, sql: str) -> None:
|
|
3008
|
+
"""Execute a SQL query directly.
|
|
3009
|
+
|
|
3010
|
+
Args:
|
|
3011
|
+
sql: The SQL query string to execute.
|
|
3012
|
+
"""
|
|
3013
|
+
# Add to history
|
|
3014
|
+
self._add_history(f"SQL Query:\n[$accent]{sql}[/]")
|
|
3015
|
+
|
|
3016
|
+
# Execute the SQL query
|
|
3017
|
+
try:
|
|
3018
|
+
self.df = self.df.sql(sql)
|
|
3019
|
+
except Exception as e:
|
|
3020
|
+
self.notify(f"Error executing SQL query [$error]{sql}[/]: {str(e)}", title="SQL Query", severity="error")
|
|
3021
|
+
return
|
|
3022
|
+
|
|
3023
|
+
if not len(self.df):
|
|
3024
|
+
self.notify(f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning")
|
|
3025
|
+
return
|
|
3026
|
+
|
|
3027
|
+
# Recreate the table display
|
|
3028
|
+
self._setup_table()
|
|
3029
|
+
|
|
3030
|
+
self.notify(
|
|
3031
|
+
f"SQL query executed successfully. Now showing [$accent]{len(self.df)}[/] rows and [$accent]{len(self.df.columns)}[/] columns.",
|
|
3032
|
+
title="SQL Query",
|
|
3033
|
+
)
|