dataframe-textual 1.5.0__py3-none-any.whl → 1.10.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataframe_textual/__main__.py +1 -1
- dataframe_textual/common.py +15 -7
- dataframe_textual/data_frame_table.py +1001 -897
- dataframe_textual/data_frame_viewer.py +313 -101
- dataframe_textual/sql_screen.py +47 -11
- dataframe_textual/table_screen.py +56 -58
- dataframe_textual/yes_no_screen.py +68 -7
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-1.10.1.dist-info}/METADATA +106 -245
- dataframe_textual-1.10.1.dist-info/RECORD +14 -0
- dataframe_textual-1.5.0.dist-info/RECORD +0 -14
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-1.10.1.dist-info}/WHEEL +0 -0
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-1.10.1.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-1.10.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -12,6 +12,7 @@ from rich.text import Text
|
|
|
12
12
|
from textual import work
|
|
13
13
|
from textual.coordinate import Coordinate
|
|
14
14
|
from textual.events import Click
|
|
15
|
+
from textual.reactive import reactive
|
|
15
16
|
from textual.render import measure
|
|
16
17
|
from textual.widgets import DataTable, TabPane
|
|
17
18
|
from textual.widgets._data_table import (
|
|
@@ -79,6 +80,7 @@ class History:
|
|
|
79
80
|
fixed_columns: int
|
|
80
81
|
cursor_coordinate: Coordinate
|
|
81
82
|
matches: dict[int, set[int]]
|
|
83
|
+
dirty: bool = False # Whether this history state has unsaved changes
|
|
82
84
|
|
|
83
85
|
|
|
84
86
|
@dataclass
|
|
@@ -112,11 +114,18 @@ class DataFrameTable(DataTable):
|
|
|
112
114
|
- **↑↓←→** - 🎯 Move cursor (cell/row/column)
|
|
113
115
|
- **g** - ⬆️ Jump to first row
|
|
114
116
|
- **G** - ⬇️ Jump to last row
|
|
117
|
+
- **HOME/END** - 🎯 Jump to first/last column
|
|
118
|
+
- **Ctrl+HOME/END** - 🎯 Jump to page top/bottom
|
|
115
119
|
- **Ctrl+F** - 📜 Page down
|
|
116
120
|
- **Ctrl+B** - 📜 Page up
|
|
117
121
|
- **PgUp/PgDn** - 📜 Page up/down
|
|
118
122
|
|
|
119
|
-
##
|
|
123
|
+
## ♻️ Undo/Redo/Reset
|
|
124
|
+
- **u** - ↩️ Undo last action
|
|
125
|
+
- **U** - 🔄 Redo last undone action
|
|
126
|
+
- **Ctrl+U** - 🔁 Reset to initial state
|
|
127
|
+
|
|
128
|
+
## 👁️ Viewing & Display
|
|
120
129
|
- **Enter** - 📋 Show row details in modal
|
|
121
130
|
- **F** - 📊 Show frequency distribution
|
|
122
131
|
- **s** - 📈 Show statistics for current column
|
|
@@ -134,7 +143,7 @@ class DataFrameTable(DataTable):
|
|
|
134
143
|
- **]** - 🔽 Sort column descending
|
|
135
144
|
- *(Multi-column sort supported)*
|
|
136
145
|
|
|
137
|
-
## 🔍
|
|
146
|
+
## 🔍 Searching & Filtering
|
|
138
147
|
- **|** - 🔎 Search in current column with expression
|
|
139
148
|
- **\\\\** - 🔎 Search in current column using cursor value
|
|
140
149
|
- **/** - 🔎 Find in current column with cursor value
|
|
@@ -143,8 +152,8 @@ class DataFrameTable(DataTable):
|
|
|
143
152
|
- **:** - 🌐 Global find with expression
|
|
144
153
|
- **n** - ⬇️ Go to next match
|
|
145
154
|
- **N** - ⬆️ Go to previous match
|
|
146
|
-
- **v** - 👁️ View/filter rows by cell or selected rows
|
|
147
|
-
- **V** - 🔧 View/filter rows by expression
|
|
155
|
+
- **v** - 👁️ View/filter rows by cell or selected rows and hide others
|
|
156
|
+
- **V** - 🔧 View/filter rows by expression and hide others
|
|
148
157
|
- *(All search/find support case-insensitive & whole-word matching)*
|
|
149
158
|
|
|
150
159
|
## ✏️ Replace
|
|
@@ -152,24 +161,25 @@ class DataFrameTable(DataTable):
|
|
|
152
161
|
- **R** - 🔄 Replace across all columns (interactive or all)
|
|
153
162
|
- *(Supports case-insensitive & whole-word matching)*
|
|
154
163
|
|
|
155
|
-
## ✅ Selection &
|
|
164
|
+
## ✅ Selection & Filter
|
|
156
165
|
- **'** - ✓️ Select/deselect current row
|
|
157
166
|
- **t** - 💡 Toggle row selection (invert all)
|
|
167
|
+
- **T** - 🧹 Clear all selections and matches
|
|
158
168
|
- **{** - ⬆️ Go to previous selected row
|
|
159
169
|
- **}** - ⬇️ Go to next selected row
|
|
160
|
-
- **"** - 📍 Filter
|
|
161
|
-
- **T** - 🧹 Clear all selections and matches
|
|
170
|
+
- **"** - 📍 Filter selected rows and remove others
|
|
162
171
|
|
|
163
172
|
## 🔍 SQL Interface
|
|
164
|
-
- **l** - 💬 Open simple SQL interface (select columns &
|
|
173
|
+
- **l** - 💬 Open simple SQL interface (select columns & where clause)
|
|
165
174
|
- **L** - 🔎 Open advanced SQL interface (full SQL queries)
|
|
166
175
|
|
|
167
|
-
## ✏️
|
|
176
|
+
## ✏️ Editing
|
|
168
177
|
- **Double-click** - ✍️ Edit cell or rename column header
|
|
169
178
|
- **e** - ✍️ Edit current cell
|
|
170
179
|
- **E** - 📊 Edit entire column with expression
|
|
171
180
|
- **a** - ➕ Add empty column after current
|
|
172
181
|
- **A** - ➕ Add column with name and optional expression
|
|
182
|
+
- **@** - 🔗 Add a new link column from template
|
|
173
183
|
- **x** - ❌ Delete current row
|
|
174
184
|
- **X** - ❌ Delete row and those below
|
|
175
185
|
- **Ctrl+X** - ❌ Delete row and those above
|
|
@@ -182,23 +192,17 @@ class DataFrameTable(DataTable):
|
|
|
182
192
|
- **Shift+↑↓** - ⬆️⬇️ Move row up/down
|
|
183
193
|
- **Shift+←→** - ⬅️➡️ Move column left/right
|
|
184
194
|
|
|
185
|
-
## 🎨 Type
|
|
195
|
+
## 🎨 Type Casting
|
|
186
196
|
- **#** - 🔢 Cast column to integer
|
|
187
197
|
- **%** - 🔢 Cast column to float
|
|
188
198
|
- **!** - ✅ Cast column to boolean
|
|
189
199
|
- **$** - 📝 Cast column to string
|
|
190
200
|
|
|
191
|
-
##
|
|
192
|
-
- **@** - 🔗 Add a new link column from template expression (e.g., `https://example.com/$_`)
|
|
193
|
-
|
|
194
|
-
## 💾 Data Management
|
|
201
|
+
## 💾 Copy & Save
|
|
195
202
|
- **c** - 📋 Copy cell to clipboard
|
|
196
203
|
- **Ctrl+c** - 📊 Copy column to clipboard
|
|
197
204
|
- **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
|
|
198
205
|
- **Ctrl+s** - 💾 Save current tab to file
|
|
199
|
-
- **u** - ↩️ Undo last action
|
|
200
|
-
- **U** - 🔄 Redo last undone action
|
|
201
|
-
- **Ctrl+U** - 🔁 Reset to initial state
|
|
202
206
|
""").strip()
|
|
203
207
|
|
|
204
208
|
# fmt: off
|
|
@@ -208,6 +212,10 @@ class DataFrameTable(DataTable):
|
|
|
208
212
|
("G", "jump_bottom", "Jump to bottom"),
|
|
209
213
|
("ctrl+f", "forward_page", "Page down"),
|
|
210
214
|
("ctrl+b", "backward_page", "Page up"),
|
|
215
|
+
# Undo/Redo/Reset
|
|
216
|
+
("u", "undo", "Undo"),
|
|
217
|
+
("U", "redo", "Redo"),
|
|
218
|
+
("ctrl+u", "reset", "Reset to initial state"),
|
|
211
219
|
# Display
|
|
212
220
|
("h", "hide_column", "Hide column"),
|
|
213
221
|
("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
|
|
@@ -274,7 +282,7 @@ class DataFrameTable(DataTable):
|
|
|
274
282
|
("shift+right", "move_column_right", "Move column right"),
|
|
275
283
|
("shift+up", "move_row_up", "Move row up"),
|
|
276
284
|
("shift+down", "move_row_down", "Move row down"),
|
|
277
|
-
# Type
|
|
285
|
+
# Type Casting
|
|
278
286
|
("number_sign", "cast_column_dtype('pl.Int64')", "Cast column dtype to integer"), # `#`
|
|
279
287
|
("percent_sign", "cast_column_dtype('pl.Float64')", "Cast column dtype to float"), # `%`
|
|
280
288
|
("exclamation_mark", "cast_column_dtype('pl.Boolean')", "Cast column dtype to bool"), # `!`
|
|
@@ -282,14 +290,13 @@ class DataFrameTable(DataTable):
|
|
|
282
290
|
# Sql
|
|
283
291
|
("l", "simple_sql", "Simple SQL interface"),
|
|
284
292
|
("L", "advanced_sql", "Advanced SQL interface"),
|
|
285
|
-
# Undo/Redo
|
|
286
|
-
("u", "undo", "Undo"),
|
|
287
|
-
("U", "redo", "Redo"),
|
|
288
|
-
("ctrl+u", "reset", "Reset to initial state"),
|
|
289
293
|
]
|
|
290
294
|
# fmt: on
|
|
291
295
|
|
|
292
|
-
|
|
296
|
+
# Track if dataframe has unsaved changes
|
|
297
|
+
dirty: reactive[bool] = reactive(False)
|
|
298
|
+
|
|
299
|
+
def __init__(self, df: pl.DataFrame, filename: str = "", tabname: str = "", **kwargs) -> None:
|
|
293
300
|
"""Initialize the DataFrameTable with a dataframe and manage all state.
|
|
294
301
|
|
|
295
302
|
Sets up the table widget with display configuration, loads the dataframe, and
|
|
@@ -298,19 +305,16 @@ class DataFrameTable(DataTable):
|
|
|
298
305
|
Args:
|
|
299
306
|
df: The Polars DataFrame to display and edit.
|
|
300
307
|
filename: Optional source filename for the data (used in save operations). Defaults to "".
|
|
301
|
-
|
|
308
|
+
tabname: Optional name for the tab displaying this dataframe. Defaults to "".
|
|
302
309
|
**kwargs: Additional keyword arguments passed to the parent DataTable widget.
|
|
303
|
-
|
|
304
|
-
Returns:
|
|
305
|
-
None
|
|
306
310
|
"""
|
|
307
|
-
super().__init__(
|
|
311
|
+
super().__init__(**kwargs)
|
|
308
312
|
|
|
309
313
|
# DataFrame state
|
|
310
314
|
self.dataframe = df # Original dataframe
|
|
311
315
|
self.df = df # Internal/working dataframe
|
|
312
|
-
self.filename = filename # Current filename
|
|
313
|
-
|
|
316
|
+
self.filename = filename or "untitled.csv" # Current filename
|
|
317
|
+
self.tabname = tabname or Path(filename).stem # Tab name
|
|
314
318
|
# Pagination & Loading
|
|
315
319
|
self.INITIAL_BATCH_SIZE = (self.app.size.height // 100 + 1) * 100
|
|
316
320
|
self.BATCH_SIZE = self.INITIAL_BATCH_SIZE // 2
|
|
@@ -332,9 +336,6 @@ class DataFrameTable(DataTable):
|
|
|
332
336
|
# Current history state for redo
|
|
333
337
|
self.history: History = None
|
|
334
338
|
|
|
335
|
-
# Pending filename for save operations
|
|
336
|
-
self._pending_filename = ""
|
|
337
|
-
|
|
338
339
|
# Whether to use thousand separator for numeric display
|
|
339
340
|
self.thousand_separator = False
|
|
340
341
|
|
|
@@ -433,6 +434,15 @@ class DataFrameTable(DataTable):
|
|
|
433
434
|
matches.append((ridx, cidx))
|
|
434
435
|
return matches
|
|
435
436
|
|
|
437
|
+
@property
|
|
438
|
+
def last_history(self) -> History:
|
|
439
|
+
"""Get the last history state.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
History: The most recent History object from the histories deque.
|
|
443
|
+
"""
|
|
444
|
+
return self.histories[-1] if self.histories else None
|
|
445
|
+
|
|
436
446
|
def get_row_key(self, row_idx: int) -> RowKey:
|
|
437
447
|
"""Get the row key for a given table row index.
|
|
438
448
|
|
|
@@ -455,7 +465,7 @@ class DataFrameTable(DataTable):
|
|
|
455
465
|
"""
|
|
456
466
|
return self._column_locations.get_key(col_idx)
|
|
457
467
|
|
|
458
|
-
def
|
|
468
|
+
def should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
|
|
459
469
|
"""Determine if the given cell should be highlighted because of the cursor.
|
|
460
470
|
|
|
461
471
|
In "cell" mode, also highlights the row and column headers. In "row" and "column"
|
|
@@ -498,9 +508,6 @@ class DataFrameTable(DataTable):
|
|
|
498
508
|
Args:
|
|
499
509
|
old_coordinate: The previous cursor coordinate.
|
|
500
510
|
new_coordinate: The new cursor coordinate.
|
|
501
|
-
|
|
502
|
-
Returns:
|
|
503
|
-
None
|
|
504
511
|
"""
|
|
505
512
|
if old_coordinate != new_coordinate:
|
|
506
513
|
# Emit CellSelected message for cell cursor type (keyboard navigation only)
|
|
@@ -538,6 +545,27 @@ class DataFrameTable(DataTable):
|
|
|
538
545
|
else:
|
|
539
546
|
self._scroll_cursor_into_view()
|
|
540
547
|
|
|
548
|
+
def watch_dirty(self, old_dirty: bool, new_dirty: bool) -> None:
|
|
549
|
+
"""Watch for changes to the dirty state and update tab title.
|
|
550
|
+
|
|
551
|
+
When new_dirty is True, set the tab color to red.
|
|
552
|
+
When new_dirty is False, remove the red color.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
old_dirty: The old dirty state.
|
|
556
|
+
new_dirty: The new dirty state.
|
|
557
|
+
"""
|
|
558
|
+
if old_dirty == new_dirty:
|
|
559
|
+
return # No change
|
|
560
|
+
|
|
561
|
+
# Find the corresponding ContentTab
|
|
562
|
+
content_tab = self.app.query_one(f"#--content-tab-{self.id}")
|
|
563
|
+
if content_tab:
|
|
564
|
+
if new_dirty:
|
|
565
|
+
content_tab.add_class("dirty")
|
|
566
|
+
else:
|
|
567
|
+
content_tab.remove_class("dirty")
|
|
568
|
+
|
|
541
569
|
def move_cursor_to(self, ridx: int, cidx: int) -> None:
|
|
542
570
|
"""Move cursor based on the dataframe indices.
|
|
543
571
|
|
|
@@ -547,7 +575,7 @@ class DataFrameTable(DataTable):
|
|
|
547
575
|
"""
|
|
548
576
|
# Ensure the target row is loaded
|
|
549
577
|
if ridx >= self.loaded_rows:
|
|
550
|
-
self.
|
|
578
|
+
self.load_rows(stop=ridx + self.BATCH_SIZE)
|
|
551
579
|
|
|
552
580
|
row_key = str(ridx)
|
|
553
581
|
col_key = self.df.columns[cidx]
|
|
@@ -559,11 +587,8 @@ class DataFrameTable(DataTable):
|
|
|
559
587
|
|
|
560
588
|
Called by Textual when the widget is first added to the display tree.
|
|
561
589
|
Currently a placeholder as table setup is deferred until first use.
|
|
562
|
-
|
|
563
|
-
Returns:
|
|
564
|
-
None
|
|
565
590
|
"""
|
|
566
|
-
# self.
|
|
591
|
+
# self.setup_table()
|
|
567
592
|
pass
|
|
568
593
|
|
|
569
594
|
def on_key(self, event) -> None:
|
|
@@ -574,13 +599,10 @@ class DataFrameTable(DataTable):
|
|
|
574
599
|
|
|
575
600
|
Args:
|
|
576
601
|
event: The key event object.
|
|
577
|
-
|
|
578
|
-
Returns:
|
|
579
|
-
None
|
|
580
602
|
"""
|
|
581
603
|
if event.key in ("pagedown", "down"):
|
|
582
604
|
# Let the table handle the navigation first
|
|
583
|
-
self.
|
|
605
|
+
self.check_and_load_more()
|
|
584
606
|
|
|
585
607
|
def on_click(self, event: Click) -> None:
|
|
586
608
|
"""Handle mouse click events on the table.
|
|
@@ -589,9 +611,6 @@ class DataFrameTable(DataTable):
|
|
|
589
611
|
|
|
590
612
|
Args:
|
|
591
613
|
event: The click event containing row and column information.
|
|
592
|
-
|
|
593
|
-
Returns:
|
|
594
|
-
None
|
|
595
614
|
"""
|
|
596
615
|
if self.cursor_type == "cell" and event.chain > 1: # only on double-click or more
|
|
597
616
|
try:
|
|
@@ -602,9 +621,9 @@ class DataFrameTable(DataTable):
|
|
|
602
621
|
|
|
603
622
|
# header row
|
|
604
623
|
if row_idx == -1:
|
|
605
|
-
self.
|
|
624
|
+
self.do_rename_column()
|
|
606
625
|
else:
|
|
607
|
-
self.
|
|
626
|
+
self.do_edit_cell()
|
|
608
627
|
|
|
609
628
|
# Action handlers for BINDINGS
|
|
610
629
|
def action_jump_top(self) -> None:
|
|
@@ -613,12 +632,12 @@ class DataFrameTable(DataTable):
|
|
|
613
632
|
|
|
614
633
|
def action_jump_bottom(self) -> None:
|
|
615
634
|
"""Jump to the bottom of the table."""
|
|
616
|
-
self.
|
|
635
|
+
self.load_rows(move_to_end=True)
|
|
617
636
|
|
|
618
637
|
def action_forward_page(self) -> None:
|
|
619
638
|
"""Scroll down one page."""
|
|
620
639
|
super().action_page_down()
|
|
621
|
-
self.
|
|
640
|
+
self.check_and_load_more()
|
|
622
641
|
|
|
623
642
|
def action_backward_page(self) -> None:
|
|
624
643
|
"""Scroll up one page."""
|
|
@@ -626,39 +645,39 @@ class DataFrameTable(DataTable):
|
|
|
626
645
|
|
|
627
646
|
def action_view_row_detail(self) -> None:
|
|
628
647
|
"""View details of the current row."""
|
|
629
|
-
self.
|
|
648
|
+
self.do_view_row_detail()
|
|
630
649
|
|
|
631
650
|
def action_delete_column(self) -> None:
|
|
632
651
|
"""Delete the current column."""
|
|
633
|
-
self.
|
|
652
|
+
self.do_delete_column()
|
|
634
653
|
|
|
635
654
|
def action_hide_column(self) -> None:
|
|
636
655
|
"""Hide the current column."""
|
|
637
|
-
self.
|
|
656
|
+
self.do_hide_column()
|
|
638
657
|
|
|
639
658
|
def action_expand_column(self) -> None:
|
|
640
659
|
"""Expand the current column to its full width."""
|
|
641
|
-
self.
|
|
660
|
+
self.do_expand_column()
|
|
642
661
|
|
|
643
662
|
def action_show_hidden_rows_columns(self) -> None:
|
|
644
663
|
"""Show all hidden rows/columns."""
|
|
645
|
-
self.
|
|
664
|
+
self.do_show_hidden_rows_columns()
|
|
646
665
|
|
|
647
666
|
def action_sort_ascending(self) -> None:
|
|
648
667
|
"""Sort by current column in ascending order."""
|
|
649
|
-
self.
|
|
668
|
+
self.do_sort_by_column(descending=False)
|
|
650
669
|
|
|
651
670
|
def action_sort_descending(self) -> None:
|
|
652
671
|
"""Sort by current column in descending order."""
|
|
653
|
-
self.
|
|
672
|
+
self.do_sort_by_column(descending=True)
|
|
654
673
|
|
|
655
674
|
def action_save_to_file(self) -> None:
|
|
656
675
|
"""Save the current dataframe to a file."""
|
|
657
|
-
self.
|
|
676
|
+
self.do_save_to_file()
|
|
658
677
|
|
|
659
678
|
def action_show_frequency(self) -> None:
|
|
660
679
|
"""Show frequency distribution for the current column."""
|
|
661
|
-
self.
|
|
680
|
+
self.do_show_frequency()
|
|
662
681
|
|
|
663
682
|
def action_show_statistics(self, scope: str = "column") -> None:
|
|
664
683
|
"""Show statistics for the current column or entire dataframe.
|
|
@@ -666,51 +685,51 @@ class DataFrameTable(DataTable):
|
|
|
666
685
|
Args:
|
|
667
686
|
scope: Either "column" for current column stats or "dataframe" for all columns.
|
|
668
687
|
"""
|
|
669
|
-
self.
|
|
688
|
+
self.do_show_statistics(scope)
|
|
670
689
|
|
|
671
690
|
def action_view_rows(self) -> None:
|
|
672
691
|
"""View rows by current cell value."""
|
|
673
|
-
self.
|
|
692
|
+
self.do_view_rows()
|
|
674
693
|
|
|
675
694
|
def action_view_rows_expr(self) -> None:
|
|
676
695
|
"""Open the advanced filter screen."""
|
|
677
|
-
self.
|
|
696
|
+
self.do_view_rows_expr()
|
|
678
697
|
|
|
679
698
|
def action_edit_cell(self) -> None:
|
|
680
699
|
"""Edit the current cell."""
|
|
681
|
-
self.
|
|
700
|
+
self.do_edit_cell()
|
|
682
701
|
|
|
683
702
|
def action_edit_column(self) -> None:
|
|
684
703
|
"""Edit the entire current column with an expression."""
|
|
685
|
-
self.
|
|
704
|
+
self.do_edit_column()
|
|
686
705
|
|
|
687
706
|
def action_add_column(self) -> None:
|
|
688
707
|
"""Add an empty column after the current column."""
|
|
689
|
-
self.
|
|
708
|
+
self.do_add_column()
|
|
690
709
|
|
|
691
710
|
def action_add_column_expr(self) -> None:
|
|
692
711
|
"""Add a new column with optional expression after the current column."""
|
|
693
|
-
self.
|
|
712
|
+
self.do_add_column_expr()
|
|
694
713
|
|
|
695
714
|
def action_add_link_column(self) -> None:
|
|
696
715
|
"""Open AddLinkScreen to create a new link column from a Polars expression."""
|
|
697
|
-
self.
|
|
716
|
+
self.do_add_link_column()
|
|
698
717
|
|
|
699
718
|
def action_rename_column(self) -> None:
|
|
700
719
|
"""Rename the current column."""
|
|
701
|
-
self.
|
|
720
|
+
self.do_rename_column()
|
|
702
721
|
|
|
703
722
|
def action_clear_cell(self) -> None:
|
|
704
723
|
"""Clear the current cell (set to None)."""
|
|
705
|
-
self.
|
|
724
|
+
self.do_clear_cell()
|
|
706
725
|
|
|
707
726
|
def action_search_cursor_value(self) -> None:
|
|
708
727
|
"""Search cursor value in the current column."""
|
|
709
|
-
self.
|
|
728
|
+
self.do_search_cursor_value()
|
|
710
729
|
|
|
711
730
|
def action_search_expr(self) -> None:
|
|
712
731
|
"""Search by expression in the current column."""
|
|
713
|
-
self.
|
|
732
|
+
self.do_search_expr()
|
|
714
733
|
|
|
715
734
|
def action_find_cursor_value(self, scope="column") -> None:
|
|
716
735
|
"""Find by cursor value.
|
|
@@ -718,7 +737,7 @@ class DataFrameTable(DataTable):
|
|
|
718
737
|
Args:
|
|
719
738
|
scope: "column" to find in current column, "global" to find across all columns.
|
|
720
739
|
"""
|
|
721
|
-
self.
|
|
740
|
+
self.do_find_cursor_value(scope=scope)
|
|
722
741
|
|
|
723
742
|
def action_find_expr(self, scope="column") -> None:
|
|
724
743
|
"""Find by expression.
|
|
@@ -726,88 +745,87 @@ class DataFrameTable(DataTable):
|
|
|
726
745
|
Args:
|
|
727
746
|
scope: "column" to find in current column, "global" to find across all columns.
|
|
728
747
|
"""
|
|
729
|
-
self.
|
|
748
|
+
self.do_find_expr(scope=scope)
|
|
730
749
|
|
|
731
750
|
def action_replace(self) -> None:
|
|
732
751
|
"""Replace values in current column."""
|
|
733
|
-
self.
|
|
752
|
+
self.do_replace()
|
|
734
753
|
|
|
735
754
|
def action_replace_global(self) -> None:
|
|
736
755
|
"""Replace values across all columns."""
|
|
737
|
-
self.
|
|
756
|
+
self.do_replace_global()
|
|
738
757
|
|
|
739
758
|
def action_toggle_row_selection(self) -> None:
|
|
740
759
|
"""Toggle selection for the current row."""
|
|
741
|
-
self.
|
|
760
|
+
self.do_toggle_row_selection()
|
|
742
761
|
|
|
743
762
|
def action_toggle_selections(self) -> None:
|
|
744
763
|
"""Toggle all row selections."""
|
|
745
|
-
self.
|
|
764
|
+
self.do_toggle_selections()
|
|
746
765
|
|
|
747
766
|
def action_filter_rows(self) -> None:
|
|
748
767
|
"""Filter to show only selected rows."""
|
|
749
|
-
self.
|
|
768
|
+
self.do_filter_rows()
|
|
750
769
|
|
|
751
770
|
def action_delete_row(self) -> None:
|
|
752
771
|
"""Delete the current row."""
|
|
753
|
-
self.
|
|
772
|
+
self.do_delete_row()
|
|
754
773
|
|
|
755
774
|
def action_delete_row_and_below(self) -> None:
|
|
756
775
|
"""Delete the current row and those below."""
|
|
757
|
-
self.
|
|
776
|
+
self.do_delete_row(more="below")
|
|
758
777
|
|
|
759
778
|
def action_delete_row_and_up(self) -> None:
|
|
760
779
|
"""Delete the current row and those above."""
|
|
761
|
-
self.
|
|
780
|
+
self.do_delete_row(more="above")
|
|
762
781
|
|
|
763
782
|
def action_duplicate_column(self) -> None:
|
|
764
783
|
"""Duplicate the current column."""
|
|
765
|
-
self.
|
|
784
|
+
self.do_duplicate_column()
|
|
766
785
|
|
|
767
786
|
def action_duplicate_row(self) -> None:
|
|
768
787
|
"""Duplicate the current row."""
|
|
769
|
-
self.
|
|
788
|
+
self.do_duplicate_row()
|
|
770
789
|
|
|
771
790
|
def action_undo(self) -> None:
|
|
772
791
|
"""Undo the last action."""
|
|
773
|
-
self.
|
|
792
|
+
self.do_undo()
|
|
774
793
|
|
|
775
794
|
def action_redo(self) -> None:
|
|
776
795
|
"""Redo the last undone action."""
|
|
777
|
-
self.
|
|
796
|
+
self.do_redo()
|
|
778
797
|
|
|
779
798
|
def action_reset(self) -> None:
|
|
780
799
|
"""Reset to the initial state."""
|
|
781
|
-
self.
|
|
782
|
-
self.notify("Restored initial state", title="Reset")
|
|
800
|
+
self.do_reset()
|
|
783
801
|
|
|
784
802
|
def action_move_column_left(self) -> None:
|
|
785
803
|
"""Move the current column to the left."""
|
|
786
|
-
self.
|
|
804
|
+
self.do_move_column("left")
|
|
787
805
|
|
|
788
806
|
def action_move_column_right(self) -> None:
|
|
789
807
|
"""Move the current column to the right."""
|
|
790
|
-
self.
|
|
808
|
+
self.do_move_column("right")
|
|
791
809
|
|
|
792
810
|
def action_move_row_up(self) -> None:
|
|
793
811
|
"""Move the current row up."""
|
|
794
|
-
self.
|
|
812
|
+
self.do_move_row("up")
|
|
795
813
|
|
|
796
814
|
def action_move_row_down(self) -> None:
|
|
797
815
|
"""Move the current row down."""
|
|
798
|
-
self.
|
|
816
|
+
self.do_move_row("down")
|
|
799
817
|
|
|
800
818
|
def action_clear_selections_and_matches(self) -> None:
|
|
801
819
|
"""Clear all row selections and matches."""
|
|
802
|
-
self.
|
|
820
|
+
self.do_clear_selections_and_matches()
|
|
803
821
|
|
|
804
822
|
def action_cycle_cursor_type(self) -> None:
|
|
805
823
|
"""Cycle through cursor types."""
|
|
806
|
-
self.
|
|
824
|
+
self.do_cycle_cursor_type()
|
|
807
825
|
|
|
808
826
|
def action_freeze_row_column(self) -> None:
|
|
809
827
|
"""Open the freeze screen."""
|
|
810
|
-
self.
|
|
828
|
+
self.do_freeze_row_column()
|
|
811
829
|
|
|
812
830
|
def action_toggle_row_labels(self) -> None:
|
|
813
831
|
"""Toggle row labels visibility."""
|
|
@@ -817,7 +835,7 @@ class DataFrameTable(DataTable):
|
|
|
817
835
|
|
|
818
836
|
def action_cast_column_dtype(self, dtype: str | pl.DataType) -> None:
|
|
819
837
|
"""Cast the current column to a different data type."""
|
|
820
|
-
self.
|
|
838
|
+
self.do_cast_column_dtype(dtype)
|
|
821
839
|
|
|
822
840
|
def action_copy_cell(self) -> None:
|
|
823
841
|
"""Copy the current cell to clipboard."""
|
|
@@ -826,7 +844,7 @@ class DataFrameTable(DataTable):
|
|
|
826
844
|
|
|
827
845
|
try:
|
|
828
846
|
cell_str = str(self.df.item(ridx, cidx))
|
|
829
|
-
self.
|
|
847
|
+
self.do_copy_to_clipboard(cell_str, f"Copied: [$success]{cell_str[:50]}[/]")
|
|
830
848
|
except IndexError:
|
|
831
849
|
self.notify("Error copying cell", title="Clipboard", severity="error")
|
|
832
850
|
|
|
@@ -839,7 +857,7 @@ class DataFrameTable(DataTable):
|
|
|
839
857
|
col_values = [str(val) for val in self.df[col_name].to_list()]
|
|
840
858
|
col_str = "\n".join(col_values)
|
|
841
859
|
|
|
842
|
-
self.
|
|
860
|
+
self.do_copy_to_clipboard(
|
|
843
861
|
col_str,
|
|
844
862
|
f"Copied [$accent]{len(col_values)}[/] values from column [$success]{col_name}[/]",
|
|
845
863
|
)
|
|
@@ -855,7 +873,7 @@ class DataFrameTable(DataTable):
|
|
|
855
873
|
row_values = [str(val) for val in self.df.row(ridx)]
|
|
856
874
|
row_str = "\t".join(row_values)
|
|
857
875
|
|
|
858
|
-
self.
|
|
876
|
+
self.do_copy_to_clipboard(
|
|
859
877
|
row_str,
|
|
860
878
|
f"Copied row [$accent]{ridx + 1}[/] with [$success]{len(row_values)}[/] values",
|
|
861
879
|
)
|
|
@@ -865,40 +883,61 @@ class DataFrameTable(DataTable):
|
|
|
865
883
|
def action_show_thousand_separator(self) -> None:
|
|
866
884
|
"""Toggle thousand separator for numeric display."""
|
|
867
885
|
self.thousand_separator = not self.thousand_separator
|
|
868
|
-
self.
|
|
886
|
+
self.setup_table()
|
|
869
887
|
# status = "enabled" if self.thousand_separator else "disabled"
|
|
870
888
|
# self.notify(f"Thousand separator {status}", title="Display")
|
|
871
889
|
|
|
872
890
|
def action_next_match(self) -> None:
|
|
873
891
|
"""Go to the next matched cell."""
|
|
874
|
-
self.
|
|
892
|
+
self.do_next_match()
|
|
875
893
|
|
|
876
894
|
def action_previous_match(self) -> None:
|
|
877
895
|
"""Go to the previous matched cell."""
|
|
878
|
-
self.
|
|
896
|
+
self.do_previous_match()
|
|
879
897
|
|
|
880
898
|
def action_next_selected_row(self) -> None:
|
|
881
899
|
"""Go to the next selected row."""
|
|
882
|
-
self.
|
|
900
|
+
self.do_next_selected_row()
|
|
883
901
|
|
|
884
902
|
def action_previous_selected_row(self) -> None:
|
|
885
903
|
"""Go to the previous selected row."""
|
|
886
|
-
self.
|
|
904
|
+
self.do_previous_selected_row()
|
|
887
905
|
|
|
888
906
|
def action_simple_sql(self) -> None:
|
|
889
907
|
"""Open the SQL interface screen."""
|
|
890
|
-
self.
|
|
908
|
+
self.do_simple_sql()
|
|
891
909
|
|
|
892
910
|
def action_advanced_sql(self) -> None:
|
|
893
911
|
"""Open the advanced SQL interface screen."""
|
|
894
|
-
self.
|
|
912
|
+
self.do_advanced_sql()
|
|
895
913
|
|
|
896
914
|
def on_mouse_scroll_down(self, event) -> None:
|
|
897
915
|
"""Load more rows when scrolling down with mouse."""
|
|
898
|
-
self.
|
|
916
|
+
self.check_and_load_more()
|
|
899
917
|
|
|
900
918
|
# Setup & Loading
|
|
901
|
-
def
|
|
919
|
+
def reset_df(self, new_df: pl.DataFrame, dirty: bool = True) -> None:
|
|
920
|
+
"""Reset the dataframe to a new one and refresh the table.
|
|
921
|
+
|
|
922
|
+
Args:
|
|
923
|
+
new_df: The new Polars DataFrame to set.
|
|
924
|
+
dirty: Whether to mark the table as dirty (unsaved changes). Defaults to True.
|
|
925
|
+
"""
|
|
926
|
+
# Set new dataframe and reset table
|
|
927
|
+
self.df = new_df
|
|
928
|
+
self.loaded_rows = 0
|
|
929
|
+
self.sorted_columns = {}
|
|
930
|
+
self.hidden_columns = set()
|
|
931
|
+
self.selected_rows = [False] * len(self.df)
|
|
932
|
+
self.visible_rows = [True] * len(self.df)
|
|
933
|
+
self.fixed_rows = 0
|
|
934
|
+
self.fixed_columns = 0
|
|
935
|
+
self.matches = defaultdict(set)
|
|
936
|
+
self.histories.clear()
|
|
937
|
+
self.history = None
|
|
938
|
+
self.dirty = dirty # Mark as dirty since data changed
|
|
939
|
+
|
|
940
|
+
def setup_table(self, reset: bool = False) -> None:
|
|
902
941
|
"""Setup the table for display.
|
|
903
942
|
|
|
904
943
|
Row keys are 0-based indices, which map directly to dataframe row indices.
|
|
@@ -909,15 +948,7 @@ class DataFrameTable(DataTable):
|
|
|
909
948
|
|
|
910
949
|
# Reset to original dataframe
|
|
911
950
|
if reset:
|
|
912
|
-
self.
|
|
913
|
-
self.loaded_rows = 0
|
|
914
|
-
self.sorted_columns = {}
|
|
915
|
-
self.hidden_columns = set()
|
|
916
|
-
self.selected_rows = [False] * len(self.df)
|
|
917
|
-
self.visible_rows = [True] * len(self.df)
|
|
918
|
-
self.fixed_rows = 0
|
|
919
|
-
self.fixed_columns = 0
|
|
920
|
-
self.matches = defaultdict(set)
|
|
951
|
+
self.reset_df(self.dataframe, dirty=False)
|
|
921
952
|
|
|
922
953
|
# Lazy load up to INITIAL_BATCH_SIZE visible rows
|
|
923
954
|
stop, visible_count = self.INITIAL_BATCH_SIZE, 0
|
|
@@ -938,14 +969,14 @@ class DataFrameTable(DataTable):
|
|
|
938
969
|
# Save current cursor position before clearing
|
|
939
970
|
row_idx, col_idx = self.cursor_coordinate
|
|
940
971
|
|
|
941
|
-
self.
|
|
942
|
-
self.
|
|
972
|
+
self.setup_columns()
|
|
973
|
+
self.load_rows(stop)
|
|
943
974
|
|
|
944
975
|
# Restore cursor position
|
|
945
976
|
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
946
977
|
self.move_cursor(row=row_idx, column=col_idx)
|
|
947
978
|
|
|
948
|
-
def
|
|
979
|
+
def determine_column_widths(self) -> dict[str, int]:
|
|
949
980
|
"""Determine optimal width for each column based on data type and content.
|
|
950
981
|
|
|
951
982
|
For String columns:
|
|
@@ -1015,7 +1046,7 @@ class DataFrameTable(DataTable):
|
|
|
1015
1046
|
|
|
1016
1047
|
return column_widths
|
|
1017
1048
|
|
|
1018
|
-
def
|
|
1049
|
+
def setup_columns(self) -> None:
|
|
1019
1050
|
"""Clear table and setup columns.
|
|
1020
1051
|
|
|
1021
1052
|
Column keys are header names from the dataframe.
|
|
@@ -1024,7 +1055,7 @@ class DataFrameTable(DataTable):
|
|
|
1024
1055
|
self.clear(columns=True)
|
|
1025
1056
|
|
|
1026
1057
|
# Get optimal column widths
|
|
1027
|
-
column_widths = self.
|
|
1058
|
+
column_widths = self.determine_column_widths()
|
|
1028
1059
|
|
|
1029
1060
|
# Add columns with justified headers
|
|
1030
1061
|
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
@@ -1047,7 +1078,7 @@ class DataFrameTable(DataTable):
|
|
|
1047
1078
|
|
|
1048
1079
|
self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
|
|
1049
1080
|
|
|
1050
|
-
def
|
|
1081
|
+
def load_rows(self, stop: int | None = None, move_to_end: bool = False) -> None:
|
|
1051
1082
|
"""Load a batch of rows into the table (synchronous wrapper).
|
|
1052
1083
|
|
|
1053
1084
|
Args:
|
|
@@ -1069,7 +1100,7 @@ class DataFrameTable(DataTable):
|
|
|
1069
1100
|
|
|
1070
1101
|
def _continue(result: bool) -> None:
|
|
1071
1102
|
if result:
|
|
1072
|
-
self.
|
|
1103
|
+
self.load_rows_async(stop, move_to_end=move_to_end)
|
|
1073
1104
|
|
|
1074
1105
|
self.app.push_screen(
|
|
1075
1106
|
ConfirmScreen(
|
|
@@ -1082,10 +1113,10 @@ class DataFrameTable(DataTable):
|
|
|
1082
1113
|
return
|
|
1083
1114
|
|
|
1084
1115
|
# Load rows asynchronously
|
|
1085
|
-
self.
|
|
1116
|
+
self.load_rows_async(stop, move_to_end=move_to_end)
|
|
1086
1117
|
|
|
1087
1118
|
@work(exclusive=True, description="Loading rows...")
|
|
1088
|
-
async def
|
|
1119
|
+
async def load_rows_async(self, stop: int, move_to_end: bool = False) -> None:
|
|
1089
1120
|
"""Perform loading with async to avoid blocking.
|
|
1090
1121
|
|
|
1091
1122
|
Args:
|
|
@@ -1099,19 +1130,19 @@ class DataFrameTable(DataTable):
|
|
|
1099
1130
|
# Load max BATCH_SIZE rows at a time
|
|
1100
1131
|
chunk_size = min(self.BATCH_SIZE, stop - self.loaded_rows)
|
|
1101
1132
|
next_stop = min(self.loaded_rows + chunk_size, stop)
|
|
1102
|
-
self.
|
|
1133
|
+
self.load_rows_batch(next_stop)
|
|
1103
1134
|
|
|
1104
1135
|
# If there's more to load, yield to event loop with delay
|
|
1105
1136
|
if next_stop < stop:
|
|
1106
1137
|
await sleep_async(0.05) # 50ms delay to allow UI updates
|
|
1107
|
-
self.
|
|
1138
|
+
self.load_rows_async(stop, move_to_end=move_to_end)
|
|
1108
1139
|
return
|
|
1109
1140
|
|
|
1110
1141
|
# After loading completes, move cursor to end if requested
|
|
1111
1142
|
if move_to_end:
|
|
1112
1143
|
self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
|
|
1113
1144
|
|
|
1114
|
-
def
|
|
1145
|
+
def load_rows_batch(self, stop: int) -> None:
|
|
1115
1146
|
"""Load a batch of rows into the table.
|
|
1116
1147
|
|
|
1117
1148
|
Row keys are 0-based indices as strings, which map directly to dataframe row indices.
|
|
@@ -1157,7 +1188,7 @@ class DataFrameTable(DataTable):
|
|
|
1157
1188
|
self.notify("Error loading rows", title="Load", severity="error")
|
|
1158
1189
|
self.log(f"Error loading rows: {str(e)}")
|
|
1159
1190
|
|
|
1160
|
-
def
|
|
1191
|
+
def check_and_load_more(self) -> None:
|
|
1161
1192
|
"""Check if we need to load more rows and load them."""
|
|
1162
1193
|
# If we've loaded everything, no need to check
|
|
1163
1194
|
if self.loaded_rows >= len(self.df):
|
|
@@ -1168,10 +1199,10 @@ class DataFrameTable(DataTable):
|
|
|
1168
1199
|
|
|
1169
1200
|
# If visible area is close to the end of loaded rows, load more
|
|
1170
1201
|
if bottom_visible_row >= self.loaded_rows - 10:
|
|
1171
|
-
self.
|
|
1202
|
+
self.load_rows(self.loaded_rows + self.BATCH_SIZE)
|
|
1172
1203
|
|
|
1173
1204
|
# Highlighting
|
|
1174
|
-
def
|
|
1205
|
+
def apply_highlight(self, force: bool = False) -> None:
|
|
1175
1206
|
"""Update all rows, highlighting selected ones and restoring others to default.
|
|
1176
1207
|
|
|
1177
1208
|
Args:
|
|
@@ -1181,10 +1212,10 @@ class DataFrameTable(DataTable):
|
|
|
1181
1212
|
stop = rindex(self.selected_rows, True) + 1
|
|
1182
1213
|
stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
1183
1214
|
|
|
1184
|
-
self.
|
|
1185
|
-
self.
|
|
1215
|
+
self.load_rows(stop)
|
|
1216
|
+
self.highlight_table(force)
|
|
1186
1217
|
|
|
1187
|
-
def
|
|
1218
|
+
def highlight_table(self, force: bool = False) -> None:
|
|
1188
1219
|
"""Highlight selected rows/cells in red."""
|
|
1189
1220
|
if not force and not any(self.selected_rows) and not self.matches:
|
|
1190
1221
|
return # Nothing to highlight
|
|
@@ -1221,7 +1252,7 @@ class DataFrameTable(DataTable):
|
|
|
1221
1252
|
self.update_cell(row.key, col.key, cell_text)
|
|
1222
1253
|
|
|
1223
1254
|
# History & Undo
|
|
1224
|
-
def
|
|
1255
|
+
def create_history(self, description: str) -> None:
|
|
1225
1256
|
"""Create the initial history state."""
|
|
1226
1257
|
return History(
|
|
1227
1258
|
description=description,
|
|
@@ -1236,9 +1267,10 @@ class DataFrameTable(DataTable):
|
|
|
1236
1267
|
fixed_columns=self.fixed_columns,
|
|
1237
1268
|
cursor_coordinate=self.cursor_coordinate,
|
|
1238
1269
|
matches={k: v.copy() for k, v in self.matches.items()},
|
|
1270
|
+
dirty=self.dirty,
|
|
1239
1271
|
)
|
|
1240
1272
|
|
|
1241
|
-
def
|
|
1273
|
+
def apply_history(self, history: History) -> None:
|
|
1242
1274
|
"""Apply the current history state to the table."""
|
|
1243
1275
|
if history is None:
|
|
1244
1276
|
return
|
|
@@ -1255,20 +1287,26 @@ class DataFrameTable(DataTable):
|
|
|
1255
1287
|
self.fixed_columns = history.fixed_columns
|
|
1256
1288
|
self.cursor_coordinate = history.cursor_coordinate
|
|
1257
1289
|
self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
|
|
1290
|
+
self.dirty = history.dirty
|
|
1258
1291
|
|
|
1259
1292
|
# Recreate table for display
|
|
1260
|
-
self.
|
|
1293
|
+
self.setup_table()
|
|
1261
1294
|
|
|
1262
|
-
def
|
|
1295
|
+
def add_history(self, description: str, dirty: bool = False) -> None:
|
|
1263
1296
|
"""Add the current state to the history stack.
|
|
1264
1297
|
|
|
1265
1298
|
Args:
|
|
1266
1299
|
description: Description of the action for this history entry.
|
|
1300
|
+
dirty: Whether this operation modifies the data (True) or just display state (False).
|
|
1267
1301
|
"""
|
|
1268
|
-
history = self.
|
|
1302
|
+
history = self.create_history(description)
|
|
1269
1303
|
self.histories.append(history)
|
|
1270
1304
|
|
|
1271
|
-
|
|
1305
|
+
# Mark table as dirty if this operation modifies data
|
|
1306
|
+
if dirty:
|
|
1307
|
+
self.dirty = True
|
|
1308
|
+
|
|
1309
|
+
def do_undo(self) -> None:
|
|
1272
1310
|
"""Undo the last action."""
|
|
1273
1311
|
if not self.histories:
|
|
1274
1312
|
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
@@ -1278,14 +1316,14 @@ class DataFrameTable(DataTable):
|
|
|
1278
1316
|
history = self.histories.pop()
|
|
1279
1317
|
|
|
1280
1318
|
# Save current state for redo
|
|
1281
|
-
self.history = self.
|
|
1319
|
+
self.history = self.create_history(history.description)
|
|
1282
1320
|
|
|
1283
1321
|
# Restore state
|
|
1284
|
-
self.
|
|
1322
|
+
self.apply_history(history)
|
|
1285
1323
|
|
|
1286
1324
|
self.notify(f"Reverted: {history.description}", title="Undo")
|
|
1287
1325
|
|
|
1288
|
-
def
|
|
1326
|
+
def do_redo(self) -> None:
|
|
1289
1327
|
"""Redo the last undone action."""
|
|
1290
1328
|
if self.history is None:
|
|
1291
1329
|
self.notify("No actions to redo", title="Redo", severity="warning")
|
|
@@ -1294,39 +1332,51 @@ class DataFrameTable(DataTable):
|
|
|
1294
1332
|
description = self.history.description
|
|
1295
1333
|
|
|
1296
1334
|
# Save current state for undo
|
|
1297
|
-
self.
|
|
1335
|
+
self.add_history(description)
|
|
1298
1336
|
|
|
1299
1337
|
# Restore state
|
|
1300
|
-
self.
|
|
1338
|
+
self.apply_history(self.history)
|
|
1301
1339
|
|
|
1302
1340
|
# Clear redo state
|
|
1303
1341
|
self.history = None
|
|
1304
1342
|
|
|
1305
1343
|
self.notify(f"Reapplied: {description}", title="Redo")
|
|
1306
1344
|
|
|
1345
|
+
def do_reset(self) -> None:
|
|
1346
|
+
"""Reset the table to the initial state."""
|
|
1347
|
+
self.setup_table(reset=True)
|
|
1348
|
+
self.notify("Restored initial state", title="Reset")
|
|
1349
|
+
|
|
1350
|
+
def restore_dirty(self, default: bool | None = None) -> None:
|
|
1351
|
+
"""Restore the dirty state from the last history entry."""
|
|
1352
|
+
if self.last_history:
|
|
1353
|
+
self.dirty = self.last_history.dirty
|
|
1354
|
+
elif default is not None:
|
|
1355
|
+
self.dirty = default
|
|
1356
|
+
|
|
1307
1357
|
# Display
|
|
1308
|
-
def
|
|
1358
|
+
def do_cycle_cursor_type(self) -> None:
|
|
1309
1359
|
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
1310
1360
|
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
1311
1361
|
self.cursor_type = next_type
|
|
1312
1362
|
|
|
1313
1363
|
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
1314
1364
|
|
|
1315
|
-
def
|
|
1365
|
+
def do_view_row_detail(self) -> None:
|
|
1316
1366
|
"""Open a modal screen to view the selected row's details."""
|
|
1317
1367
|
ridx = self.cursor_row_idx
|
|
1318
1368
|
|
|
1319
1369
|
# Push the modal screen
|
|
1320
1370
|
self.app.push_screen(RowDetailScreen(ridx, self))
|
|
1321
1371
|
|
|
1322
|
-
def
|
|
1372
|
+
def do_show_frequency(self) -> None:
|
|
1323
1373
|
"""Show frequency distribution for the current column."""
|
|
1324
1374
|
cidx = self.cursor_col_idx
|
|
1325
1375
|
|
|
1326
1376
|
# Push the frequency modal screen
|
|
1327
1377
|
self.app.push_screen(FrequencyScreen(cidx, self))
|
|
1328
1378
|
|
|
1329
|
-
def
|
|
1379
|
+
def do_show_statistics(self, scope: str = "column") -> None:
|
|
1330
1380
|
"""Show statistics for the current column or entire dataframe.
|
|
1331
1381
|
|
|
1332
1382
|
Args:
|
|
@@ -1340,11 +1390,11 @@ class DataFrameTable(DataTable):
|
|
|
1340
1390
|
cidx = self.cursor_col_idx
|
|
1341
1391
|
self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
|
|
1342
1392
|
|
|
1343
|
-
def
|
|
1393
|
+
def do_freeze_row_column(self) -> None:
|
|
1344
1394
|
"""Open the freeze screen to set fixed rows and columns."""
|
|
1345
|
-
self.app.push_screen(FreezeScreen(), callback=self.
|
|
1395
|
+
self.app.push_screen(FreezeScreen(), callback=self.freeze_row_column)
|
|
1346
1396
|
|
|
1347
|
-
def
|
|
1397
|
+
def freeze_row_column(self, result: tuple[int, int] | None) -> None:
|
|
1348
1398
|
"""Handle result from PinScreen.
|
|
1349
1399
|
|
|
1350
1400
|
Args:
|
|
@@ -1356,7 +1406,7 @@ class DataFrameTable(DataTable):
|
|
|
1356
1406
|
fixed_rows, fixed_columns = result
|
|
1357
1407
|
|
|
1358
1408
|
# Add to history
|
|
1359
|
-
self.
|
|
1409
|
+
self.add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
|
|
1360
1410
|
|
|
1361
1411
|
# Apply the pin settings to the table
|
|
1362
1412
|
if fixed_rows >= 0:
|
|
@@ -1366,14 +1416,14 @@ class DataFrameTable(DataTable):
|
|
|
1366
1416
|
|
|
1367
1417
|
# self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
|
|
1368
1418
|
|
|
1369
|
-
def
|
|
1419
|
+
def do_hide_column(self) -> None:
|
|
1370
1420
|
"""Hide the currently selected column from the table display."""
|
|
1371
1421
|
col_key = self.cursor_col_key
|
|
1372
1422
|
col_name = col_key.value
|
|
1373
1423
|
col_idx = self.cursor_column
|
|
1374
1424
|
|
|
1375
1425
|
# Add to history
|
|
1376
|
-
self.
|
|
1426
|
+
self.add_history(f"Hid column [$success]{col_name}[/]")
|
|
1377
1427
|
|
|
1378
1428
|
# Remove the column from the table display (but keep in dataframe)
|
|
1379
1429
|
self.remove_column(col_key)
|
|
@@ -1387,7 +1437,7 @@ class DataFrameTable(DataTable):
|
|
|
1387
1437
|
|
|
1388
1438
|
# self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
|
|
1389
1439
|
|
|
1390
|
-
def
|
|
1440
|
+
def do_expand_column(self) -> None:
|
|
1391
1441
|
"""Expand the current column to show the widest cell in the loaded data."""
|
|
1392
1442
|
col_idx = self.cursor_col_idx
|
|
1393
1443
|
col_key = self.cursor_col_key
|
|
@@ -1424,7 +1474,7 @@ class DataFrameTable(DataTable):
|
|
|
1424
1474
|
self.notify("Error expanding column", title="Expand", severity="error")
|
|
1425
1475
|
self.log(f"Error expanding column `{col_name}`: {str(e)}")
|
|
1426
1476
|
|
|
1427
|
-
def
|
|
1477
|
+
def do_show_hidden_rows_columns(self) -> None:
|
|
1428
1478
|
"""Show all hidden rows/columns by recreating the table."""
|
|
1429
1479
|
# Get currently visible columns
|
|
1430
1480
|
visible_cols = set(col.key for col in self.ordered_columns)
|
|
@@ -1437,760 +1487,761 @@ class DataFrameTable(DataTable):
|
|
|
1437
1487
|
return
|
|
1438
1488
|
|
|
1439
1489
|
# Add to history
|
|
1440
|
-
self.
|
|
1490
|
+
self.add_history("Showed hidden rows/columns")
|
|
1441
1491
|
|
|
1442
1492
|
# Clear hidden rows/columns tracking
|
|
1443
1493
|
self.visible_rows = [True] * len(self.df)
|
|
1444
1494
|
self.hidden_columns.clear()
|
|
1445
1495
|
|
|
1446
1496
|
# Recreate table for display
|
|
1447
|
-
self.
|
|
1497
|
+
self.setup_table()
|
|
1448
1498
|
|
|
1449
1499
|
self.notify(
|
|
1450
1500
|
f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
|
|
1451
1501
|
title="Show",
|
|
1452
1502
|
)
|
|
1453
1503
|
|
|
1454
|
-
#
|
|
1455
|
-
def
|
|
1456
|
-
"""
|
|
1457
|
-
# Get the column to remove
|
|
1458
|
-
col_idx = self.cursor_column
|
|
1459
|
-
col_name = self.cursor_col_name
|
|
1460
|
-
col_key = self.cursor_col_key
|
|
1461
|
-
|
|
1462
|
-
col_names_to_remove = []
|
|
1463
|
-
col_keys_to_remove = []
|
|
1504
|
+
# Sort
|
|
1505
|
+
def do_sort_by_column(self, descending: bool = False) -> None:
|
|
1506
|
+
"""Sort by the currently selected column.
|
|
1464
1507
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
col_key = self.get_column_key(i)
|
|
1469
|
-
col_names_to_remove.append(col_key.value)
|
|
1470
|
-
col_keys_to_remove.append(col_key)
|
|
1508
|
+
Supports multi-column sorting:
|
|
1509
|
+
- First press on a column: sort by that column only
|
|
1510
|
+
- Subsequent presses on other columns: add to sort order
|
|
1471
1511
|
|
|
1472
|
-
|
|
1512
|
+
Args:
|
|
1513
|
+
descending: If True, sort in descending order. If False, ascending order.
|
|
1514
|
+
"""
|
|
1515
|
+
col_name = self.cursor_col_name
|
|
1516
|
+
col_idx = self.cursor_column
|
|
1473
1517
|
|
|
1474
|
-
#
|
|
1475
|
-
|
|
1476
|
-
for i in range(col_idx, len(self.columns)):
|
|
1477
|
-
col_key = self.get_column_key(i)
|
|
1478
|
-
col_names_to_remove.append(col_key.value)
|
|
1479
|
-
col_keys_to_remove.append(col_key)
|
|
1518
|
+
# Check if this column is already in the sort keys
|
|
1519
|
+
old_desc = self.sorted_columns.get(col_name)
|
|
1480
1520
|
|
|
1481
|
-
|
|
1521
|
+
# Add to history
|
|
1522
|
+
self.add_history(f"Sorted on column [$success]{col_name}[/]", dirty=True)
|
|
1523
|
+
if old_desc is None:
|
|
1524
|
+
# Add new column to sort
|
|
1525
|
+
self.sorted_columns[col_name] = descending
|
|
1526
|
+
elif old_desc == descending:
|
|
1527
|
+
# Same direction - remove from sort
|
|
1528
|
+
del self.sorted_columns[col_name]
|
|
1529
|
+
else:
|
|
1530
|
+
# Move to end of sort order
|
|
1531
|
+
del self.sorted_columns[col_name]
|
|
1532
|
+
self.sorted_columns[col_name] = descending
|
|
1482
1533
|
|
|
1483
|
-
#
|
|
1534
|
+
# Apply multi-column sort
|
|
1535
|
+
if sort_cols := list(self.sorted_columns.keys()):
|
|
1536
|
+
descending_flags = list(self.sorted_columns.values())
|
|
1537
|
+
df_sorted = self.df.with_row_index(RIDX).sort(sort_cols, descending=descending_flags, nulls_last=True)
|
|
1484
1538
|
else:
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
message = f"Removed column [$success]{col_name}[/]"
|
|
1539
|
+
# No sort columns - restore original order
|
|
1540
|
+
df_sorted = self.df.with_row_index(RIDX)
|
|
1488
1541
|
|
|
1489
|
-
#
|
|
1490
|
-
|
|
1542
|
+
# Updated selected_rows and visible_rows to match new order
|
|
1543
|
+
old_row_indices = df_sorted[RIDX].to_list()
|
|
1544
|
+
self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
|
|
1545
|
+
self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
|
|
1491
1546
|
|
|
1492
|
-
#
|
|
1493
|
-
|
|
1494
|
-
self.remove_column(ck)
|
|
1547
|
+
# Update the dataframe
|
|
1548
|
+
self.df = df_sorted.drop(RIDX)
|
|
1495
1549
|
|
|
1496
|
-
#
|
|
1497
|
-
|
|
1498
|
-
if col_idx > last_col_idx:
|
|
1499
|
-
self.move_cursor(column=last_col_idx)
|
|
1550
|
+
# Recreate table for display
|
|
1551
|
+
self.setup_table()
|
|
1500
1552
|
|
|
1501
|
-
#
|
|
1502
|
-
|
|
1503
|
-
if col_name in self.sorted_columns:
|
|
1504
|
-
del self.sorted_columns[col_name]
|
|
1553
|
+
# Restore cursor position on the sorted column
|
|
1554
|
+
self.move_cursor(column=col_idx, row=0)
|
|
1505
1555
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
if not self.matches[row_idx]:
|
|
1512
|
-
del self.matches[row_idx]
|
|
1556
|
+
# Edit
|
|
1557
|
+
def do_edit_cell(self, ridx: int = None, cidx: int = None) -> None:
|
|
1558
|
+
"""Open modal to edit the selected cell."""
|
|
1559
|
+
ridx = self.cursor_row_idx if ridx is None else ridx
|
|
1560
|
+
cidx = self.cursor_col_idx if cidx is None else cidx
|
|
1513
1561
|
|
|
1514
|
-
#
|
|
1515
|
-
self.
|
|
1562
|
+
# Push the edit modal screen
|
|
1563
|
+
self.app.push_screen(
|
|
1564
|
+
EditCellScreen(ridx, cidx, self.df),
|
|
1565
|
+
callback=self.edit_cell,
|
|
1566
|
+
)
|
|
1516
1567
|
|
|
1517
|
-
|
|
1568
|
+
def edit_cell(self, result) -> None:
|
|
1569
|
+
"""Handle result from EditCellScreen."""
|
|
1570
|
+
if result is None:
|
|
1571
|
+
return
|
|
1518
1572
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1573
|
+
ridx, cidx, new_value = result
|
|
1574
|
+
if new_value is None:
|
|
1575
|
+
self.app.push_screen(
|
|
1576
|
+
EditCellScreen(ridx, cidx, self.df),
|
|
1577
|
+
callback=self.edit_cell,
|
|
1578
|
+
)
|
|
1579
|
+
return
|
|
1523
1580
|
|
|
1524
|
-
|
|
1525
|
-
new_col_name = f"{col_name}_copy"
|
|
1581
|
+
col_name = self.df.columns[cidx]
|
|
1526
1582
|
|
|
1527
1583
|
# Add to history
|
|
1528
|
-
self.
|
|
1529
|
-
|
|
1530
|
-
# Create new column and reorder columns to insert after current column
|
|
1531
|
-
cols_before = self.df.columns[: cidx + 1]
|
|
1532
|
-
cols_after = self.df.columns[cidx + 1 :]
|
|
1533
|
-
|
|
1534
|
-
# Add the new column and reorder columns for insertion after current column
|
|
1535
|
-
self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
|
|
1536
|
-
list(cols_before) + [new_col_name] + list(cols_after)
|
|
1537
|
-
)
|
|
1584
|
+
self.add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]", dirty=True)
|
|
1538
1585
|
|
|
1539
|
-
# Update
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
new_cols.add(col_idx_in_set + 1)
|
|
1548
|
-
new_matches[row_idx] = new_cols
|
|
1549
|
-
self.matches = new_matches
|
|
1586
|
+
# Update the cell in the dataframe
|
|
1587
|
+
try:
|
|
1588
|
+
self.df = self.df.with_columns(
|
|
1589
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
1590
|
+
.then(pl.lit(new_value))
|
|
1591
|
+
.otherwise(pl.col(col_name))
|
|
1592
|
+
.alias(col_name)
|
|
1593
|
+
)
|
|
1550
1594
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1595
|
+
# Update the display
|
|
1596
|
+
cell_value = self.df.item(ridx, cidx)
|
|
1597
|
+
if cell_value is None:
|
|
1598
|
+
cell_value = NULL_DISPLAY
|
|
1599
|
+
dtype = self.df.dtypes[cidx]
|
|
1600
|
+
dc = DtypeConfig(dtype)
|
|
1601
|
+
formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
|
|
1553
1602
|
|
|
1554
|
-
|
|
1555
|
-
|
|
1603
|
+
# string as keys
|
|
1604
|
+
row_key = str(ridx)
|
|
1605
|
+
col_key = col_name
|
|
1606
|
+
self.update_cell(row_key, col_key, formatted_value, update_width=True)
|
|
1556
1607
|
|
|
1557
|
-
|
|
1608
|
+
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1609
|
+
except Exception as e:
|
|
1610
|
+
self.notify("Error updating cell", title="Edit", severity="error")
|
|
1611
|
+
self.log(f"Error updating cell: {str(e)}")
|
|
1558
1612
|
|
|
1559
|
-
def
|
|
1560
|
-
"""
|
|
1613
|
+
def do_edit_column(self) -> None:
|
|
1614
|
+
"""Open modal to edit the entire column with an expression."""
|
|
1615
|
+
cidx = self.cursor_col_idx
|
|
1561
1616
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1617
|
+
# Push the edit column modal screen
|
|
1618
|
+
self.app.push_screen(
|
|
1619
|
+
EditColumnScreen(cidx, self.df),
|
|
1620
|
+
callback=self.edit_column,
|
|
1621
|
+
)
|
|
1566
1622
|
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1623
|
+
def edit_column(self, result) -> None:
|
|
1624
|
+
"""Edit a column."""
|
|
1625
|
+
if result is None:
|
|
1626
|
+
return
|
|
1627
|
+
term, cidx = result
|
|
1570
1628
|
|
|
1571
|
-
|
|
1572
|
-
if selected:
|
|
1573
|
-
predicates[ridx] = False
|
|
1629
|
+
col_name = self.df.columns[cidx]
|
|
1574
1630
|
|
|
1575
|
-
#
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
|
|
1579
|
-
for i in range(ridx + 1):
|
|
1580
|
-
predicates[i] = False
|
|
1631
|
+
# Null case
|
|
1632
|
+
if term is None or term == NULL:
|
|
1633
|
+
expr = pl.lit(None)
|
|
1581
1634
|
|
|
1582
|
-
#
|
|
1583
|
-
elif
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1635
|
+
# Check if term is a valid expression
|
|
1636
|
+
elif tentative_expr(term):
|
|
1637
|
+
try:
|
|
1638
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
1639
|
+
except Exception as e:
|
|
1640
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Edit", severity="error")
|
|
1641
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1642
|
+
return
|
|
1589
1643
|
|
|
1590
|
-
#
|
|
1644
|
+
# Otherwise, treat term as a literal value
|
|
1591
1645
|
else:
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1646
|
+
dtype = self.df.dtypes[cidx]
|
|
1647
|
+
try:
|
|
1648
|
+
value = DtypeConfig(dtype).convert(term)
|
|
1649
|
+
expr = pl.lit(value)
|
|
1650
|
+
except Exception:
|
|
1651
|
+
self.notify(
|
|
1652
|
+
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1653
|
+
title="Edit",
|
|
1654
|
+
severity="error",
|
|
1655
|
+
)
|
|
1656
|
+
expr = pl.lit(str(term))
|
|
1596
1657
|
|
|
1597
1658
|
# Add to history
|
|
1598
|
-
self.
|
|
1659
|
+
self.add_history(f"Edited column [$accent]{col_name}[/] with expression", dirty=True)
|
|
1599
1660
|
|
|
1600
|
-
# Apply the filter to remove rows
|
|
1601
1661
|
try:
|
|
1602
|
-
|
|
1662
|
+
# Apply the expression to the column
|
|
1663
|
+
self.df = self.df.with_columns(expr.alias(col_name))
|
|
1603
1664
|
except Exception as e:
|
|
1604
|
-
self.notify(
|
|
1605
|
-
|
|
1665
|
+
self.notify(
|
|
1666
|
+
f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
|
|
1667
|
+
title="Edit",
|
|
1668
|
+
severity="error",
|
|
1669
|
+
)
|
|
1670
|
+
self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
|
|
1606
1671
|
return
|
|
1607
1672
|
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
# Update selected and visible rows tracking
|
|
1611
|
-
old_row_indices = set(df[RIDX].to_list())
|
|
1612
|
-
self.selected_rows = [selected for i, selected in enumerate(self.selected_rows) if i in old_row_indices]
|
|
1613
|
-
self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
|
|
1673
|
+
# Recreate table for display
|
|
1674
|
+
self.setup_table()
|
|
1614
1675
|
|
|
1615
|
-
#
|
|
1616
|
-
self.matches = defaultdict(set)
|
|
1676
|
+
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
|
|
1617
1677
|
|
|
1618
|
-
|
|
1619
|
-
|
|
1678
|
+
def do_rename_column(self) -> None:
|
|
1679
|
+
"""Open modal to rename the selected column."""
|
|
1680
|
+
col_name = self.cursor_col_name
|
|
1681
|
+
col_idx = self.cursor_column
|
|
1620
1682
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1683
|
+
# Push the rename column modal screen
|
|
1684
|
+
self.app.push_screen(
|
|
1685
|
+
RenameColumnScreen(col_idx, col_name, self.df.columns),
|
|
1686
|
+
callback=self.rename_column,
|
|
1687
|
+
)
|
|
1624
1688
|
|
|
1625
|
-
def
|
|
1626
|
-
"""
|
|
1627
|
-
|
|
1689
|
+
def rename_column(self, result) -> None:
|
|
1690
|
+
"""Handle result from RenameColumnScreen."""
|
|
1691
|
+
if result is None:
|
|
1692
|
+
return
|
|
1628
1693
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1694
|
+
col_idx, col_name, new_name = result
|
|
1695
|
+
if new_name is None:
|
|
1696
|
+
self.app.push_screen(
|
|
1697
|
+
RenameColumnScreen(col_idx, col_name, self.df.columns),
|
|
1698
|
+
callback=self.rename_column,
|
|
1699
|
+
)
|
|
1700
|
+
return
|
|
1631
1701
|
|
|
1632
1702
|
# Add to history
|
|
1633
|
-
self.
|
|
1634
|
-
|
|
1635
|
-
# Concatenate: rows before + duplicated row + rows after
|
|
1636
|
-
df_before = self.df.slice(0, ridx + 1)
|
|
1637
|
-
df_after = self.df.slice(ridx + 1)
|
|
1703
|
+
self.add_history(f"Renamed column [$accent]{col_name}[/] to [$success]{new_name}[/]", dirty=True)
|
|
1638
1704
|
|
|
1639
|
-
#
|
|
1640
|
-
self.df =
|
|
1705
|
+
# Rename the column in the dataframe
|
|
1706
|
+
self.df = self.df.rename({col_name: new_name})
|
|
1641
1707
|
|
|
1642
|
-
# Update
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
self.selected_rows = new_selected_rows
|
|
1646
|
-
self.visible_rows = new_visible_rows
|
|
1708
|
+
# Update sorted_columns if this column was sorted
|
|
1709
|
+
if col_name in self.sorted_columns:
|
|
1710
|
+
self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
|
|
1647
1711
|
|
|
1648
|
-
# Update
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
new_matches[row_idx] = cols
|
|
1653
|
-
else:
|
|
1654
|
-
new_matches[row_idx + 1] = cols
|
|
1655
|
-
self.matches = new_matches
|
|
1712
|
+
# Update hidden_columns if this column was hidden
|
|
1713
|
+
if col_name in self.hidden_columns:
|
|
1714
|
+
self.hidden_columns.remove(col_name)
|
|
1715
|
+
self.hidden_columns.add(new_name)
|
|
1656
1716
|
|
|
1657
1717
|
# Recreate table for display
|
|
1658
|
-
self.
|
|
1659
|
-
|
|
1660
|
-
# Move cursor to the new duplicated row
|
|
1661
|
-
self.move_cursor(row=ridx + 1)
|
|
1718
|
+
self.setup_table()
|
|
1662
1719
|
|
|
1663
|
-
#
|
|
1720
|
+
# Move cursor to the renamed column
|
|
1721
|
+
self.move_cursor(column=col_idx)
|
|
1664
1722
|
|
|
1665
|
-
|
|
1666
|
-
"""Move the current column left or right.
|
|
1723
|
+
# self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
|
|
1667
1724
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
col_key = self.cursor_col_key
|
|
1673
|
-
col_name = col_key.value
|
|
1725
|
+
def do_clear_cell(self) -> None:
|
|
1726
|
+
"""Clear the current cell by setting its value to None."""
|
|
1727
|
+
row_key, col_key = self.cursor_key
|
|
1728
|
+
ridx = self.cursor_row_idx
|
|
1674
1729
|
cidx = self.cursor_col_idx
|
|
1675
|
-
|
|
1676
|
-
# Validate move is possible
|
|
1677
|
-
if direction == "left":
|
|
1678
|
-
if col_idx <= 0:
|
|
1679
|
-
self.notify("Cannot move column left", title="Move", severity="warning")
|
|
1680
|
-
return
|
|
1681
|
-
swap_idx = col_idx - 1
|
|
1682
|
-
elif direction == "right":
|
|
1683
|
-
if col_idx >= len(self.columns) - 1:
|
|
1684
|
-
self.notify("Cannot move column right", title="Move", severity="warning")
|
|
1685
|
-
return
|
|
1686
|
-
swap_idx = col_idx + 1
|
|
1687
|
-
|
|
1688
|
-
# Get column to swap
|
|
1689
|
-
_, swap_key = self.coordinate_to_cell_key(Coordinate(row_idx, swap_idx))
|
|
1690
|
-
swap_name = swap_key.value
|
|
1691
|
-
swap_cidx = self.df.columns.index(swap_name)
|
|
1730
|
+
col_name = self.cursor_col_name
|
|
1692
1731
|
|
|
1693
1732
|
# Add to history
|
|
1694
|
-
self.
|
|
1695
|
-
|
|
1696
|
-
# Swap columns in the table's internal column locations
|
|
1697
|
-
self.check_idle()
|
|
1698
|
-
|
|
1699
|
-
(
|
|
1700
|
-
self._column_locations[col_key],
|
|
1701
|
-
self._column_locations[swap_key],
|
|
1702
|
-
) = (
|
|
1703
|
-
self._column_locations.get(swap_key),
|
|
1704
|
-
self._column_locations.get(col_key),
|
|
1705
|
-
)
|
|
1706
|
-
|
|
1707
|
-
self._update_count += 1
|
|
1708
|
-
self.refresh()
|
|
1733
|
+
self.add_history(f"Cleared cell [$success]({ridx + 1}, {col_name})[/]", dirty=True)
|
|
1709
1734
|
|
|
1710
|
-
#
|
|
1711
|
-
|
|
1735
|
+
# Update the cell to None in the dataframe
|
|
1736
|
+
try:
|
|
1737
|
+
self.df = self.df.with_columns(
|
|
1738
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
1739
|
+
.then(pl.lit(None))
|
|
1740
|
+
.otherwise(pl.col(col_name))
|
|
1741
|
+
.alias(col_name)
|
|
1742
|
+
)
|
|
1712
1743
|
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1744
|
+
# Update the display
|
|
1745
|
+
dtype = self.df.dtypes[cidx]
|
|
1746
|
+
dc = DtypeConfig(dtype)
|
|
1747
|
+
formatted_value = Text(NULL_DISPLAY, style=dc.style, justify=dc.justify)
|
|
1717
1748
|
|
|
1718
|
-
|
|
1749
|
+
self.update_cell(row_key, col_key, formatted_value)
|
|
1719
1750
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1751
|
+
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
1752
|
+
except Exception as e:
|
|
1753
|
+
self.notify("Error clearing cell", title="Clear", severity="error")
|
|
1754
|
+
self.log(f"Error clearing cell: {str(e)}")
|
|
1755
|
+
raise e
|
|
1722
1756
|
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
row_idx, col_idx = self.cursor_coordinate
|
|
1757
|
+
def do_add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
|
|
1758
|
+
"""Add acolumn after the current column."""
|
|
1759
|
+
cidx = self.cursor_col_idx
|
|
1727
1760
|
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
self.notify("Cannot move row down", title="Move", severity="warning")
|
|
1737
|
-
return
|
|
1738
|
-
swap_idx = row_idx + 1
|
|
1761
|
+
if not col_name:
|
|
1762
|
+
# Generate a unique column name
|
|
1763
|
+
base_name = "new_col"
|
|
1764
|
+
new_name = base_name
|
|
1765
|
+
counter = 1
|
|
1766
|
+
while new_name in self.df.columns:
|
|
1767
|
+
new_name = f"{base_name}_{counter}"
|
|
1768
|
+
counter += 1
|
|
1739
1769
|
else:
|
|
1740
|
-
|
|
1741
|
-
return
|
|
1742
|
-
|
|
1743
|
-
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
1744
|
-
swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
|
|
1770
|
+
new_name = col_name
|
|
1745
1771
|
|
|
1746
1772
|
# Add to history
|
|
1747
|
-
self.
|
|
1748
|
-
f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])"
|
|
1749
|
-
)
|
|
1773
|
+
self.add_history(f"Added column [$success]{new_name}[/] after column {cidx + 1}", dirty=True)
|
|
1750
1774
|
|
|
1751
|
-
|
|
1752
|
-
|
|
1775
|
+
try:
|
|
1776
|
+
# Create an empty column (all None values)
|
|
1777
|
+
if isinstance(col_value, pl.Expr):
|
|
1778
|
+
new_col = col_value.alias(new_name)
|
|
1779
|
+
else:
|
|
1780
|
+
new_col = pl.lit(col_value).alias(new_name)
|
|
1753
1781
|
|
|
1754
|
-
|
|
1755
|
-
self.
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
self._row_locations.get(swap_key),
|
|
1759
|
-
self._row_locations.get(row_key),
|
|
1760
|
-
)
|
|
1782
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1783
|
+
cols = self.df.columns
|
|
1784
|
+
cols_before = cols[: cidx + 1]
|
|
1785
|
+
cols_after = cols[cidx + 1 :]
|
|
1761
1786
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1787
|
+
# Build the new dataframe with columns reordered
|
|
1788
|
+
select_cols = cols_before + [new_name] + cols_after
|
|
1789
|
+
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
1764
1790
|
|
|
1765
|
-
|
|
1766
|
-
|
|
1791
|
+
# Recreate table for display
|
|
1792
|
+
self.setup_table()
|
|
1767
1793
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
swap_ridx = int(swap_key.value) # 0-based
|
|
1771
|
-
first, second = sorted([ridx, swap_ridx])
|
|
1794
|
+
# Move cursor to the new column
|
|
1795
|
+
self.move_cursor(column=cidx + 1)
|
|
1772
1796
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1797
|
+
# self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1798
|
+
except Exception as e:
|
|
1799
|
+
self.notify("Error adding column", title="Add Column", severity="error")
|
|
1800
|
+
self.log(f"Error adding column: {str(e)}")
|
|
1801
|
+
raise e
|
|
1802
|
+
|
|
1803
|
+
def do_add_column_expr(self) -> None:
|
|
1804
|
+
"""Open screen to add a new column with optional expression."""
|
|
1805
|
+
cidx = self.cursor_col_idx
|
|
1806
|
+
self.app.push_screen(
|
|
1807
|
+
AddColumnScreen(cidx, self.df),
|
|
1808
|
+
self.add_column_expr,
|
|
1781
1809
|
)
|
|
1782
1810
|
|
|
1783
|
-
|
|
1811
|
+
def add_column_expr(self, result: tuple[int, str, str, pl.Expr] | None) -> None:
|
|
1812
|
+
"""Add a new column with an expression."""
|
|
1813
|
+
if result is None:
|
|
1814
|
+
return
|
|
1784
1815
|
|
|
1785
|
-
|
|
1786
|
-
def _sort_by_column(self, descending: bool = False) -> None:
|
|
1787
|
-
"""Sort by the currently selected column.
|
|
1788
|
-
|
|
1789
|
-
Supports multi-column sorting:
|
|
1790
|
-
- First press on a column: sort by that column only
|
|
1791
|
-
- Subsequent presses on other columns: add to sort order
|
|
1792
|
-
|
|
1793
|
-
Args:
|
|
1794
|
-
descending: If True, sort in descending order. If False, ascending order.
|
|
1795
|
-
"""
|
|
1796
|
-
col_name = self.cursor_col_name
|
|
1797
|
-
col_idx = self.cursor_column
|
|
1798
|
-
|
|
1799
|
-
# Check if this column is already in the sort keys
|
|
1800
|
-
old_desc = self.sorted_columns.get(col_name)
|
|
1816
|
+
cidx, new_col_name, expr = result
|
|
1801
1817
|
|
|
1802
1818
|
# Add to history
|
|
1803
|
-
self.
|
|
1804
|
-
if old_desc is None:
|
|
1805
|
-
# Add new column to sort
|
|
1806
|
-
self.sorted_columns[col_name] = descending
|
|
1807
|
-
elif old_desc == descending:
|
|
1808
|
-
# Same direction - remove from sort
|
|
1809
|
-
del self.sorted_columns[col_name]
|
|
1810
|
-
else:
|
|
1811
|
-
# Move to end of sort order
|
|
1812
|
-
del self.sorted_columns[col_name]
|
|
1813
|
-
self.sorted_columns[col_name] = descending
|
|
1819
|
+
self.add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.", dirty=True)
|
|
1814
1820
|
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
df_sorted = self.df.with_row_index(RIDX).sort(sort_cols, descending=descending_flags, nulls_last=True)
|
|
1819
|
-
else:
|
|
1820
|
-
# No sort columns - restore original order
|
|
1821
|
-
df_sorted = self.df.with_row_index(RIDX)
|
|
1822
|
-
|
|
1823
|
-
# Updated selected_rows and visible_rows to match new order
|
|
1824
|
-
old_row_indices = df_sorted[RIDX].to_list()
|
|
1825
|
-
self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
|
|
1826
|
-
self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
|
|
1821
|
+
try:
|
|
1822
|
+
# Create the column
|
|
1823
|
+
new_col = expr.alias(new_col_name)
|
|
1827
1824
|
|
|
1828
|
-
|
|
1829
|
-
|
|
1825
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1826
|
+
cols = self.df.columns
|
|
1827
|
+
cols_before = cols[: cidx + 1]
|
|
1828
|
+
cols_after = cols[cidx + 1 :]
|
|
1830
1829
|
|
|
1831
|
-
|
|
1832
|
-
|
|
1830
|
+
# Build the new dataframe with columns reordered
|
|
1831
|
+
select_cols = cols_before + [new_col_name] + cols_after
|
|
1832
|
+
self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
|
|
1833
1833
|
|
|
1834
|
-
|
|
1835
|
-
|
|
1834
|
+
# Recreate table for display
|
|
1835
|
+
self.setup_table()
|
|
1836
1836
|
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
"""Open modal to edit the selected cell."""
|
|
1840
|
-
ridx = self.cursor_row_idx if ridx is None else ridx
|
|
1841
|
-
cidx = self.cursor_col_idx if cidx is None else cidx
|
|
1842
|
-
col_name = self.df.columns[cidx]
|
|
1837
|
+
# Move cursor to the new column
|
|
1838
|
+
self.move_cursor(column=cidx + 1)
|
|
1843
1839
|
|
|
1844
|
-
|
|
1845
|
-
|
|
1840
|
+
# self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
|
|
1841
|
+
except Exception as e:
|
|
1842
|
+
self.notify("Error adding column", title="Add Column", severity="error")
|
|
1843
|
+
self.log(f"Error adding column `{new_col_name}`: {str(e)}")
|
|
1846
1844
|
|
|
1847
|
-
|
|
1845
|
+
def do_add_link_column(self) -> None:
|
|
1848
1846
|
self.app.push_screen(
|
|
1849
|
-
|
|
1850
|
-
callback=self.
|
|
1847
|
+
AddLinkScreen(self.cursor_col_idx, self.df),
|
|
1848
|
+
callback=self.add_link_column,
|
|
1851
1849
|
)
|
|
1852
1850
|
|
|
1853
|
-
def
|
|
1854
|
-
"""Handle result from
|
|
1855
|
-
if result is None:
|
|
1856
|
-
return
|
|
1851
|
+
def add_link_column(self, result: tuple[str, str] | None) -> None:
|
|
1852
|
+
"""Handle result from AddLinkScreen.
|
|
1857
1853
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1854
|
+
Creates a new link column in the dataframe with clickable links based on a
|
|
1855
|
+
user-provided template. Supports multiple placeholder types:
|
|
1856
|
+
- `$_` - Current column (based on cursor position)
|
|
1857
|
+
- `$1`, `$2`, etc. - Column by 1-based position index
|
|
1858
|
+
- `$name` - Column by name (e.g., `$id`, `$product_name`)
|
|
1859
|
+
|
|
1860
|
+
The template is evaluated for each row using Polars expressions with vectorized
|
|
1861
|
+
string concatenation. The new column is inserted after the current column.
|
|
1862
|
+
|
|
1863
|
+
Args:
|
|
1864
|
+
result: Tuple of (cidx, new_col_name, link_template) or None if cancelled.
|
|
1865
|
+
"""
|
|
1866
|
+
if result is None:
|
|
1864
1867
|
return
|
|
1868
|
+
cidx, new_col_name, link_template = result
|
|
1865
1869
|
|
|
1866
|
-
|
|
1870
|
+
self.add_history(
|
|
1871
|
+
f"Added link column [$accent]{new_col_name}[/] with template [$success]{link_template}[/].", dirty=True
|
|
1872
|
+
)
|
|
1867
1873
|
|
|
1868
|
-
# Update the cell in the dataframe
|
|
1869
1874
|
try:
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
.then(pl.lit(new_value))
|
|
1873
|
-
.otherwise(pl.col(col_name))
|
|
1874
|
-
.alias(col_name)
|
|
1875
|
-
)
|
|
1875
|
+
# Hack to support PubChem link
|
|
1876
|
+
link_template = link_template.replace("PC", "pubchem.ncbi.nlm.nih.gov")
|
|
1876
1877
|
|
|
1877
|
-
#
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
cell_value = NULL_DISPLAY
|
|
1881
|
-
dtype = self.df.dtypes[cidx]
|
|
1882
|
-
dc = DtypeConfig(dtype)
|
|
1883
|
-
formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
|
|
1878
|
+
# Ensure link starts with http:// or https://
|
|
1879
|
+
if not link_template.startswith(("https://", "http://")):
|
|
1880
|
+
link_template = "https://" + link_template
|
|
1884
1881
|
|
|
1885
|
-
#
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1882
|
+
# Parse template placeholders into Polars expressions
|
|
1883
|
+
parts = parse_placeholders(link_template, self.df.columns, cidx)
|
|
1884
|
+
|
|
1885
|
+
# Build the concatenation expression
|
|
1886
|
+
exprs = [part if isinstance(part, pl.Expr) else pl.lit(part) for part in parts]
|
|
1887
|
+
new_col = pl.concat_str(exprs).alias(new_col_name)
|
|
1888
|
+
|
|
1889
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1890
|
+
cols = self.df.columns
|
|
1891
|
+
cols_before = cols[: cidx + 1]
|
|
1892
|
+
cols_after = cols[cidx + 1 :]
|
|
1893
|
+
|
|
1894
|
+
# Build the new dataframe with columns reordered
|
|
1895
|
+
select_cols = cols_before + [new_col_name] + cols_after
|
|
1896
|
+
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
1897
|
+
|
|
1898
|
+
# Recreate table for display
|
|
1899
|
+
self.setup_table()
|
|
1900
|
+
|
|
1901
|
+
# Move cursor to the new column
|
|
1902
|
+
self.move_cursor(column=cidx + 1)
|
|
1903
|
+
|
|
1904
|
+
self.notify(f"Added link column [$success]{new_col_name}[/]. Use Ctrl/Cmd click to open.", title="Add Link")
|
|
1889
1905
|
|
|
1890
|
-
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1891
1906
|
except Exception as e:
|
|
1892
|
-
self.notify("Error
|
|
1893
|
-
self.log(f"Error
|
|
1907
|
+
self.notify(f"Error adding link column [$error]{new_col_name}[/]", title="Add Link", severity="error")
|
|
1908
|
+
self.log(f"Error adding link column: {str(e)}")
|
|
1894
1909
|
|
|
1895
|
-
def
|
|
1896
|
-
"""
|
|
1897
|
-
|
|
1910
|
+
def do_delete_column(self, more: str = None) -> None:
|
|
1911
|
+
"""Remove the currently selected column from the table."""
|
|
1912
|
+
# Get the column to remove
|
|
1913
|
+
col_idx = self.cursor_column
|
|
1914
|
+
col_name = self.cursor_col_name
|
|
1915
|
+
col_key = self.cursor_col_key
|
|
1898
1916
|
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
EditColumnScreen(cidx, self.df),
|
|
1902
|
-
callback=self._do_edit_column,
|
|
1903
|
-
)
|
|
1917
|
+
col_names_to_remove = []
|
|
1918
|
+
col_keys_to_remove = []
|
|
1904
1919
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1920
|
+
# Remove all columns before the current column
|
|
1921
|
+
if more == "before":
|
|
1922
|
+
for i in range(col_idx + 1):
|
|
1923
|
+
col_key = self.get_column_key(i)
|
|
1924
|
+
col_names_to_remove.append(col_key.value)
|
|
1925
|
+
col_keys_to_remove.append(col_key)
|
|
1910
1926
|
|
|
1911
|
-
|
|
1927
|
+
message = f"Removed column [$success]{col_name}[/] and all columns before"
|
|
1912
1928
|
|
|
1913
|
-
#
|
|
1914
|
-
|
|
1915
|
-
|
|
1929
|
+
# Remove all columns after the current column
|
|
1930
|
+
elif more == "after":
|
|
1931
|
+
for i in range(col_idx, len(self.columns)):
|
|
1932
|
+
col_key = self.get_column_key(i)
|
|
1933
|
+
col_names_to_remove.append(col_key.value)
|
|
1934
|
+
col_keys_to_remove.append(col_key)
|
|
1916
1935
|
|
|
1917
|
-
|
|
1918
|
-
elif tentative_expr(term):
|
|
1919
|
-
try:
|
|
1920
|
-
expr = validate_expr(term, self.df.columns, cidx)
|
|
1921
|
-
except Exception as e:
|
|
1922
|
-
self.notify(f"Error validating expression [$error]{term}[/]", title="Edit", severity="error")
|
|
1923
|
-
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1924
|
-
return
|
|
1936
|
+
message = f"Removed column [$success]{col_name}[/] and all columns after"
|
|
1925
1937
|
|
|
1926
|
-
#
|
|
1938
|
+
# Remove only the current column
|
|
1927
1939
|
else:
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
expr = pl.lit(value)
|
|
1932
|
-
except Exception:
|
|
1933
|
-
self.notify(
|
|
1934
|
-
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1935
|
-
title="Edit",
|
|
1936
|
-
severity="error",
|
|
1937
|
-
)
|
|
1938
|
-
expr = pl.lit(str(term))
|
|
1940
|
+
col_names_to_remove.append(col_name)
|
|
1941
|
+
col_keys_to_remove.append(col_key)
|
|
1942
|
+
message = f"Removed column [$success]{col_name}[/]"
|
|
1939
1943
|
|
|
1940
1944
|
# Add to history
|
|
1941
|
-
self.
|
|
1945
|
+
self.add_history(message, dirty=True)
|
|
1942
1946
|
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
self.
|
|
1946
|
-
except Exception as e:
|
|
1947
|
-
self.notify(
|
|
1948
|
-
f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
|
|
1949
|
-
title="Edit",
|
|
1950
|
-
severity="error",
|
|
1951
|
-
)
|
|
1952
|
-
self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
|
|
1953
|
-
return
|
|
1947
|
+
# Remove the columns from the table display using the column names as keys
|
|
1948
|
+
for ck in col_keys_to_remove:
|
|
1949
|
+
self.remove_column(ck)
|
|
1954
1950
|
|
|
1955
|
-
#
|
|
1956
|
-
self.
|
|
1951
|
+
# Move cursor left if we deleted the last column(s)
|
|
1952
|
+
last_col_idx = len(self.columns) - 1
|
|
1953
|
+
if col_idx > last_col_idx:
|
|
1954
|
+
self.move_cursor(column=last_col_idx)
|
|
1957
1955
|
|
|
1958
|
-
#
|
|
1956
|
+
# Remove from sorted columns if present
|
|
1957
|
+
for col_name in col_names_to_remove:
|
|
1958
|
+
if col_name in self.sorted_columns:
|
|
1959
|
+
del self.sorted_columns[col_name]
|
|
1959
1960
|
|
|
1960
|
-
|
|
1961
|
-
|
|
1961
|
+
# Remove from matches
|
|
1962
|
+
col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
|
|
1963
|
+
for row_idx in list(self.matches.keys()):
|
|
1964
|
+
self.matches[row_idx].difference_update(col_indices_to_remove)
|
|
1965
|
+
# Remove empty entries
|
|
1966
|
+
if not self.matches[row_idx]:
|
|
1967
|
+
del self.matches[row_idx]
|
|
1968
|
+
|
|
1969
|
+
# Remove from dataframe
|
|
1970
|
+
self.df = self.df.drop(col_names_to_remove)
|
|
1971
|
+
|
|
1972
|
+
self.notify(message, title="Delete")
|
|
1973
|
+
|
|
1974
|
+
def do_duplicate_column(self) -> None:
|
|
1975
|
+
"""Duplicate the currently selected column, inserting it right after the current column."""
|
|
1976
|
+
cidx = self.cursor_col_idx
|
|
1962
1977
|
col_name = self.cursor_col_name
|
|
1978
|
+
|
|
1963
1979
|
col_idx = self.cursor_column
|
|
1980
|
+
new_col_name = f"{col_name}_copy"
|
|
1964
1981
|
|
|
1965
|
-
#
|
|
1966
|
-
self.
|
|
1967
|
-
|
|
1968
|
-
|
|
1982
|
+
# Add to history
|
|
1983
|
+
self.add_history(f"Duplicated column [$success]{col_name}[/]", dirty=True)
|
|
1984
|
+
|
|
1985
|
+
# Create new column and reorder columns to insert after current column
|
|
1986
|
+
cols_before = self.df.columns[: cidx + 1]
|
|
1987
|
+
cols_after = self.df.columns[cidx + 1 :]
|
|
1988
|
+
|
|
1989
|
+
# Add the new column and reorder columns for insertion after current column
|
|
1990
|
+
self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
|
|
1991
|
+
list(cols_before) + [new_col_name] + list(cols_after)
|
|
1969
1992
|
)
|
|
1970
1993
|
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1994
|
+
# Update matches to account for new column
|
|
1995
|
+
new_matches = defaultdict(set)
|
|
1996
|
+
for row_idx, cols in self.matches.items():
|
|
1997
|
+
new_cols = set()
|
|
1998
|
+
for col_idx_in_set in cols:
|
|
1999
|
+
if col_idx_in_set <= cidx:
|
|
2000
|
+
new_cols.add(col_idx_in_set)
|
|
2001
|
+
else:
|
|
2002
|
+
new_cols.add(col_idx_in_set + 1)
|
|
2003
|
+
new_matches[row_idx] = new_cols
|
|
2004
|
+
self.matches = new_matches
|
|
1975
2005
|
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
self.app.push_screen(
|
|
1979
|
-
RenameColumnScreen(col_idx, col_name, self.df.columns),
|
|
1980
|
-
callback=self._do_rename_column,
|
|
1981
|
-
)
|
|
1982
|
-
return
|
|
2006
|
+
# Recreate table for display
|
|
2007
|
+
self.setup_table()
|
|
1983
2008
|
|
|
1984
|
-
#
|
|
1985
|
-
self.
|
|
2009
|
+
# Move cursor to the new duplicated column
|
|
2010
|
+
self.move_cursor(column=col_idx + 1)
|
|
1986
2011
|
|
|
1987
|
-
#
|
|
1988
|
-
self.df = self.df.rename({col_name: new_name})
|
|
2012
|
+
# self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
|
|
1989
2013
|
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
|
|
2014
|
+
def do_delete_row(self, more: str = None) -> None:
|
|
2015
|
+
"""Delete rows from the table and dataframe.
|
|
1993
2016
|
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2017
|
+
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
2018
|
+
"""
|
|
2019
|
+
old_count = len(self.df)
|
|
2020
|
+
predicates = [True] * len(self.df)
|
|
1998
2021
|
|
|
1999
|
-
#
|
|
2000
|
-
self.
|
|
2022
|
+
# Delete all selected rows
|
|
2023
|
+
if selected_count := self.selected_rows.count(True):
|
|
2024
|
+
history_desc = f"Deleted {selected_count} selected row(s)"
|
|
2001
2025
|
|
|
2002
|
-
|
|
2003
|
-
|
|
2026
|
+
for ridx, selected in enumerate(self.selected_rows):
|
|
2027
|
+
if selected:
|
|
2028
|
+
predicates[ridx] = False
|
|
2004
2029
|
|
|
2005
|
-
#
|
|
2030
|
+
# Delete current row and those above
|
|
2031
|
+
elif more == "above":
|
|
2032
|
+
ridx = self.cursor_row_idx
|
|
2033
|
+
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
|
|
2034
|
+
for i in range(ridx + 1):
|
|
2035
|
+
predicates[i] = False
|
|
2006
2036
|
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2037
|
+
# Delete current row and those below
|
|
2038
|
+
elif more == "below":
|
|
2039
|
+
ridx = self.cursor_row_idx
|
|
2040
|
+
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
|
|
2041
|
+
for i in range(ridx, len(self.df)):
|
|
2042
|
+
if self.visible_rows[i]:
|
|
2043
|
+
predicates[i] = False
|
|
2044
|
+
|
|
2045
|
+
# Delete the row at the cursor
|
|
2046
|
+
else:
|
|
2047
|
+
ridx = self.cursor_row_idx
|
|
2048
|
+
history_desc = f"Deleted row [$success]{ridx + 1}[/]"
|
|
2049
|
+
if self.visible_rows[ridx]:
|
|
2050
|
+
predicates[ridx] = False
|
|
2013
2051
|
|
|
2014
2052
|
# Add to history
|
|
2015
|
-
self.
|
|
2053
|
+
self.add_history(history_desc, dirty=True)
|
|
2016
2054
|
|
|
2017
|
-
#
|
|
2055
|
+
# Apply the filter to remove rows
|
|
2018
2056
|
try:
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
)
|
|
2025
|
-
|
|
2026
|
-
# Update the display
|
|
2027
|
-
dtype = self.df.dtypes[cidx]
|
|
2028
|
-
dc = DtypeConfig(dtype)
|
|
2029
|
-
formatted_value = Text(NULL_DISPLAY, style=dc.style, justify=dc.justify)
|
|
2057
|
+
df = self.df.with_row_index(RIDX).filter(predicates)
|
|
2058
|
+
except Exception as e:
|
|
2059
|
+
self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error")
|
|
2060
|
+
self.histories.pop() # Remove last history entry
|
|
2061
|
+
return
|
|
2030
2062
|
|
|
2031
|
-
|
|
2063
|
+
self.df = df.drop(RIDX)
|
|
2032
2064
|
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
raise e
|
|
2065
|
+
# Update selected and visible rows tracking
|
|
2066
|
+
old_row_indices = set(df[RIDX].to_list())
|
|
2067
|
+
self.selected_rows = [selected for i, selected in enumerate(self.selected_rows) if i in old_row_indices]
|
|
2068
|
+
self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
|
|
2038
2069
|
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
cidx = self.cursor_col_idx
|
|
2070
|
+
# Clear all matches since row indices have changed
|
|
2071
|
+
self.matches = defaultdict(set)
|
|
2042
2072
|
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
base_name = "new_col"
|
|
2046
|
-
new_name = base_name
|
|
2047
|
-
counter = 1
|
|
2048
|
-
while new_name in self.df.columns:
|
|
2049
|
-
new_name = f"{base_name}_{counter}"
|
|
2050
|
-
counter += 1
|
|
2051
|
-
else:
|
|
2052
|
-
new_name = col_name
|
|
2073
|
+
# Recreate table for display
|
|
2074
|
+
self.setup_table()
|
|
2053
2075
|
|
|
2054
|
-
|
|
2055
|
-
|
|
2076
|
+
deleted_count = old_count - len(self.df)
|
|
2077
|
+
if deleted_count > 0:
|
|
2078
|
+
self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
|
|
2056
2079
|
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
new_col = col_value.alias(new_name)
|
|
2061
|
-
else:
|
|
2062
|
-
new_col = pl.lit(col_value).alias(new_name)
|
|
2080
|
+
def do_duplicate_row(self) -> None:
|
|
2081
|
+
"""Duplicate the currently selected row, inserting it right after the current row."""
|
|
2082
|
+
ridx = self.cursor_row_idx
|
|
2063
2083
|
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
cols_before = cols[: cidx + 1]
|
|
2067
|
-
cols_after = cols[cidx + 1 :]
|
|
2084
|
+
# Get the row to duplicate
|
|
2085
|
+
row_to_duplicate = self.df.slice(ridx, 1)
|
|
2068
2086
|
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
2087
|
+
# Add to history
|
|
2088
|
+
self.add_history(f"Duplicated row [$success]{ridx + 1}[/]", dirty=True)
|
|
2072
2089
|
|
|
2073
|
-
|
|
2074
|
-
|
|
2090
|
+
# Concatenate: rows before + duplicated row + rows after
|
|
2091
|
+
df_before = self.df.slice(0, ridx + 1)
|
|
2092
|
+
df_after = self.df.slice(ridx + 1)
|
|
2075
2093
|
|
|
2076
|
-
|
|
2077
|
-
|
|
2094
|
+
# Combine the parts
|
|
2095
|
+
self.df = pl.concat([df_before, row_to_duplicate, df_after])
|
|
2078
2096
|
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2097
|
+
# Update selected and visible rows tracking to account for new row
|
|
2098
|
+
new_selected_rows = self.selected_rows[: ridx + 1] + [self.selected_rows[ridx]] + self.selected_rows[ridx + 1 :]
|
|
2099
|
+
new_visible_rows = self.visible_rows[: ridx + 1] + [self.visible_rows[ridx]] + self.visible_rows[ridx + 1 :]
|
|
2100
|
+
self.selected_rows = new_selected_rows
|
|
2101
|
+
self.visible_rows = new_visible_rows
|
|
2084
2102
|
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2103
|
+
# Update matches to account for new row
|
|
2104
|
+
new_matches = defaultdict(set)
|
|
2105
|
+
for row_idx, cols in self.matches.items():
|
|
2106
|
+
if row_idx <= ridx:
|
|
2107
|
+
new_matches[row_idx] = cols
|
|
2108
|
+
else:
|
|
2109
|
+
new_matches[row_idx + 1] = cols
|
|
2110
|
+
self.matches = new_matches
|
|
2092
2111
|
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
if result is None:
|
|
2096
|
-
return
|
|
2112
|
+
# Recreate table for display
|
|
2113
|
+
self.setup_table()
|
|
2097
2114
|
|
|
2098
|
-
|
|
2115
|
+
# Move cursor to the new duplicated row
|
|
2116
|
+
self.move_cursor(row=ridx + 1)
|
|
2099
2117
|
|
|
2100
|
-
#
|
|
2101
|
-
self._add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.")
|
|
2118
|
+
# self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
|
|
2102
2119
|
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
new_col = expr.alias(new_col_name)
|
|
2120
|
+
def do_move_column(self, direction: str) -> None:
|
|
2121
|
+
"""Move the current column left or right.
|
|
2106
2122
|
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2123
|
+
Args:
|
|
2124
|
+
direction: "left" to move left, "right" to move right.
|
|
2125
|
+
"""
|
|
2126
|
+
row_idx, col_idx = self.cursor_coordinate
|
|
2127
|
+
col_key = self.cursor_col_key
|
|
2128
|
+
col_name = col_key.value
|
|
2129
|
+
cidx = self.cursor_col_idx
|
|
2111
2130
|
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2131
|
+
# Validate move is possible
|
|
2132
|
+
if direction == "left":
|
|
2133
|
+
if col_idx <= 0:
|
|
2134
|
+
self.notify("Cannot move column left", title="Move", severity="warning")
|
|
2135
|
+
return
|
|
2136
|
+
swap_idx = col_idx - 1
|
|
2137
|
+
elif direction == "right":
|
|
2138
|
+
if col_idx >= len(self.columns) - 1:
|
|
2139
|
+
self.notify("Cannot move column right", title="Move", severity="warning")
|
|
2140
|
+
return
|
|
2141
|
+
swap_idx = col_idx + 1
|
|
2115
2142
|
|
|
2116
|
-
|
|
2117
|
-
|
|
2143
|
+
# Get column to swap
|
|
2144
|
+
_, swap_key = self.coordinate_to_cell_key(Coordinate(row_idx, swap_idx))
|
|
2145
|
+
swap_name = swap_key.value
|
|
2146
|
+
swap_cidx = self.df.columns.index(swap_name)
|
|
2118
2147
|
|
|
2119
|
-
|
|
2120
|
-
|
|
2148
|
+
# Add to history
|
|
2149
|
+
self.add_history(
|
|
2150
|
+
f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])", dirty=True
|
|
2151
|
+
)
|
|
2121
2152
|
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
self.notify("Error adding column", title="Add Column", severity="error")
|
|
2125
|
-
self.log(f"Error adding column `{new_col_name}`: {str(e)}")
|
|
2153
|
+
# Swap columns in the table's internal column locations
|
|
2154
|
+
self.check_idle()
|
|
2126
2155
|
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2156
|
+
(
|
|
2157
|
+
self._column_locations[col_key],
|
|
2158
|
+
self._column_locations[swap_key],
|
|
2159
|
+
) = (
|
|
2160
|
+
self._column_locations.get(swap_key),
|
|
2161
|
+
self._column_locations.get(col_key),
|
|
2131
2162
|
)
|
|
2132
2163
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2164
|
+
self._update_count += 1
|
|
2165
|
+
self.refresh()
|
|
2135
2166
|
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
- `$_` - Current column (based on cursor position)
|
|
2139
|
-
- `$1`, `$2`, etc. - Column by 1-based position index
|
|
2140
|
-
- `$name` - Column by name (e.g., `$id`, `$product_name`)
|
|
2167
|
+
# Restore cursor position on the moved column
|
|
2168
|
+
self.move_cursor(row=row_idx, column=swap_idx)
|
|
2141
2169
|
|
|
2142
|
-
|
|
2143
|
-
|
|
2170
|
+
# Update the dataframe column order
|
|
2171
|
+
cols = list(self.df.columns)
|
|
2172
|
+
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
2173
|
+
self.df = self.df.select(cols)
|
|
2144
2174
|
|
|
2145
|
-
|
|
2146
|
-
result: Tuple of (cidx, new_col_name, link_template) or None if cancelled.
|
|
2175
|
+
# self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
|
|
2147
2176
|
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
"""
|
|
2151
|
-
if result is None:
|
|
2152
|
-
return
|
|
2153
|
-
cidx, new_col_name, link_template = result
|
|
2177
|
+
def do_move_row(self, direction: str) -> None:
|
|
2178
|
+
"""Move the current row up or down.
|
|
2154
2179
|
|
|
2155
|
-
|
|
2180
|
+
Args:
|
|
2181
|
+
direction: "up" to move up, "down" to move down.
|
|
2182
|
+
"""
|
|
2183
|
+
row_idx, col_idx = self.cursor_coordinate
|
|
2156
2184
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2185
|
+
# Validate move is possible
|
|
2186
|
+
if direction == "up":
|
|
2187
|
+
if row_idx <= 0:
|
|
2188
|
+
self.notify("Cannot move row up", title="Move", severity="warning")
|
|
2189
|
+
return
|
|
2190
|
+
swap_idx = row_idx - 1
|
|
2191
|
+
elif direction == "down":
|
|
2192
|
+
if row_idx >= len(self.rows) - 1:
|
|
2193
|
+
self.notify("Cannot move row down", title="Move", severity="warning")
|
|
2194
|
+
return
|
|
2195
|
+
swap_idx = row_idx + 1
|
|
2196
|
+
else:
|
|
2197
|
+
# Invalid direction
|
|
2198
|
+
return
|
|
2160
2199
|
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
link_template = "https://" + link_template
|
|
2200
|
+
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
2201
|
+
swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
|
|
2164
2202
|
|
|
2165
|
-
|
|
2166
|
-
|
|
2203
|
+
# Add to history
|
|
2204
|
+
self.add_history(
|
|
2205
|
+
f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])",
|
|
2206
|
+
dirty=True,
|
|
2207
|
+
)
|
|
2167
2208
|
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
new_col = pl.concat_str(exprs).alias(new_col_name)
|
|
2209
|
+
# Swap rows in the table's internal row locations
|
|
2210
|
+
self.check_idle()
|
|
2171
2211
|
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2212
|
+
(
|
|
2213
|
+
self._row_locations[row_key],
|
|
2214
|
+
self._row_locations[swap_key],
|
|
2215
|
+
) = (
|
|
2216
|
+
self._row_locations.get(swap_key),
|
|
2217
|
+
self._row_locations.get(row_key),
|
|
2218
|
+
)
|
|
2176
2219
|
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
2220
|
+
self._update_count += 1
|
|
2221
|
+
self.refresh()
|
|
2180
2222
|
|
|
2181
|
-
|
|
2182
|
-
|
|
2223
|
+
# Restore cursor position on the moved row
|
|
2224
|
+
self.move_cursor(row=swap_idx, column=col_idx)
|
|
2183
2225
|
|
|
2184
|
-
|
|
2185
|
-
|
|
2226
|
+
# Swap rows in the dataframe
|
|
2227
|
+
ridx = int(row_key.value) # 0-based
|
|
2228
|
+
swap_ridx = int(swap_key.value) # 0-based
|
|
2229
|
+
first, second = sorted([ridx, swap_ridx])
|
|
2186
2230
|
|
|
2187
|
-
|
|
2231
|
+
self.df = pl.concat(
|
|
2232
|
+
[
|
|
2233
|
+
self.df.slice(0, first),
|
|
2234
|
+
self.df.slice(second, 1),
|
|
2235
|
+
self.df.slice(first + 1, second - first - 1),
|
|
2236
|
+
self.df.slice(first, 1),
|
|
2237
|
+
self.df.slice(second + 1),
|
|
2238
|
+
]
|
|
2239
|
+
)
|
|
2188
2240
|
|
|
2189
|
-
|
|
2190
|
-
self.notify(f"Error adding link column: {str(e)}", title="Add Link", severity="error")
|
|
2191
|
-
self.log(f"Error adding link column: {str(e)}") # Type Casting
|
|
2241
|
+
# self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
|
|
2192
2242
|
|
|
2193
|
-
|
|
2243
|
+
# Type casting
|
|
2244
|
+
def do_cast_column_dtype(self, dtype: str) -> None:
|
|
2194
2245
|
"""Cast the current column to a different data type.
|
|
2195
2246
|
|
|
2196
2247
|
Args:
|
|
@@ -2215,8 +2266,9 @@ class DataFrameTable(DataTable):
|
|
|
2215
2266
|
return # No change needed
|
|
2216
2267
|
|
|
2217
2268
|
# Add to history
|
|
2218
|
-
self.
|
|
2219
|
-
f"Cast column [$accent]{col_name}[/] from [$success]{current_dtype}[/] to [$success]{target_dtype}[/]"
|
|
2269
|
+
self.add_history(
|
|
2270
|
+
f"Cast column [$accent]{col_name}[/] from [$success]{current_dtype}[/] to [$success]{target_dtype}[/]",
|
|
2271
|
+
dirty=True,
|
|
2220
2272
|
)
|
|
2221
2273
|
|
|
2222
2274
|
try:
|
|
@@ -2224,7 +2276,7 @@ class DataFrameTable(DataTable):
|
|
|
2224
2276
|
self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
|
|
2225
2277
|
|
|
2226
2278
|
# Recreate table for display
|
|
2227
|
-
self.
|
|
2279
|
+
self.setup_table()
|
|
2228
2280
|
|
|
2229
2281
|
self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
|
|
2230
2282
|
except Exception as e:
|
|
@@ -2236,16 +2288,16 @@ class DataFrameTable(DataTable):
|
|
|
2236
2288
|
self.log(f"Error casting column `{col_name}`: {str(e)}")
|
|
2237
2289
|
|
|
2238
2290
|
# Search
|
|
2239
|
-
def
|
|
2291
|
+
def do_search_cursor_value(self) -> None:
|
|
2240
2292
|
"""Search with cursor value in current column."""
|
|
2241
2293
|
cidx = self.cursor_col_idx
|
|
2242
2294
|
|
|
2243
2295
|
# Get the value of the currently selected cell
|
|
2244
2296
|
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
2245
2297
|
|
|
2246
|
-
self.
|
|
2298
|
+
self.search((term, cidx, False, True))
|
|
2247
2299
|
|
|
2248
|
-
def
|
|
2300
|
+
def do_search_expr(self) -> None:
|
|
2249
2301
|
"""Search by expression."""
|
|
2250
2302
|
cidx = self.cursor_col_idx
|
|
2251
2303
|
|
|
@@ -2255,10 +2307,10 @@ class DataFrameTable(DataTable):
|
|
|
2255
2307
|
# Push the search modal screen
|
|
2256
2308
|
self.app.push_screen(
|
|
2257
2309
|
SearchScreen("Search", term, self.df, cidx),
|
|
2258
|
-
callback=self.
|
|
2310
|
+
callback=self.search,
|
|
2259
2311
|
)
|
|
2260
2312
|
|
|
2261
|
-
def
|
|
2313
|
+
def search(self, result) -> None:
|
|
2262
2314
|
"""Search for a term."""
|
|
2263
2315
|
if result is None:
|
|
2264
2316
|
return
|
|
@@ -2326,7 +2378,7 @@ class DataFrameTable(DataTable):
|
|
|
2326
2378
|
return
|
|
2327
2379
|
|
|
2328
2380
|
# Add to history
|
|
2329
|
-
self.
|
|
2381
|
+
self.add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
2330
2382
|
|
|
2331
2383
|
# Update selected rows to include new matches
|
|
2332
2384
|
for m in matches:
|
|
@@ -2336,10 +2388,10 @@ class DataFrameTable(DataTable):
|
|
|
2336
2388
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
|
|
2337
2389
|
|
|
2338
2390
|
# Recreate table for display
|
|
2339
|
-
self.
|
|
2391
|
+
self.setup_table()
|
|
2340
2392
|
|
|
2341
2393
|
# Find
|
|
2342
|
-
def
|
|
2394
|
+
def find_matches(
|
|
2343
2395
|
self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
|
|
2344
2396
|
) -> dict[int, set[int]]:
|
|
2345
2397
|
"""Find matches for a term in the dataframe.
|
|
@@ -2401,7 +2453,7 @@ class DataFrameTable(DataTable):
|
|
|
2401
2453
|
|
|
2402
2454
|
return matches
|
|
2403
2455
|
|
|
2404
|
-
def
|
|
2456
|
+
def do_find_cursor_value(self, scope="column") -> None:
|
|
2405
2457
|
"""Find by cursor value.
|
|
2406
2458
|
|
|
2407
2459
|
Args:
|
|
@@ -2412,11 +2464,11 @@ class DataFrameTable(DataTable):
|
|
|
2412
2464
|
|
|
2413
2465
|
if scope == "column":
|
|
2414
2466
|
cidx = self.cursor_col_idx
|
|
2415
|
-
self.
|
|
2467
|
+
self.find((term, cidx, False, True))
|
|
2416
2468
|
else:
|
|
2417
|
-
self.
|
|
2469
|
+
self.find_global((term, None, False, True))
|
|
2418
2470
|
|
|
2419
|
-
def
|
|
2471
|
+
def do_find_expr(self, scope="column") -> None:
|
|
2420
2472
|
"""Open screen to find by expression.
|
|
2421
2473
|
|
|
2422
2474
|
Args:
|
|
@@ -2429,10 +2481,10 @@ class DataFrameTable(DataTable):
|
|
|
2429
2481
|
# Push the search modal screen
|
|
2430
2482
|
self.app.push_screen(
|
|
2431
2483
|
SearchScreen("Find", term, self.df, cidx),
|
|
2432
|
-
callback=self.
|
|
2484
|
+
callback=self.find if scope == "column" else self.find_global,
|
|
2433
2485
|
)
|
|
2434
2486
|
|
|
2435
|
-
def
|
|
2487
|
+
def find(self, result) -> None:
|
|
2436
2488
|
"""Find a term in current column."""
|
|
2437
2489
|
if result is None:
|
|
2438
2490
|
return
|
|
@@ -2441,7 +2493,7 @@ class DataFrameTable(DataTable):
|
|
|
2441
2493
|
col_name = self.df.columns[cidx]
|
|
2442
2494
|
|
|
2443
2495
|
try:
|
|
2444
|
-
matches = self.
|
|
2496
|
+
matches = self.find_matches(term, cidx, match_nocase, match_whole)
|
|
2445
2497
|
except Exception as e:
|
|
2446
2498
|
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
2447
2499
|
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
@@ -2456,7 +2508,7 @@ class DataFrameTable(DataTable):
|
|
|
2456
2508
|
return
|
|
2457
2509
|
|
|
2458
2510
|
# Add to history
|
|
2459
|
-
self.
|
|
2511
|
+
self.add_history(f"Found [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
2460
2512
|
|
|
2461
2513
|
# Add to matches and count total
|
|
2462
2514
|
match_count = sum(len(col_idxs) for col_idxs in matches.values())
|
|
@@ -2466,16 +2518,16 @@ class DataFrameTable(DataTable):
|
|
|
2466
2518
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
|
|
2467
2519
|
|
|
2468
2520
|
# Recreate table for display
|
|
2469
|
-
self.
|
|
2521
|
+
self.setup_table()
|
|
2470
2522
|
|
|
2471
|
-
def
|
|
2523
|
+
def find_global(self, result) -> None:
|
|
2472
2524
|
"""Global find a term across all columns."""
|
|
2473
2525
|
if result is None:
|
|
2474
2526
|
return
|
|
2475
2527
|
term, cidx, match_nocase, match_whole = result
|
|
2476
2528
|
|
|
2477
2529
|
try:
|
|
2478
|
-
matches = self.
|
|
2530
|
+
matches = self.find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2479
2531
|
except Exception as e:
|
|
2480
2532
|
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
2481
2533
|
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
@@ -2490,7 +2542,7 @@ class DataFrameTable(DataTable):
|
|
|
2490
2542
|
return
|
|
2491
2543
|
|
|
2492
2544
|
# Add to history
|
|
2493
|
-
self.
|
|
2545
|
+
self.add_history(f"Found [$success]{term}[/] across all columns")
|
|
2494
2546
|
|
|
2495
2547
|
# Add to matches and count total
|
|
2496
2548
|
match_count = sum(len(col_idxs) for col_idxs in matches.values())
|
|
@@ -2502,9 +2554,9 @@ class DataFrameTable(DataTable):
|
|
|
2502
2554
|
)
|
|
2503
2555
|
|
|
2504
2556
|
# Recreate table for display
|
|
2505
|
-
self.
|
|
2557
|
+
self.setup_table()
|
|
2506
2558
|
|
|
2507
|
-
def
|
|
2559
|
+
def do_next_match(self) -> None:
|
|
2508
2560
|
"""Move cursor to the next match."""
|
|
2509
2561
|
if not self.matches:
|
|
2510
2562
|
self.notify("No matches to navigate", title="Next Match", severity="warning")
|
|
@@ -2526,7 +2578,7 @@ class DataFrameTable(DataTable):
|
|
|
2526
2578
|
first_ridx, first_cidx = ordered_matches[0]
|
|
2527
2579
|
self.move_cursor_to(first_ridx, first_cidx)
|
|
2528
2580
|
|
|
2529
|
-
def
|
|
2581
|
+
def do_previous_match(self) -> None:
|
|
2530
2582
|
"""Move cursor to the previous match."""
|
|
2531
2583
|
if not self.matches:
|
|
2532
2584
|
self.notify("No matches to navigate", title="Previous Match", severity="warning")
|
|
@@ -2554,7 +2606,7 @@ class DataFrameTable(DataTable):
|
|
|
2554
2606
|
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
2555
2607
|
self.move_cursor(row=row_idx, column=col_idx)
|
|
2556
2608
|
|
|
2557
|
-
def
|
|
2609
|
+
def do_next_selected_row(self) -> None:
|
|
2558
2610
|
"""Move cursor to the next selected row."""
|
|
2559
2611
|
if not any(self.selected_rows):
|
|
2560
2612
|
self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
|
|
@@ -2576,7 +2628,7 @@ class DataFrameTable(DataTable):
|
|
|
2576
2628
|
first_ridx = selected_row_indices[0]
|
|
2577
2629
|
self.move_cursor_to(first_ridx, self.cursor_col_idx)
|
|
2578
2630
|
|
|
2579
|
-
def
|
|
2631
|
+
def do_previous_selected_row(self) -> None:
|
|
2580
2632
|
"""Move cursor to the previous selected row."""
|
|
2581
2633
|
if not any(self.selected_rows):
|
|
2582
2634
|
self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
|
|
@@ -2599,31 +2651,31 @@ class DataFrameTable(DataTable):
|
|
|
2599
2651
|
self.move_cursor_to(last_ridx, self.cursor_col_idx)
|
|
2600
2652
|
|
|
2601
2653
|
# Replace
|
|
2602
|
-
def
|
|
2654
|
+
def do_replace(self) -> None:
|
|
2603
2655
|
"""Open replace screen for current column."""
|
|
2604
2656
|
# Push the replace modal screen
|
|
2605
2657
|
self.app.push_screen(
|
|
2606
2658
|
FindReplaceScreen(self, title="Find and Replace in Current Column"),
|
|
2607
|
-
callback=self.
|
|
2659
|
+
callback=self.replace,
|
|
2608
2660
|
)
|
|
2609
2661
|
|
|
2610
|
-
def
|
|
2662
|
+
def replace(self, result) -> None:
|
|
2611
2663
|
"""Handle replace in current column."""
|
|
2612
|
-
self.
|
|
2664
|
+
self.handle_replace(result, self.cursor_col_idx)
|
|
2613
2665
|
|
|
2614
|
-
def
|
|
2666
|
+
def do_replace_global(self) -> None:
|
|
2615
2667
|
"""Open replace screen for all columns."""
|
|
2616
2668
|
# Push the replace modal screen
|
|
2617
2669
|
self.app.push_screen(
|
|
2618
2670
|
FindReplaceScreen(self, title="Global Find and Replace"),
|
|
2619
|
-
callback=self.
|
|
2671
|
+
callback=self.replace_global,
|
|
2620
2672
|
)
|
|
2621
2673
|
|
|
2622
|
-
def
|
|
2674
|
+
def replace_global(self, result) -> None:
|
|
2623
2675
|
"""Handle replace across all columns."""
|
|
2624
|
-
self.
|
|
2676
|
+
self.handle_replace(result, None)
|
|
2625
2677
|
|
|
2626
|
-
def
|
|
2678
|
+
def handle_replace(self, result, cidx) -> None:
|
|
2627
2679
|
"""Handle replace result from ReplaceScreen.
|
|
2628
2680
|
|
|
2629
2681
|
Args:
|
|
@@ -2640,14 +2692,14 @@ class DataFrameTable(DataTable):
|
|
|
2640
2692
|
col_name = self.df.columns[cidx]
|
|
2641
2693
|
|
|
2642
2694
|
# Find all matches
|
|
2643
|
-
matches = self.
|
|
2695
|
+
matches = self.find_matches(term_find, cidx, match_nocase, match_whole)
|
|
2644
2696
|
|
|
2645
2697
|
if not matches:
|
|
2646
2698
|
self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
|
|
2647
2699
|
return
|
|
2648
2700
|
|
|
2649
2701
|
# Add to history
|
|
2650
|
-
self.
|
|
2702
|
+
self.add_history(
|
|
2651
2703
|
f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
|
|
2652
2704
|
)
|
|
2653
2705
|
|
|
@@ -2655,11 +2707,11 @@ class DataFrameTable(DataTable):
|
|
|
2655
2707
|
self.matches = {ridx: col_idxs.copy() for ridx, col_idxs in matches.items()}
|
|
2656
2708
|
|
|
2657
2709
|
# Recreate table for display
|
|
2658
|
-
self.
|
|
2710
|
+
self.setup_table()
|
|
2659
2711
|
|
|
2660
2712
|
# Store state for interactive replacement using dataclass
|
|
2661
2713
|
sorted_rows = sorted(self.matches.keys())
|
|
2662
|
-
self.
|
|
2714
|
+
self.replace_state = ReplaceState(
|
|
2663
2715
|
term_find=term_find,
|
|
2664
2716
|
term_replace=term_replace,
|
|
2665
2717
|
match_nocase=match_nocase,
|
|
@@ -2679,10 +2731,10 @@ class DataFrameTable(DataTable):
|
|
|
2679
2731
|
try:
|
|
2680
2732
|
if replace_all:
|
|
2681
2733
|
# Replace all occurrences
|
|
2682
|
-
self.
|
|
2734
|
+
self.replace_all(term_find, term_replace)
|
|
2683
2735
|
else:
|
|
2684
2736
|
# Replace with confirmation for each occurrence
|
|
2685
|
-
self.
|
|
2737
|
+
self.replace_interactive(term_find, term_replace)
|
|
2686
2738
|
|
|
2687
2739
|
except Exception as e:
|
|
2688
2740
|
self.notify(
|
|
@@ -2692,23 +2744,23 @@ class DataFrameTable(DataTable):
|
|
|
2692
2744
|
)
|
|
2693
2745
|
self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
|
|
2694
2746
|
|
|
2695
|
-
def
|
|
2747
|
+
def replace_all(self, term_find: str, term_replace: str) -> None:
|
|
2696
2748
|
"""Replace all occurrences."""
|
|
2697
|
-
state = self.
|
|
2749
|
+
state = self.replace_state
|
|
2698
2750
|
self.app.push_screen(
|
|
2699
2751
|
ConfirmScreen(
|
|
2700
2752
|
"Replace All",
|
|
2701
2753
|
label=f"Replace [$success]{term_find}[/] with [$success]{term_replace or repr('')}[/] for all [$accent]{state.total_occurrence}[/] occurrences?",
|
|
2702
2754
|
),
|
|
2703
|
-
callback=self.
|
|
2755
|
+
callback=self.handle_replace_all_confirmation,
|
|
2704
2756
|
)
|
|
2705
2757
|
|
|
2706
|
-
def
|
|
2758
|
+
def handle_replace_all_confirmation(self, result) -> None:
|
|
2707
2759
|
"""Handle user's confirmation for replace all."""
|
|
2708
2760
|
if result is None:
|
|
2709
2761
|
return
|
|
2710
2762
|
|
|
2711
|
-
state = self.
|
|
2763
|
+
state = self.replace_state
|
|
2712
2764
|
rows = state.rows
|
|
2713
2765
|
cols_per_row = state.cols_per_row
|
|
2714
2766
|
|
|
@@ -2756,7 +2808,11 @@ class DataFrameTable(DataTable):
|
|
|
2756
2808
|
state.replaced_occurrence += len(ridxs)
|
|
2757
2809
|
|
|
2758
2810
|
# Recreate table for display
|
|
2759
|
-
self.
|
|
2811
|
+
self.setup_table()
|
|
2812
|
+
|
|
2813
|
+
# Mark as dirty if any replacements were made
|
|
2814
|
+
if state.replaced_occurrence > 0:
|
|
2815
|
+
self.dirty = True
|
|
2760
2816
|
|
|
2761
2817
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2762
2818
|
self.notify(
|
|
@@ -2764,11 +2820,11 @@ class DataFrameTable(DataTable):
|
|
|
2764
2820
|
title="Replace",
|
|
2765
2821
|
)
|
|
2766
2822
|
|
|
2767
|
-
def
|
|
2823
|
+
def replace_interactive(self, term_find: str, term_replace: str) -> None:
|
|
2768
2824
|
"""Replace with user confirmation for each occurrence."""
|
|
2769
2825
|
try:
|
|
2770
2826
|
# Start with first match
|
|
2771
|
-
self.
|
|
2827
|
+
self.show_next_replace_confirmation()
|
|
2772
2828
|
except Exception as e:
|
|
2773
2829
|
self.notify(
|
|
2774
2830
|
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
|
|
@@ -2777,9 +2833,9 @@ class DataFrameTable(DataTable):
|
|
|
2777
2833
|
)
|
|
2778
2834
|
self.log(f"Error in interactive replace: {str(e)}")
|
|
2779
2835
|
|
|
2780
|
-
def
|
|
2836
|
+
def show_next_replace_confirmation(self) -> None:
|
|
2781
2837
|
"""Show confirmation for next replacement."""
|
|
2782
|
-
state = self.
|
|
2838
|
+
state = self.replace_state
|
|
2783
2839
|
if state.done:
|
|
2784
2840
|
# All done - show final notification
|
|
2785
2841
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
@@ -2787,6 +2843,10 @@ class DataFrameTable(DataTable):
|
|
|
2787
2843
|
if state.skipped_occurrence > 0:
|
|
2788
2844
|
msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
|
|
2789
2845
|
self.notify(msg, title="Replace")
|
|
2846
|
+
|
|
2847
|
+
if state.replaced_occurrence > 0:
|
|
2848
|
+
self.dirty = True
|
|
2849
|
+
|
|
2790
2850
|
return
|
|
2791
2851
|
|
|
2792
2852
|
# Move cursor to next match
|
|
@@ -2801,12 +2861,12 @@ class DataFrameTable(DataTable):
|
|
|
2801
2861
|
|
|
2802
2862
|
self.app.push_screen(
|
|
2803
2863
|
ConfirmScreen("Replace", label=label, maybe="Skip"),
|
|
2804
|
-
callback=self.
|
|
2864
|
+
callback=self.handle_replace_confirmation,
|
|
2805
2865
|
)
|
|
2806
2866
|
|
|
2807
|
-
def
|
|
2867
|
+
def handle_replace_confirmation(self, result) -> None:
|
|
2808
2868
|
"""Handle user's confirmation response."""
|
|
2809
|
-
state = self.
|
|
2869
|
+
state = self.replace_state
|
|
2810
2870
|
if state.done:
|
|
2811
2871
|
return
|
|
2812
2872
|
|
|
@@ -2849,38 +2909,34 @@ class DataFrameTable(DataTable):
|
|
|
2849
2909
|
# Cancel
|
|
2850
2910
|
else:
|
|
2851
2911
|
state.done = True
|
|
2852
|
-
self._setup_table()
|
|
2853
|
-
return
|
|
2854
|
-
|
|
2855
|
-
# Move to next
|
|
2856
|
-
if state.current_cpos + 1 < len(state.cols_per_row[state.current_rpos]):
|
|
2857
|
-
state.current_cpos += 1
|
|
2858
|
-
else:
|
|
2859
|
-
state.current_cpos = 0
|
|
2860
|
-
state.current_rpos += 1
|
|
2861
2912
|
|
|
2862
|
-
if
|
|
2863
|
-
|
|
2913
|
+
if not state.done:
|
|
2914
|
+
# Get the new value of the current cell after replacement
|
|
2915
|
+
new_cell_value = self.df.item(ridx, cidx)
|
|
2916
|
+
row_key = str(ridx)
|
|
2917
|
+
col_key = col_name
|
|
2918
|
+
self.update_cell(
|
|
2919
|
+
row_key, col_key, Text(str(new_cell_value), style=HIGHLIGHT_COLOR, justify=DtypeConfig(dtype).justify)
|
|
2920
|
+
)
|
|
2864
2921
|
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
)
|
|
2922
|
+
# Move to next
|
|
2923
|
+
if state.current_cpos + 1 < len(state.cols_per_row[state.current_rpos]):
|
|
2924
|
+
state.current_cpos += 1
|
|
2925
|
+
else:
|
|
2926
|
+
state.current_cpos = 0
|
|
2927
|
+
state.current_rpos += 1
|
|
2872
2928
|
|
|
2873
|
-
|
|
2874
|
-
|
|
2929
|
+
if state.current_rpos >= len(state.rows):
|
|
2930
|
+
state.done = True
|
|
2875
2931
|
|
|
2876
2932
|
# Show next confirmation
|
|
2877
|
-
self.
|
|
2933
|
+
self.show_next_replace_confirmation()
|
|
2878
2934
|
|
|
2879
2935
|
# Selection & Match
|
|
2880
|
-
def
|
|
2936
|
+
def do_toggle_selections(self) -> None:
|
|
2881
2937
|
"""Toggle selected rows highlighting on/off."""
|
|
2882
2938
|
# Add to history
|
|
2883
|
-
self.
|
|
2939
|
+
self.add_history("Toggled row selection")
|
|
2884
2940
|
|
|
2885
2941
|
if False in self.visible_rows:
|
|
2886
2942
|
# Some rows are hidden - invert only selected visible rows and clear selections for hidden rows
|
|
@@ -2898,12 +2954,12 @@ class DataFrameTable(DataTable):
|
|
|
2898
2954
|
self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
|
|
2899
2955
|
|
|
2900
2956
|
# Recreate table for display
|
|
2901
|
-
self.
|
|
2957
|
+
self.setup_table()
|
|
2902
2958
|
|
|
2903
|
-
def
|
|
2959
|
+
def do_toggle_row_selection(self) -> None:
|
|
2904
2960
|
"""Select/deselect current row."""
|
|
2905
2961
|
# Add to history
|
|
2906
|
-
self.
|
|
2962
|
+
self.add_history("Toggled row selection")
|
|
2907
2963
|
|
|
2908
2964
|
ridx = self.cursor_row_idx
|
|
2909
2965
|
self.selected_rows[ridx] = not self.selected_rows[ridx]
|
|
@@ -2924,7 +2980,7 @@ class DataFrameTable(DataTable):
|
|
|
2924
2980
|
|
|
2925
2981
|
self.update_cell(row_key, col_key, cell_text)
|
|
2926
2982
|
|
|
2927
|
-
def
|
|
2983
|
+
def do_clear_selections_and_matches(self) -> None:
|
|
2928
2984
|
"""Clear all selected rows and matches without removing them from the dataframe."""
|
|
2929
2985
|
# Check if any selected rows or matches
|
|
2930
2986
|
if not any(self.selected_rows) and not self.matches:
|
|
@@ -2936,19 +2992,19 @@ class DataFrameTable(DataTable):
|
|
|
2936
2992
|
)
|
|
2937
2993
|
|
|
2938
2994
|
# Add to history
|
|
2939
|
-
self.
|
|
2995
|
+
self.add_history("Cleared all selected rows")
|
|
2940
2996
|
|
|
2941
2997
|
# Clear all selections
|
|
2942
2998
|
self.selected_rows = [False] * len(self.df)
|
|
2943
2999
|
self.matches = defaultdict(set)
|
|
2944
3000
|
|
|
2945
3001
|
# Recreate table for display
|
|
2946
|
-
self.
|
|
3002
|
+
self.setup_table()
|
|
2947
3003
|
|
|
2948
3004
|
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
2949
3005
|
|
|
2950
3006
|
# Filter & View
|
|
2951
|
-
def
|
|
3007
|
+
def do_filter_rows(self) -> None:
|
|
2952
3008
|
"""Keep only the rows with selections and matches, and remove others."""
|
|
2953
3009
|
if not any(self.selected_rows) and not self.matches:
|
|
2954
3010
|
self.notify("No rows to filter", title="Filter", severity="warning")
|
|
@@ -2959,28 +3015,23 @@ class DataFrameTable(DataTable):
|
|
|
2959
3015
|
]
|
|
2960
3016
|
|
|
2961
3017
|
# Add to history
|
|
2962
|
-
self.
|
|
3018
|
+
self.add_history("Filtered to selections and matches", dirty=True)
|
|
2963
3019
|
|
|
2964
3020
|
# Apply filter to dataframe with row indices
|
|
2965
3021
|
df_filtered = self.df.with_row_index(RIDX).filter(filter_expr)
|
|
2966
3022
|
|
|
2967
|
-
# Update selections and matches
|
|
2968
|
-
self.selected_rows = [self.selected_rows[ridx] for ridx in df_filtered[RIDX]]
|
|
2969
|
-
self.matches = {
|
|
2970
|
-
idx: self.matches[ridx].copy() for idx, ridx in enumerate(df_filtered[RIDX]) if ridx in self.matches
|
|
2971
|
-
}
|
|
2972
|
-
|
|
2973
3023
|
# Update dataframe
|
|
2974
|
-
self.
|
|
3024
|
+
self.reset_df(df_filtered.drop(RIDX))
|
|
2975
3025
|
|
|
2976
3026
|
# Recreate table for display
|
|
2977
|
-
self.
|
|
3027
|
+
self.setup_table()
|
|
2978
3028
|
|
|
2979
3029
|
self.notify(
|
|
2980
|
-
f"
|
|
3030
|
+
f"Filtered rows with selections or matches and removed others. Now showing [$accent]{len(self.df)}[/] rows",
|
|
3031
|
+
title="Filter",
|
|
2981
3032
|
)
|
|
2982
3033
|
|
|
2983
|
-
def
|
|
3034
|
+
def do_view_rows(self) -> None:
|
|
2984
3035
|
"""View rows.
|
|
2985
3036
|
|
|
2986
3037
|
If there are selected rows or matches, view those rows.
|
|
@@ -2997,22 +3048,24 @@ class DataFrameTable(DataTable):
|
|
|
2997
3048
|
# Otherwise, use the current cell value
|
|
2998
3049
|
else:
|
|
2999
3050
|
ridx = self.cursor_row_idx
|
|
3000
|
-
|
|
3051
|
+
value = self.df.item(ridx, cidx)
|
|
3052
|
+
term = NULL if value is None else str(value)
|
|
3001
3053
|
|
|
3002
|
-
self.
|
|
3054
|
+
self.view_rows((term, cidx, False, True))
|
|
3003
3055
|
|
|
3004
|
-
def
|
|
3056
|
+
def do_view_rows_expr(self) -> None:
|
|
3005
3057
|
"""Open the filter screen to enter an expression."""
|
|
3006
3058
|
ridx = self.cursor_row_idx
|
|
3007
3059
|
cidx = self.cursor_col_idx
|
|
3008
|
-
cursor_value =
|
|
3060
|
+
cursor_value = self.df.item(ridx, cidx)
|
|
3061
|
+
term = NULL if cursor_value is None else str(cursor_value)
|
|
3009
3062
|
|
|
3010
3063
|
self.app.push_screen(
|
|
3011
|
-
FilterScreen(self.df, cidx,
|
|
3012
|
-
callback=self.
|
|
3064
|
+
FilterScreen(self.df, cidx, term),
|
|
3065
|
+
callback=self.view_rows,
|
|
3013
3066
|
)
|
|
3014
3067
|
|
|
3015
|
-
def
|
|
3068
|
+
def view_rows(self, result) -> None:
|
|
3016
3069
|
"""Show only rows with selections or matches, and do hide others. Do not modify the dataframe."""
|
|
3017
3070
|
if result is None:
|
|
3018
3071
|
return
|
|
@@ -3062,13 +3115,15 @@ class DataFrameTable(DataTable):
|
|
|
3062
3115
|
if False in self.visible_rows:
|
|
3063
3116
|
lf = lf.filter(self.visible_rows)
|
|
3064
3117
|
|
|
3118
|
+
expr_str = "boolean list or series" if isinstance(expr, (list, pl.Series)) else str(expr)
|
|
3119
|
+
|
|
3065
3120
|
# Apply the filter expression
|
|
3066
3121
|
try:
|
|
3067
3122
|
df_filtered = lf.filter(expr).collect()
|
|
3068
3123
|
except Exception as e:
|
|
3069
3124
|
self.histories.pop() # Remove last history entry
|
|
3070
|
-
self.notify(f"Error applying filter [$error]{
|
|
3071
|
-
self.log(f"Error applying filter `{
|
|
3125
|
+
self.notify(f"Error applying filter [$error]{expr_str}[/]", title="Filter", severity="error")
|
|
3126
|
+
self.log(f"Error applying filter `{expr_str}`: {str(e)}")
|
|
3072
3127
|
return
|
|
3073
3128
|
|
|
3074
3129
|
matched_count = len(df_filtered)
|
|
@@ -3077,7 +3132,7 @@ class DataFrameTable(DataTable):
|
|
|
3077
3132
|
return
|
|
3078
3133
|
|
|
3079
3134
|
# Add to history
|
|
3080
|
-
self.
|
|
3135
|
+
self.add_history(f"Filtered by expression [$success]{expr_str}[/]")
|
|
3081
3136
|
|
|
3082
3137
|
# Mark unfiltered rows as invisible
|
|
3083
3138
|
filtered_row_indices = set(df_filtered[RIDX].to_list())
|
|
@@ -3087,12 +3142,12 @@ class DataFrameTable(DataTable):
|
|
|
3087
3142
|
self.visible_rows[ridx] = False
|
|
3088
3143
|
|
|
3089
3144
|
# Recreate table for display
|
|
3090
|
-
self.
|
|
3145
|
+
self.setup_table()
|
|
3091
3146
|
|
|
3092
3147
|
self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
|
|
3093
3148
|
|
|
3094
3149
|
# Copy & Save
|
|
3095
|
-
def
|
|
3150
|
+
def do_copy_to_clipboard(self, content: str, message: str) -> None:
|
|
3096
3151
|
"""Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
|
|
3097
3152
|
|
|
3098
3153
|
Args:
|
|
@@ -3115,54 +3170,64 @@ class DataFrameTable(DataTable):
|
|
|
3115
3170
|
except FileNotFoundError:
|
|
3116
3171
|
self.notify("Error copying to clipboard", title="Clipboard", severity="error")
|
|
3117
3172
|
|
|
3118
|
-
def
|
|
3173
|
+
def do_save_to_file(
|
|
3174
|
+
self, title: str = "Save to File", all_tabs: bool | None = None, task_after_save: str | None = None
|
|
3175
|
+
) -> None:
|
|
3119
3176
|
"""Open screen to save file."""
|
|
3120
|
-
self.
|
|
3177
|
+
self._task_after_save = task_after_save
|
|
3178
|
+
|
|
3179
|
+
multi_tab = len(self.app.tabs) > 1
|
|
3180
|
+
filename = (
|
|
3181
|
+
"all-tabs.xlsx"
|
|
3182
|
+
if all_tabs or (all_tabs is None and multi_tab)
|
|
3183
|
+
else str(Path(self.filename).with_stem(self.tabname))
|
|
3184
|
+
)
|
|
3185
|
+
self.app.push_screen(
|
|
3186
|
+
SaveFileScreen(filename, title=title, all_tabs=all_tabs, multi_tab=multi_tab),
|
|
3187
|
+
callback=self.save_to_file,
|
|
3188
|
+
)
|
|
3121
3189
|
|
|
3122
|
-
def
|
|
3190
|
+
def save_to_file(self, result) -> None:
|
|
3123
3191
|
"""Handle result from SaveFileScreen."""
|
|
3124
|
-
if
|
|
3192
|
+
if result is None:
|
|
3125
3193
|
return
|
|
3126
|
-
|
|
3127
|
-
ext = filepath.suffix.lower()
|
|
3194
|
+
filename, all_tabs = result
|
|
3128
3195
|
|
|
3129
3196
|
# Whether to save all tabs (for Excel files)
|
|
3130
3197
|
self._all_tabs = all_tabs
|
|
3131
3198
|
|
|
3132
3199
|
# Check if file exists
|
|
3133
|
-
if
|
|
3200
|
+
if Path(filename).exists():
|
|
3134
3201
|
self._pending_filename = filename
|
|
3135
3202
|
self.app.push_screen(
|
|
3136
3203
|
ConfirmScreen("File already exists. Overwrite?"),
|
|
3137
|
-
callback=self.
|
|
3204
|
+
callback=self.confirm_overwrite,
|
|
3138
3205
|
)
|
|
3139
|
-
elif ext in (".xlsx", ".xls"):
|
|
3140
|
-
self._do_save_excel(filename)
|
|
3141
3206
|
else:
|
|
3142
|
-
self.
|
|
3207
|
+
self.save_file(filename)
|
|
3143
3208
|
|
|
3144
|
-
def
|
|
3209
|
+
def confirm_overwrite(self, should_overwrite: bool) -> None:
|
|
3145
3210
|
"""Handle result from ConfirmScreen."""
|
|
3146
3211
|
if should_overwrite:
|
|
3147
|
-
self.
|
|
3212
|
+
self.save_file(self._pending_filename)
|
|
3148
3213
|
else:
|
|
3149
3214
|
# Go back to SaveFileScreen to allow user to enter a different name
|
|
3150
3215
|
self.app.push_screen(
|
|
3151
3216
|
SaveFileScreen(self._pending_filename),
|
|
3152
|
-
callback=self.
|
|
3217
|
+
callback=self.save_to_file,
|
|
3153
3218
|
)
|
|
3154
3219
|
|
|
3155
|
-
def
|
|
3220
|
+
def save_file(self, filename: str) -> None:
|
|
3156
3221
|
"""Actually save the dataframe to a file."""
|
|
3157
3222
|
filepath = Path(filename)
|
|
3158
3223
|
ext = filepath.suffix.lower()
|
|
3159
3224
|
|
|
3160
3225
|
# Add to history
|
|
3161
|
-
self.
|
|
3226
|
+
self.add_history(f"Saved dataframe to [$success]{filename}[/]")
|
|
3162
3227
|
|
|
3163
3228
|
try:
|
|
3164
3229
|
if ext in (".xlsx", ".xls"):
|
|
3165
|
-
self.
|
|
3230
|
+
self.save_excel(filename)
|
|
3166
3231
|
elif ext in (".tsv", ".tab"):
|
|
3167
3232
|
self.df.write_csv(filename, separator="\t")
|
|
3168
3233
|
elif ext == ".json":
|
|
@@ -3174,14 +3239,31 @@ class DataFrameTable(DataTable):
|
|
|
3174
3239
|
|
|
3175
3240
|
self.dataframe = self.df # Update original dataframe
|
|
3176
3241
|
self.filename = filename # Update current filename
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3242
|
+
|
|
3243
|
+
# Reset dirty flag after save
|
|
3244
|
+
if self._all_tabs:
|
|
3245
|
+
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3246
|
+
for table in tabs.values():
|
|
3247
|
+
table.dirty = False
|
|
3248
|
+
else:
|
|
3249
|
+
self.dirty = False
|
|
3250
|
+
|
|
3251
|
+
if self._task_after_save == "close_tab":
|
|
3252
|
+
self.app.do_close_tab()
|
|
3253
|
+
elif self._task_after_save == "quit_app":
|
|
3254
|
+
self.app.exit()
|
|
3255
|
+
|
|
3256
|
+
# From ConfirmScreen callback, so notify accordingly
|
|
3257
|
+
if self._all_tabs:
|
|
3258
|
+
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
|
|
3259
|
+
else:
|
|
3260
|
+
self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
|
|
3261
|
+
|
|
3180
3262
|
except Exception as e:
|
|
3181
|
-
self.notify(f"Error saving [$error]{filename}[/]", title="Save", severity="error")
|
|
3263
|
+
self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error")
|
|
3182
3264
|
self.log(f"Error saving file `{filename}`: {str(e)}")
|
|
3183
3265
|
|
|
3184
|
-
def
|
|
3266
|
+
def save_excel(self, filename: str) -> None:
|
|
3185
3267
|
"""Save to an Excel file."""
|
|
3186
3268
|
import xlsxwriter
|
|
3187
3269
|
|
|
@@ -3192,75 +3274,97 @@ class DataFrameTable(DataTable):
|
|
|
3192
3274
|
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
3193
3275
|
with xlsxwriter.Workbook(filename) as wb:
|
|
3194
3276
|
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3195
|
-
for
|
|
3196
|
-
worksheet = wb.add_worksheet(
|
|
3277
|
+
for table in tabs.values():
|
|
3278
|
+
worksheet = wb.add_worksheet(table.tabname)
|
|
3197
3279
|
table.df.write_excel(workbook=wb, worksheet=worksheet)
|
|
3198
3280
|
|
|
3199
|
-
# From ConfirmScreen callback, so notify accordingly
|
|
3200
|
-
if self._all_tabs is True:
|
|
3201
|
-
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save")
|
|
3202
|
-
else:
|
|
3203
|
-
self.notify(
|
|
3204
|
-
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save"
|
|
3205
|
-
)
|
|
3206
|
-
|
|
3207
3281
|
# SQL Interface
|
|
3208
|
-
def
|
|
3282
|
+
def do_simple_sql(self) -> None:
|
|
3209
3283
|
"""Open the SQL interface screen."""
|
|
3210
3284
|
self.app.push_screen(
|
|
3211
3285
|
SimpleSqlScreen(self),
|
|
3212
|
-
callback=self.
|
|
3286
|
+
callback=self.simple_sql,
|
|
3213
3287
|
)
|
|
3214
3288
|
|
|
3215
|
-
def
|
|
3289
|
+
def simple_sql(self, result) -> None:
|
|
3216
3290
|
"""Handle SQL result result from SimpleSqlScreen."""
|
|
3217
3291
|
if result is None:
|
|
3218
3292
|
return
|
|
3219
|
-
columns, where = result
|
|
3293
|
+
columns, where, view = result
|
|
3220
3294
|
|
|
3221
3295
|
sql = f"SELECT {columns} FROM self"
|
|
3222
3296
|
if where:
|
|
3223
3297
|
sql += f" WHERE {where}"
|
|
3224
3298
|
|
|
3225
|
-
self.
|
|
3299
|
+
self.run_sql(sql, view)
|
|
3226
3300
|
|
|
3227
|
-
def
|
|
3301
|
+
def do_advanced_sql(self) -> None:
|
|
3228
3302
|
"""Open the advanced SQL interface screen."""
|
|
3229
3303
|
self.app.push_screen(
|
|
3230
3304
|
AdvancedSqlScreen(self),
|
|
3231
|
-
callback=self.
|
|
3305
|
+
callback=self.advanced_sql,
|
|
3232
3306
|
)
|
|
3233
3307
|
|
|
3234
|
-
def
|
|
3308
|
+
def advanced_sql(self, result) -> None:
|
|
3235
3309
|
"""Handle SQL result result from AdvancedSqlScreen."""
|
|
3236
3310
|
if result is None:
|
|
3237
3311
|
return
|
|
3312
|
+
sql, view = result
|
|
3238
3313
|
|
|
3239
|
-
self.
|
|
3314
|
+
self.run_sql(sql, view)
|
|
3240
3315
|
|
|
3241
|
-
def
|
|
3316
|
+
def run_sql(self, sql: str, view: bool = True) -> None:
|
|
3242
3317
|
"""Execute a SQL query directly.
|
|
3243
3318
|
|
|
3244
3319
|
Args:
|
|
3245
3320
|
sql: The SQL query string to execute.
|
|
3246
3321
|
"""
|
|
3247
|
-
|
|
3248
|
-
|
|
3322
|
+
|
|
3323
|
+
import re
|
|
3324
|
+
|
|
3325
|
+
RE_FROM_SELF = re.compile(r"\bfrom\s+self\b", re.IGNORECASE)
|
|
3326
|
+
|
|
3327
|
+
sql = RE_FROM_SELF.sub(f", `{RIDX}` FROM self", sql)
|
|
3249
3328
|
|
|
3250
3329
|
# Execute the SQL query
|
|
3251
3330
|
try:
|
|
3252
|
-
|
|
3331
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
3332
|
+
if False in self.visible_rows:
|
|
3333
|
+
lf = lf.filter(self.visible_rows)
|
|
3334
|
+
|
|
3335
|
+
df_filtered = lf.sql(sql).collect()
|
|
3336
|
+
|
|
3337
|
+
if not len(df_filtered):
|
|
3338
|
+
self.notify(
|
|
3339
|
+
f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning"
|
|
3340
|
+
)
|
|
3341
|
+
return
|
|
3342
|
+
|
|
3343
|
+
# Add to history
|
|
3344
|
+
self.add_history(f"SQL Query:\n[$accent]{sql}[/]", dirty=not view)
|
|
3345
|
+
|
|
3346
|
+
if view:
|
|
3347
|
+
# Just view - do not modify the dataframe
|
|
3348
|
+
filtered_row_indices = set(df_filtered[RIDX].to_list())
|
|
3349
|
+
if filtered_row_indices:
|
|
3350
|
+
self.visible_rows = [ridx in filtered_row_indices for ridx in range(len(self.visible_rows))]
|
|
3351
|
+
|
|
3352
|
+
filtered_col_names = set(df_filtered.columns)
|
|
3353
|
+
if filtered_col_names:
|
|
3354
|
+
self.hidden_columns = {
|
|
3355
|
+
col_name for col_name in self.df.columns if col_name not in filtered_col_names
|
|
3356
|
+
}
|
|
3357
|
+
else: # filter - modify the dataframe
|
|
3358
|
+
self.df = df_filtered.drop(RIDX)
|
|
3359
|
+
self.visible_rows = [True] * len(self.df)
|
|
3360
|
+
self.hidden_columns.clear()
|
|
3253
3361
|
except Exception as e:
|
|
3254
|
-
self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error")
|
|
3362
|
+
self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error", timeout=10)
|
|
3255
3363
|
self.log(f"Error executing SQL query `{sql}`: {str(e)}")
|
|
3256
3364
|
return
|
|
3257
3365
|
|
|
3258
|
-
if not len(self.df):
|
|
3259
|
-
self.notify(f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning")
|
|
3260
|
-
return
|
|
3261
|
-
|
|
3262
3366
|
# Recreate table for display
|
|
3263
|
-
self.
|
|
3367
|
+
self.setup_table()
|
|
3264
3368
|
|
|
3265
3369
|
self.notify(
|
|
3266
3370
|
f"SQL query executed successfully. Now showing [$accent]{len(self.df)}[/] rows and [$accent]{len(self.df.columns)}[/] columns.",
|