dataframe-textual 1.5.0__py3-none-any.whl → 1.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataframe_textual/__main__.py +1 -1
- dataframe_textual/common.py +15 -7
- dataframe_textual/data_frame_table.py +979 -879
- dataframe_textual/data_frame_viewer.py +317 -101
- dataframe_textual/sql_screen.py +50 -11
- dataframe_textual/table_screen.py +1 -1
- dataframe_textual/yes_no_screen.py +66 -5
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-1.9.0.dist-info}/METADATA +106 -245
- dataframe_textual-1.9.0.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.9.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-1.9.0.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-1.9.0.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,40 @@ 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 setup_table(self, reset: bool = False) -> None:
|
|
902
920
|
"""Setup the table for display.
|
|
903
921
|
|
|
904
922
|
Row keys are 0-based indices, which map directly to dataframe row indices.
|
|
@@ -918,6 +936,9 @@ class DataFrameTable(DataTable):
|
|
|
918
936
|
self.fixed_rows = 0
|
|
919
937
|
self.fixed_columns = 0
|
|
920
938
|
self.matches = defaultdict(set)
|
|
939
|
+
self.histories.clear()
|
|
940
|
+
self.history = None
|
|
941
|
+
self.dirty = False
|
|
921
942
|
|
|
922
943
|
# Lazy load up to INITIAL_BATCH_SIZE visible rows
|
|
923
944
|
stop, visible_count = self.INITIAL_BATCH_SIZE, 0
|
|
@@ -938,14 +959,14 @@ class DataFrameTable(DataTable):
|
|
|
938
959
|
# Save current cursor position before clearing
|
|
939
960
|
row_idx, col_idx = self.cursor_coordinate
|
|
940
961
|
|
|
941
|
-
self.
|
|
942
|
-
self.
|
|
962
|
+
self.setup_columns()
|
|
963
|
+
self.load_rows(stop)
|
|
943
964
|
|
|
944
965
|
# Restore cursor position
|
|
945
966
|
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
946
967
|
self.move_cursor(row=row_idx, column=col_idx)
|
|
947
968
|
|
|
948
|
-
def
|
|
969
|
+
def determine_column_widths(self) -> dict[str, int]:
|
|
949
970
|
"""Determine optimal width for each column based on data type and content.
|
|
950
971
|
|
|
951
972
|
For String columns:
|
|
@@ -1015,7 +1036,7 @@ class DataFrameTable(DataTable):
|
|
|
1015
1036
|
|
|
1016
1037
|
return column_widths
|
|
1017
1038
|
|
|
1018
|
-
def
|
|
1039
|
+
def setup_columns(self) -> None:
|
|
1019
1040
|
"""Clear table and setup columns.
|
|
1020
1041
|
|
|
1021
1042
|
Column keys are header names from the dataframe.
|
|
@@ -1024,7 +1045,7 @@ class DataFrameTable(DataTable):
|
|
|
1024
1045
|
self.clear(columns=True)
|
|
1025
1046
|
|
|
1026
1047
|
# Get optimal column widths
|
|
1027
|
-
column_widths = self.
|
|
1048
|
+
column_widths = self.determine_column_widths()
|
|
1028
1049
|
|
|
1029
1050
|
# Add columns with justified headers
|
|
1030
1051
|
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
@@ -1047,7 +1068,7 @@ class DataFrameTable(DataTable):
|
|
|
1047
1068
|
|
|
1048
1069
|
self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
|
|
1049
1070
|
|
|
1050
|
-
def
|
|
1071
|
+
def load_rows(self, stop: int | None = None, move_to_end: bool = False) -> None:
|
|
1051
1072
|
"""Load a batch of rows into the table (synchronous wrapper).
|
|
1052
1073
|
|
|
1053
1074
|
Args:
|
|
@@ -1069,7 +1090,7 @@ class DataFrameTable(DataTable):
|
|
|
1069
1090
|
|
|
1070
1091
|
def _continue(result: bool) -> None:
|
|
1071
1092
|
if result:
|
|
1072
|
-
self.
|
|
1093
|
+
self.load_rows_async(stop, move_to_end=move_to_end)
|
|
1073
1094
|
|
|
1074
1095
|
self.app.push_screen(
|
|
1075
1096
|
ConfirmScreen(
|
|
@@ -1082,10 +1103,10 @@ class DataFrameTable(DataTable):
|
|
|
1082
1103
|
return
|
|
1083
1104
|
|
|
1084
1105
|
# Load rows asynchronously
|
|
1085
|
-
self.
|
|
1106
|
+
self.load_rows_async(stop, move_to_end=move_to_end)
|
|
1086
1107
|
|
|
1087
1108
|
@work(exclusive=True, description="Loading rows...")
|
|
1088
|
-
async def
|
|
1109
|
+
async def load_rows_async(self, stop: int, move_to_end: bool = False) -> None:
|
|
1089
1110
|
"""Perform loading with async to avoid blocking.
|
|
1090
1111
|
|
|
1091
1112
|
Args:
|
|
@@ -1099,19 +1120,19 @@ class DataFrameTable(DataTable):
|
|
|
1099
1120
|
# Load max BATCH_SIZE rows at a time
|
|
1100
1121
|
chunk_size = min(self.BATCH_SIZE, stop - self.loaded_rows)
|
|
1101
1122
|
next_stop = min(self.loaded_rows + chunk_size, stop)
|
|
1102
|
-
self.
|
|
1123
|
+
self.load_rows_batch(next_stop)
|
|
1103
1124
|
|
|
1104
1125
|
# If there's more to load, yield to event loop with delay
|
|
1105
1126
|
if next_stop < stop:
|
|
1106
1127
|
await sleep_async(0.05) # 50ms delay to allow UI updates
|
|
1107
|
-
self.
|
|
1128
|
+
self.load_rows_async(stop, move_to_end=move_to_end)
|
|
1108
1129
|
return
|
|
1109
1130
|
|
|
1110
1131
|
# After loading completes, move cursor to end if requested
|
|
1111
1132
|
if move_to_end:
|
|
1112
1133
|
self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
|
|
1113
1134
|
|
|
1114
|
-
def
|
|
1135
|
+
def load_rows_batch(self, stop: int) -> None:
|
|
1115
1136
|
"""Load a batch of rows into the table.
|
|
1116
1137
|
|
|
1117
1138
|
Row keys are 0-based indices as strings, which map directly to dataframe row indices.
|
|
@@ -1157,7 +1178,7 @@ class DataFrameTable(DataTable):
|
|
|
1157
1178
|
self.notify("Error loading rows", title="Load", severity="error")
|
|
1158
1179
|
self.log(f"Error loading rows: {str(e)}")
|
|
1159
1180
|
|
|
1160
|
-
def
|
|
1181
|
+
def check_and_load_more(self) -> None:
|
|
1161
1182
|
"""Check if we need to load more rows and load them."""
|
|
1162
1183
|
# If we've loaded everything, no need to check
|
|
1163
1184
|
if self.loaded_rows >= len(self.df):
|
|
@@ -1168,10 +1189,10 @@ class DataFrameTable(DataTable):
|
|
|
1168
1189
|
|
|
1169
1190
|
# If visible area is close to the end of loaded rows, load more
|
|
1170
1191
|
if bottom_visible_row >= self.loaded_rows - 10:
|
|
1171
|
-
self.
|
|
1192
|
+
self.load_rows(self.loaded_rows + self.BATCH_SIZE)
|
|
1172
1193
|
|
|
1173
1194
|
# Highlighting
|
|
1174
|
-
def
|
|
1195
|
+
def apply_highlight(self, force: bool = False) -> None:
|
|
1175
1196
|
"""Update all rows, highlighting selected ones and restoring others to default.
|
|
1176
1197
|
|
|
1177
1198
|
Args:
|
|
@@ -1181,10 +1202,10 @@ class DataFrameTable(DataTable):
|
|
|
1181
1202
|
stop = rindex(self.selected_rows, True) + 1
|
|
1182
1203
|
stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
1183
1204
|
|
|
1184
|
-
self.
|
|
1185
|
-
self.
|
|
1205
|
+
self.load_rows(stop)
|
|
1206
|
+
self.highlight_table(force)
|
|
1186
1207
|
|
|
1187
|
-
def
|
|
1208
|
+
def highlight_table(self, force: bool = False) -> None:
|
|
1188
1209
|
"""Highlight selected rows/cells in red."""
|
|
1189
1210
|
if not force and not any(self.selected_rows) and not self.matches:
|
|
1190
1211
|
return # Nothing to highlight
|
|
@@ -1221,7 +1242,7 @@ class DataFrameTable(DataTable):
|
|
|
1221
1242
|
self.update_cell(row.key, col.key, cell_text)
|
|
1222
1243
|
|
|
1223
1244
|
# History & Undo
|
|
1224
|
-
def
|
|
1245
|
+
def create_history(self, description: str) -> None:
|
|
1225
1246
|
"""Create the initial history state."""
|
|
1226
1247
|
return History(
|
|
1227
1248
|
description=description,
|
|
@@ -1236,9 +1257,10 @@ class DataFrameTable(DataTable):
|
|
|
1236
1257
|
fixed_columns=self.fixed_columns,
|
|
1237
1258
|
cursor_coordinate=self.cursor_coordinate,
|
|
1238
1259
|
matches={k: v.copy() for k, v in self.matches.items()},
|
|
1260
|
+
dirty=self.dirty,
|
|
1239
1261
|
)
|
|
1240
1262
|
|
|
1241
|
-
def
|
|
1263
|
+
def apply_history(self, history: History) -> None:
|
|
1242
1264
|
"""Apply the current history state to the table."""
|
|
1243
1265
|
if history is None:
|
|
1244
1266
|
return
|
|
@@ -1255,20 +1277,26 @@ class DataFrameTable(DataTable):
|
|
|
1255
1277
|
self.fixed_columns = history.fixed_columns
|
|
1256
1278
|
self.cursor_coordinate = history.cursor_coordinate
|
|
1257
1279
|
self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
|
|
1280
|
+
self.dirty = history.dirty
|
|
1258
1281
|
|
|
1259
1282
|
# Recreate table for display
|
|
1260
|
-
self.
|
|
1283
|
+
self.setup_table()
|
|
1261
1284
|
|
|
1262
|
-
def
|
|
1285
|
+
def add_history(self, description: str, dirty: bool = False) -> None:
|
|
1263
1286
|
"""Add the current state to the history stack.
|
|
1264
1287
|
|
|
1265
1288
|
Args:
|
|
1266
1289
|
description: Description of the action for this history entry.
|
|
1290
|
+
dirty: Whether this operation modifies the data (True) or just display state (False).
|
|
1267
1291
|
"""
|
|
1268
|
-
history = self.
|
|
1292
|
+
history = self.create_history(description)
|
|
1269
1293
|
self.histories.append(history)
|
|
1270
1294
|
|
|
1271
|
-
|
|
1295
|
+
# Mark table as dirty if this operation modifies data
|
|
1296
|
+
if dirty:
|
|
1297
|
+
self.dirty = True
|
|
1298
|
+
|
|
1299
|
+
def do_undo(self) -> None:
|
|
1272
1300
|
"""Undo the last action."""
|
|
1273
1301
|
if not self.histories:
|
|
1274
1302
|
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
@@ -1278,14 +1306,14 @@ class DataFrameTable(DataTable):
|
|
|
1278
1306
|
history = self.histories.pop()
|
|
1279
1307
|
|
|
1280
1308
|
# Save current state for redo
|
|
1281
|
-
self.history = self.
|
|
1309
|
+
self.history = self.create_history(history.description)
|
|
1282
1310
|
|
|
1283
1311
|
# Restore state
|
|
1284
|
-
self.
|
|
1312
|
+
self.apply_history(history)
|
|
1285
1313
|
|
|
1286
1314
|
self.notify(f"Reverted: {history.description}", title="Undo")
|
|
1287
1315
|
|
|
1288
|
-
def
|
|
1316
|
+
def do_redo(self) -> None:
|
|
1289
1317
|
"""Redo the last undone action."""
|
|
1290
1318
|
if self.history is None:
|
|
1291
1319
|
self.notify("No actions to redo", title="Redo", severity="warning")
|
|
@@ -1294,39 +1322,51 @@ class DataFrameTable(DataTable):
|
|
|
1294
1322
|
description = self.history.description
|
|
1295
1323
|
|
|
1296
1324
|
# Save current state for undo
|
|
1297
|
-
self.
|
|
1325
|
+
self.add_history(description)
|
|
1298
1326
|
|
|
1299
1327
|
# Restore state
|
|
1300
|
-
self.
|
|
1328
|
+
self.apply_history(self.history)
|
|
1301
1329
|
|
|
1302
1330
|
# Clear redo state
|
|
1303
1331
|
self.history = None
|
|
1304
1332
|
|
|
1305
1333
|
self.notify(f"Reapplied: {description}", title="Redo")
|
|
1306
1334
|
|
|
1335
|
+
def do_reset(self) -> None:
|
|
1336
|
+
"""Reset the table to the initial state."""
|
|
1337
|
+
self.setup_table(reset=True)
|
|
1338
|
+
self.notify("Restored initial state", title="Reset")
|
|
1339
|
+
|
|
1340
|
+
def restore_dirty(self, default: bool | None = None) -> None:
|
|
1341
|
+
"""Restore the dirty state from the last history entry."""
|
|
1342
|
+
if self.last_history:
|
|
1343
|
+
self.dirty = self.last_history.dirty
|
|
1344
|
+
elif default is not None:
|
|
1345
|
+
self.dirty = default
|
|
1346
|
+
|
|
1307
1347
|
# Display
|
|
1308
|
-
def
|
|
1348
|
+
def do_cycle_cursor_type(self) -> None:
|
|
1309
1349
|
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
1310
1350
|
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
1311
1351
|
self.cursor_type = next_type
|
|
1312
1352
|
|
|
1313
1353
|
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
1314
1354
|
|
|
1315
|
-
def
|
|
1355
|
+
def do_view_row_detail(self) -> None:
|
|
1316
1356
|
"""Open a modal screen to view the selected row's details."""
|
|
1317
1357
|
ridx = self.cursor_row_idx
|
|
1318
1358
|
|
|
1319
1359
|
# Push the modal screen
|
|
1320
1360
|
self.app.push_screen(RowDetailScreen(ridx, self))
|
|
1321
1361
|
|
|
1322
|
-
def
|
|
1362
|
+
def do_show_frequency(self) -> None:
|
|
1323
1363
|
"""Show frequency distribution for the current column."""
|
|
1324
1364
|
cidx = self.cursor_col_idx
|
|
1325
1365
|
|
|
1326
1366
|
# Push the frequency modal screen
|
|
1327
1367
|
self.app.push_screen(FrequencyScreen(cidx, self))
|
|
1328
1368
|
|
|
1329
|
-
def
|
|
1369
|
+
def do_show_statistics(self, scope: str = "column") -> None:
|
|
1330
1370
|
"""Show statistics for the current column or entire dataframe.
|
|
1331
1371
|
|
|
1332
1372
|
Args:
|
|
@@ -1340,11 +1380,11 @@ class DataFrameTable(DataTable):
|
|
|
1340
1380
|
cidx = self.cursor_col_idx
|
|
1341
1381
|
self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
|
|
1342
1382
|
|
|
1343
|
-
def
|
|
1383
|
+
def do_freeze_row_column(self) -> None:
|
|
1344
1384
|
"""Open the freeze screen to set fixed rows and columns."""
|
|
1345
|
-
self.app.push_screen(FreezeScreen(), callback=self.
|
|
1385
|
+
self.app.push_screen(FreezeScreen(), callback=self.freeze_row_column)
|
|
1346
1386
|
|
|
1347
|
-
def
|
|
1387
|
+
def freeze_row_column(self, result: tuple[int, int] | None) -> None:
|
|
1348
1388
|
"""Handle result from PinScreen.
|
|
1349
1389
|
|
|
1350
1390
|
Args:
|
|
@@ -1356,7 +1396,7 @@ class DataFrameTable(DataTable):
|
|
|
1356
1396
|
fixed_rows, fixed_columns = result
|
|
1357
1397
|
|
|
1358
1398
|
# Add to history
|
|
1359
|
-
self.
|
|
1399
|
+
self.add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
|
|
1360
1400
|
|
|
1361
1401
|
# Apply the pin settings to the table
|
|
1362
1402
|
if fixed_rows >= 0:
|
|
@@ -1366,14 +1406,14 @@ class DataFrameTable(DataTable):
|
|
|
1366
1406
|
|
|
1367
1407
|
# self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
|
|
1368
1408
|
|
|
1369
|
-
def
|
|
1409
|
+
def do_hide_column(self) -> None:
|
|
1370
1410
|
"""Hide the currently selected column from the table display."""
|
|
1371
1411
|
col_key = self.cursor_col_key
|
|
1372
1412
|
col_name = col_key.value
|
|
1373
1413
|
col_idx = self.cursor_column
|
|
1374
1414
|
|
|
1375
1415
|
# Add to history
|
|
1376
|
-
self.
|
|
1416
|
+
self.add_history(f"Hid column [$success]{col_name}[/]")
|
|
1377
1417
|
|
|
1378
1418
|
# Remove the column from the table display (but keep in dataframe)
|
|
1379
1419
|
self.remove_column(col_key)
|
|
@@ -1387,7 +1427,7 @@ class DataFrameTable(DataTable):
|
|
|
1387
1427
|
|
|
1388
1428
|
# self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
|
|
1389
1429
|
|
|
1390
|
-
def
|
|
1430
|
+
def do_expand_column(self) -> None:
|
|
1391
1431
|
"""Expand the current column to show the widest cell in the loaded data."""
|
|
1392
1432
|
col_idx = self.cursor_col_idx
|
|
1393
1433
|
col_key = self.cursor_col_key
|
|
@@ -1424,7 +1464,7 @@ class DataFrameTable(DataTable):
|
|
|
1424
1464
|
self.notify("Error expanding column", title="Expand", severity="error")
|
|
1425
1465
|
self.log(f"Error expanding column `{col_name}`: {str(e)}")
|
|
1426
1466
|
|
|
1427
|
-
def
|
|
1467
|
+
def do_show_hidden_rows_columns(self) -> None:
|
|
1428
1468
|
"""Show all hidden rows/columns by recreating the table."""
|
|
1429
1469
|
# Get currently visible columns
|
|
1430
1470
|
visible_cols = set(col.key for col in self.ordered_columns)
|
|
@@ -1437,760 +1477,761 @@ class DataFrameTable(DataTable):
|
|
|
1437
1477
|
return
|
|
1438
1478
|
|
|
1439
1479
|
# Add to history
|
|
1440
|
-
self.
|
|
1480
|
+
self.add_history("Showed hidden rows/columns")
|
|
1441
1481
|
|
|
1442
1482
|
# Clear hidden rows/columns tracking
|
|
1443
1483
|
self.visible_rows = [True] * len(self.df)
|
|
1444
1484
|
self.hidden_columns.clear()
|
|
1445
1485
|
|
|
1446
1486
|
# Recreate table for display
|
|
1447
|
-
self.
|
|
1487
|
+
self.setup_table()
|
|
1448
1488
|
|
|
1449
1489
|
self.notify(
|
|
1450
1490
|
f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
|
|
1451
1491
|
title="Show",
|
|
1452
1492
|
)
|
|
1453
1493
|
|
|
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 = []
|
|
1494
|
+
# Sort
|
|
1495
|
+
def do_sort_by_column(self, descending: bool = False) -> None:
|
|
1496
|
+
"""Sort by the currently selected column.
|
|
1464
1497
|
|
|
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)
|
|
1498
|
+
Supports multi-column sorting:
|
|
1499
|
+
- First press on a column: sort by that column only
|
|
1500
|
+
- Subsequent presses on other columns: add to sort order
|
|
1471
1501
|
|
|
1472
|
-
|
|
1502
|
+
Args:
|
|
1503
|
+
descending: If True, sort in descending order. If False, ascending order.
|
|
1504
|
+
"""
|
|
1505
|
+
col_name = self.cursor_col_name
|
|
1506
|
+
col_idx = self.cursor_column
|
|
1473
1507
|
|
|
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)
|
|
1508
|
+
# Check if this column is already in the sort keys
|
|
1509
|
+
old_desc = self.sorted_columns.get(col_name)
|
|
1480
1510
|
|
|
1481
|
-
|
|
1511
|
+
# Add to history
|
|
1512
|
+
self.add_history(f"Sorted on column [$success]{col_name}[/]", dirty=True)
|
|
1513
|
+
if old_desc is None:
|
|
1514
|
+
# Add new column to sort
|
|
1515
|
+
self.sorted_columns[col_name] = descending
|
|
1516
|
+
elif old_desc == descending:
|
|
1517
|
+
# Same direction - remove from sort
|
|
1518
|
+
del self.sorted_columns[col_name]
|
|
1519
|
+
else:
|
|
1520
|
+
# Move to end of sort order
|
|
1521
|
+
del self.sorted_columns[col_name]
|
|
1522
|
+
self.sorted_columns[col_name] = descending
|
|
1482
1523
|
|
|
1483
|
-
#
|
|
1524
|
+
# Apply multi-column sort
|
|
1525
|
+
if sort_cols := list(self.sorted_columns.keys()):
|
|
1526
|
+
descending_flags = list(self.sorted_columns.values())
|
|
1527
|
+
df_sorted = self.df.with_row_index(RIDX).sort(sort_cols, descending=descending_flags, nulls_last=True)
|
|
1484
1528
|
else:
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
message = f"Removed column [$success]{col_name}[/]"
|
|
1529
|
+
# No sort columns - restore original order
|
|
1530
|
+
df_sorted = self.df.with_row_index(RIDX)
|
|
1488
1531
|
|
|
1489
|
-
#
|
|
1490
|
-
|
|
1532
|
+
# Updated selected_rows and visible_rows to match new order
|
|
1533
|
+
old_row_indices = df_sorted[RIDX].to_list()
|
|
1534
|
+
self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
|
|
1535
|
+
self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
|
|
1491
1536
|
|
|
1492
|
-
#
|
|
1493
|
-
|
|
1494
|
-
self.remove_column(ck)
|
|
1537
|
+
# Update the dataframe
|
|
1538
|
+
self.df = df_sorted.drop(RIDX)
|
|
1495
1539
|
|
|
1496
|
-
#
|
|
1497
|
-
|
|
1498
|
-
if col_idx > last_col_idx:
|
|
1499
|
-
self.move_cursor(column=last_col_idx)
|
|
1540
|
+
# Recreate table for display
|
|
1541
|
+
self.setup_table()
|
|
1500
1542
|
|
|
1501
|
-
#
|
|
1502
|
-
|
|
1503
|
-
if col_name in self.sorted_columns:
|
|
1504
|
-
del self.sorted_columns[col_name]
|
|
1543
|
+
# Restore cursor position on the sorted column
|
|
1544
|
+
self.move_cursor(column=col_idx, row=0)
|
|
1505
1545
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
if not self.matches[row_idx]:
|
|
1512
|
-
del self.matches[row_idx]
|
|
1546
|
+
# Edit
|
|
1547
|
+
def do_edit_cell(self, ridx: int = None, cidx: int = None) -> None:
|
|
1548
|
+
"""Open modal to edit the selected cell."""
|
|
1549
|
+
ridx = self.cursor_row_idx if ridx is None else ridx
|
|
1550
|
+
cidx = self.cursor_col_idx if cidx is None else cidx
|
|
1513
1551
|
|
|
1514
|
-
#
|
|
1515
|
-
self.
|
|
1552
|
+
# Push the edit modal screen
|
|
1553
|
+
self.app.push_screen(
|
|
1554
|
+
EditCellScreen(ridx, cidx, self.df),
|
|
1555
|
+
callback=self.edit_cell,
|
|
1556
|
+
)
|
|
1516
1557
|
|
|
1517
|
-
|
|
1558
|
+
def edit_cell(self, result) -> None:
|
|
1559
|
+
"""Handle result from EditCellScreen."""
|
|
1560
|
+
if result is None:
|
|
1561
|
+
return
|
|
1518
1562
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1563
|
+
ridx, cidx, new_value = result
|
|
1564
|
+
if new_value is None:
|
|
1565
|
+
self.app.push_screen(
|
|
1566
|
+
EditCellScreen(ridx, cidx, self.df),
|
|
1567
|
+
callback=self.edit_cell,
|
|
1568
|
+
)
|
|
1569
|
+
return
|
|
1523
1570
|
|
|
1524
|
-
|
|
1525
|
-
new_col_name = f"{col_name}_copy"
|
|
1571
|
+
col_name = self.df.columns[cidx]
|
|
1526
1572
|
|
|
1527
1573
|
# 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
|
-
)
|
|
1574
|
+
self.add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]", dirty=True)
|
|
1538
1575
|
|
|
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
|
|
1576
|
+
# Update the cell in the dataframe
|
|
1577
|
+
try:
|
|
1578
|
+
self.df = self.df.with_columns(
|
|
1579
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
1580
|
+
.then(pl.lit(new_value))
|
|
1581
|
+
.otherwise(pl.col(col_name))
|
|
1582
|
+
.alias(col_name)
|
|
1583
|
+
)
|
|
1550
1584
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1585
|
+
# Update the display
|
|
1586
|
+
cell_value = self.df.item(ridx, cidx)
|
|
1587
|
+
if cell_value is None:
|
|
1588
|
+
cell_value = NULL_DISPLAY
|
|
1589
|
+
dtype = self.df.dtypes[cidx]
|
|
1590
|
+
dc = DtypeConfig(dtype)
|
|
1591
|
+
formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
|
|
1553
1592
|
|
|
1554
|
-
|
|
1555
|
-
|
|
1593
|
+
# string as keys
|
|
1594
|
+
row_key = str(ridx)
|
|
1595
|
+
col_key = col_name
|
|
1596
|
+
self.update_cell(row_key, col_key, formatted_value, update_width=True)
|
|
1556
1597
|
|
|
1557
|
-
|
|
1598
|
+
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1599
|
+
except Exception as e:
|
|
1600
|
+
self.notify("Error updating cell", title="Edit", severity="error")
|
|
1601
|
+
self.log(f"Error updating cell: {str(e)}")
|
|
1558
1602
|
|
|
1559
|
-
def
|
|
1560
|
-
"""
|
|
1603
|
+
def do_edit_column(self) -> None:
|
|
1604
|
+
"""Open modal to edit the entire column with an expression."""
|
|
1605
|
+
cidx = self.cursor_col_idx
|
|
1561
1606
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1607
|
+
# Push the edit column modal screen
|
|
1608
|
+
self.app.push_screen(
|
|
1609
|
+
EditColumnScreen(cidx, self.df),
|
|
1610
|
+
callback=self.edit_column,
|
|
1611
|
+
)
|
|
1566
1612
|
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1613
|
+
def edit_column(self, result) -> None:
|
|
1614
|
+
"""Edit a column."""
|
|
1615
|
+
if result is None:
|
|
1616
|
+
return
|
|
1617
|
+
term, cidx = result
|
|
1570
1618
|
|
|
1571
|
-
|
|
1572
|
-
if selected:
|
|
1573
|
-
predicates[ridx] = False
|
|
1619
|
+
col_name = self.df.columns[cidx]
|
|
1574
1620
|
|
|
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
|
|
1621
|
+
# Null case
|
|
1622
|
+
if term is None or term == NULL:
|
|
1623
|
+
expr = pl.lit(None)
|
|
1581
1624
|
|
|
1582
|
-
#
|
|
1583
|
-
elif
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1625
|
+
# Check if term is a valid expression
|
|
1626
|
+
elif tentative_expr(term):
|
|
1627
|
+
try:
|
|
1628
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
1629
|
+
except Exception as e:
|
|
1630
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Edit", severity="error")
|
|
1631
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1632
|
+
return
|
|
1589
1633
|
|
|
1590
|
-
#
|
|
1634
|
+
# Otherwise, treat term as a literal value
|
|
1591
1635
|
else:
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1636
|
+
dtype = self.df.dtypes[cidx]
|
|
1637
|
+
try:
|
|
1638
|
+
value = DtypeConfig(dtype).convert(term)
|
|
1639
|
+
expr = pl.lit(value)
|
|
1640
|
+
except Exception:
|
|
1641
|
+
self.notify(
|
|
1642
|
+
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1643
|
+
title="Edit",
|
|
1644
|
+
severity="error",
|
|
1645
|
+
)
|
|
1646
|
+
expr = pl.lit(str(term))
|
|
1647
|
+
|
|
1648
|
+
# Add to history
|
|
1649
|
+
self.add_history(f"Edited column [$accent]{col_name}[/] with expression", dirty=True)
|
|
1650
|
+
|
|
1601
1651
|
try:
|
|
1602
|
-
|
|
1652
|
+
# Apply the expression to the column
|
|
1653
|
+
self.df = self.df.with_columns(expr.alias(col_name))
|
|
1603
1654
|
except Exception as e:
|
|
1604
|
-
self.notify(
|
|
1605
|
-
|
|
1655
|
+
self.notify(
|
|
1656
|
+
f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
|
|
1657
|
+
title="Edit",
|
|
1658
|
+
severity="error",
|
|
1659
|
+
)
|
|
1660
|
+
self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
|
|
1606
1661
|
return
|
|
1607
1662
|
|
|
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]
|
|
1663
|
+
# Recreate table for display
|
|
1664
|
+
self.setup_table()
|
|
1614
1665
|
|
|
1615
|
-
#
|
|
1616
|
-
self.matches = defaultdict(set)
|
|
1666
|
+
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
|
|
1617
1667
|
|
|
1618
|
-
|
|
1619
|
-
|
|
1668
|
+
def do_rename_column(self) -> None:
|
|
1669
|
+
"""Open modal to rename the selected column."""
|
|
1670
|
+
col_name = self.cursor_col_name
|
|
1671
|
+
col_idx = self.cursor_column
|
|
1620
1672
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1673
|
+
# Push the rename column modal screen
|
|
1674
|
+
self.app.push_screen(
|
|
1675
|
+
RenameColumnScreen(col_idx, col_name, self.df.columns),
|
|
1676
|
+
callback=self.rename_column,
|
|
1677
|
+
)
|
|
1624
1678
|
|
|
1625
|
-
def
|
|
1626
|
-
"""
|
|
1627
|
-
|
|
1679
|
+
def rename_column(self, result) -> None:
|
|
1680
|
+
"""Handle result from RenameColumnScreen."""
|
|
1681
|
+
if result is None:
|
|
1682
|
+
return
|
|
1628
1683
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1684
|
+
col_idx, col_name, new_name = result
|
|
1685
|
+
if new_name is None:
|
|
1686
|
+
self.app.push_screen(
|
|
1687
|
+
RenameColumnScreen(col_idx, col_name, self.df.columns),
|
|
1688
|
+
callback=self.rename_column,
|
|
1689
|
+
)
|
|
1690
|
+
return
|
|
1631
1691
|
|
|
1632
1692
|
# 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)
|
|
1693
|
+
self.add_history(f"Renamed column [$accent]{col_name}[/] to [$success]{new_name}[/]", dirty=True)
|
|
1638
1694
|
|
|
1639
|
-
#
|
|
1640
|
-
self.df =
|
|
1695
|
+
# Rename the column in the dataframe
|
|
1696
|
+
self.df = self.df.rename({col_name: new_name})
|
|
1641
1697
|
|
|
1642
|
-
# Update
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
self.selected_rows = new_selected_rows
|
|
1646
|
-
self.visible_rows = new_visible_rows
|
|
1698
|
+
# Update sorted_columns if this column was sorted
|
|
1699
|
+
if col_name in self.sorted_columns:
|
|
1700
|
+
self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
|
|
1647
1701
|
|
|
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
|
|
1702
|
+
# Update hidden_columns if this column was hidden
|
|
1703
|
+
if col_name in self.hidden_columns:
|
|
1704
|
+
self.hidden_columns.remove(col_name)
|
|
1705
|
+
self.hidden_columns.add(new_name)
|
|
1656
1706
|
|
|
1657
1707
|
# Recreate table for display
|
|
1658
|
-
self.
|
|
1708
|
+
self.setup_table()
|
|
1659
1709
|
|
|
1660
|
-
# Move cursor to the
|
|
1661
|
-
self.move_cursor(
|
|
1662
|
-
|
|
1663
|
-
# self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
|
|
1710
|
+
# Move cursor to the renamed column
|
|
1711
|
+
self.move_cursor(column=col_idx)
|
|
1664
1712
|
|
|
1665
|
-
|
|
1666
|
-
"""Move the current column left or right.
|
|
1713
|
+
# self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
|
|
1667
1714
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
col_key = self.cursor_col_key
|
|
1673
|
-
col_name = col_key.value
|
|
1715
|
+
def do_clear_cell(self) -> None:
|
|
1716
|
+
"""Clear the current cell by setting its value to None."""
|
|
1717
|
+
row_key, col_key = self.cursor_key
|
|
1718
|
+
ridx = self.cursor_row_idx
|
|
1674
1719
|
cidx = self.cursor_col_idx
|
|
1720
|
+
col_name = self.cursor_col_name
|
|
1675
1721
|
|
|
1676
|
-
#
|
|
1677
|
-
|
|
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
|
|
1722
|
+
# Add to history
|
|
1723
|
+
self.add_history(f"Cleared cell [$success]({ridx + 1}, {col_name})[/]", dirty=True)
|
|
1687
1724
|
|
|
1688
|
-
#
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1725
|
+
# Update the cell to None in the dataframe
|
|
1726
|
+
try:
|
|
1727
|
+
self.df = self.df.with_columns(
|
|
1728
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
1729
|
+
.then(pl.lit(None))
|
|
1730
|
+
.otherwise(pl.col(col_name))
|
|
1731
|
+
.alias(col_name)
|
|
1732
|
+
)
|
|
1692
1733
|
|
|
1693
|
-
|
|
1694
|
-
|
|
1734
|
+
# Update the display
|
|
1735
|
+
dtype = self.df.dtypes[cidx]
|
|
1736
|
+
dc = DtypeConfig(dtype)
|
|
1737
|
+
formatted_value = Text(NULL_DISPLAY, style=dc.style, justify=dc.justify)
|
|
1695
1738
|
|
|
1696
|
-
|
|
1697
|
-
self.check_idle()
|
|
1739
|
+
self.update_cell(row_key, col_key, formatted_value)
|
|
1698
1740
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
self.
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
self._column_locations.get(col_key),
|
|
1705
|
-
)
|
|
1741
|
+
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
1742
|
+
except Exception as e:
|
|
1743
|
+
self.notify("Error clearing cell", title="Clear", severity="error")
|
|
1744
|
+
self.log(f"Error clearing cell: {str(e)}")
|
|
1745
|
+
raise e
|
|
1706
1746
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1747
|
+
def do_add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
|
|
1748
|
+
"""Add acolumn after the current column."""
|
|
1749
|
+
cidx = self.cursor_col_idx
|
|
1709
1750
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1751
|
+
if not col_name:
|
|
1752
|
+
# Generate a unique column name
|
|
1753
|
+
base_name = "new_col"
|
|
1754
|
+
new_name = base_name
|
|
1755
|
+
counter = 1
|
|
1756
|
+
while new_name in self.df.columns:
|
|
1757
|
+
new_name = f"{base_name}_{counter}"
|
|
1758
|
+
counter += 1
|
|
1759
|
+
else:
|
|
1760
|
+
new_name = col_name
|
|
1712
1761
|
|
|
1713
|
-
#
|
|
1714
|
-
|
|
1715
|
-
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
1716
|
-
self.df = self.df.select(cols)
|
|
1762
|
+
# Add to history
|
|
1763
|
+
self.add_history(f"Added column [$success]{new_name}[/] after column {cidx + 1}", dirty=True)
|
|
1717
1764
|
|
|
1718
|
-
|
|
1765
|
+
try:
|
|
1766
|
+
# Create an empty column (all None values)
|
|
1767
|
+
if isinstance(col_value, pl.Expr):
|
|
1768
|
+
new_col = col_value.alias(new_name)
|
|
1769
|
+
else:
|
|
1770
|
+
new_col = pl.lit(col_value).alias(new_name)
|
|
1719
1771
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1772
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1773
|
+
cols = self.df.columns
|
|
1774
|
+
cols_before = cols[: cidx + 1]
|
|
1775
|
+
cols_after = cols[cidx + 1 :]
|
|
1722
1776
|
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
row_idx, col_idx = self.cursor_coordinate
|
|
1777
|
+
# Build the new dataframe with columns reordered
|
|
1778
|
+
select_cols = cols_before + [new_name] + cols_after
|
|
1779
|
+
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
1727
1780
|
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1781
|
+
# Recreate table for display
|
|
1782
|
+
self.setup_table()
|
|
1783
|
+
|
|
1784
|
+
# Move cursor to the new column
|
|
1785
|
+
self.move_cursor(column=cidx + 1)
|
|
1786
|
+
|
|
1787
|
+
# self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1788
|
+
except Exception as e:
|
|
1789
|
+
self.notify("Error adding column", title="Add Column", severity="error")
|
|
1790
|
+
self.log(f"Error adding column: {str(e)}")
|
|
1791
|
+
raise e
|
|
1792
|
+
|
|
1793
|
+
def do_add_column_expr(self) -> None:
|
|
1794
|
+
"""Open screen to add a new column with optional expression."""
|
|
1795
|
+
cidx = self.cursor_col_idx
|
|
1796
|
+
self.app.push_screen(
|
|
1797
|
+
AddColumnScreen(cidx, self.df),
|
|
1798
|
+
self.add_column_expr,
|
|
1799
|
+
)
|
|
1800
|
+
|
|
1801
|
+
def add_column_expr(self, result: tuple[int, str, str, pl.Expr] | None) -> None:
|
|
1802
|
+
"""Add a new column with an expression."""
|
|
1803
|
+
if result is None:
|
|
1741
1804
|
return
|
|
1742
1805
|
|
|
1743
|
-
|
|
1744
|
-
swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
|
|
1806
|
+
cidx, new_col_name, expr = result
|
|
1745
1807
|
|
|
1746
1808
|
# Add to history
|
|
1747
|
-
self.
|
|
1748
|
-
f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])"
|
|
1749
|
-
)
|
|
1809
|
+
self.add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.", dirty=True)
|
|
1750
1810
|
|
|
1751
|
-
|
|
1752
|
-
|
|
1811
|
+
try:
|
|
1812
|
+
# Create the column
|
|
1813
|
+
new_col = expr.alias(new_col_name)
|
|
1753
1814
|
|
|
1754
|
-
|
|
1755
|
-
self.
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
self._row_locations.get(swap_key),
|
|
1759
|
-
self._row_locations.get(row_key),
|
|
1760
|
-
)
|
|
1815
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1816
|
+
cols = self.df.columns
|
|
1817
|
+
cols_before = cols[: cidx + 1]
|
|
1818
|
+
cols_after = cols[cidx + 1 :]
|
|
1761
1819
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1820
|
+
# Build the new dataframe with columns reordered
|
|
1821
|
+
select_cols = cols_before + [new_col_name] + cols_after
|
|
1822
|
+
self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
|
|
1764
1823
|
|
|
1765
|
-
|
|
1766
|
-
|
|
1824
|
+
# Recreate table for display
|
|
1825
|
+
self.setup_table()
|
|
1767
1826
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
swap_ridx = int(swap_key.value) # 0-based
|
|
1771
|
-
first, second = sorted([ridx, swap_ridx])
|
|
1827
|
+
# Move cursor to the new column
|
|
1828
|
+
self.move_cursor(column=cidx + 1)
|
|
1772
1829
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1830
|
+
# self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
|
|
1831
|
+
except Exception as e:
|
|
1832
|
+
self.notify("Error adding column", title="Add Column", severity="error")
|
|
1833
|
+
self.log(f"Error adding column `{new_col_name}`: {str(e)}")
|
|
1834
|
+
|
|
1835
|
+
def do_add_link_column(self) -> None:
|
|
1836
|
+
self.app.push_screen(
|
|
1837
|
+
AddLinkScreen(self.cursor_col_idx, self.df),
|
|
1838
|
+
callback=self.add_link_column,
|
|
1781
1839
|
)
|
|
1782
1840
|
|
|
1783
|
-
|
|
1841
|
+
def add_link_column(self, result: tuple[str, str] | None) -> None:
|
|
1842
|
+
"""Handle result from AddLinkScreen.
|
|
1784
1843
|
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1844
|
+
Creates a new link column in the dataframe with clickable links based on a
|
|
1845
|
+
user-provided template. Supports multiple placeholder types:
|
|
1846
|
+
- `$_` - Current column (based on cursor position)
|
|
1847
|
+
- `$1`, `$2`, etc. - Column by 1-based position index
|
|
1848
|
+
- `$name` - Column by name (e.g., `$id`, `$product_name`)
|
|
1788
1849
|
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
- Subsequent presses on other columns: add to sort order
|
|
1850
|
+
The template is evaluated for each row using Polars expressions with vectorized
|
|
1851
|
+
string concatenation. The new column is inserted after the current column.
|
|
1792
1852
|
|
|
1793
1853
|
Args:
|
|
1794
|
-
|
|
1854
|
+
result: Tuple of (cidx, new_col_name, link_template) or None if cancelled.
|
|
1795
1855
|
"""
|
|
1796
|
-
|
|
1797
|
-
|
|
1856
|
+
if result is None:
|
|
1857
|
+
return
|
|
1858
|
+
cidx, new_col_name, link_template = result
|
|
1798
1859
|
|
|
1799
|
-
|
|
1800
|
-
|
|
1860
|
+
self.add_history(
|
|
1861
|
+
f"Added link column [$accent]{new_col_name}[/] with template [$success]{link_template}[/].", dirty=True
|
|
1862
|
+
)
|
|
1801
1863
|
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
|
1864
|
+
try:
|
|
1865
|
+
# Hack to support PubChem link
|
|
1866
|
+
link_template = link_template.replace("PC", "pubchem.ncbi.nlm.nih.gov")
|
|
1814
1867
|
|
|
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)
|
|
1868
|
+
# Ensure link starts with http:// or https://
|
|
1869
|
+
if not link_template.startswith(("https://", "http://")):
|
|
1870
|
+
link_template = "https://" + link_template
|
|
1822
1871
|
|
|
1823
|
-
|
|
1824
|
-
|
|
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]
|
|
1872
|
+
# Parse template placeholders into Polars expressions
|
|
1873
|
+
parts = parse_placeholders(link_template, self.df.columns, cidx)
|
|
1827
1874
|
|
|
1828
|
-
|
|
1829
|
-
|
|
1875
|
+
# Build the concatenation expression
|
|
1876
|
+
exprs = [part if isinstance(part, pl.Expr) else pl.lit(part) for part in parts]
|
|
1877
|
+
new_col = pl.concat_str(exprs).alias(new_col_name)
|
|
1830
1878
|
|
|
1831
|
-
|
|
1832
|
-
|
|
1879
|
+
# Get columns up to current, the new column, then remaining columns
|
|
1880
|
+
cols = self.df.columns
|
|
1881
|
+
cols_before = cols[: cidx + 1]
|
|
1882
|
+
cols_after = cols[cidx + 1 :]
|
|
1833
1883
|
|
|
1834
|
-
|
|
1835
|
-
|
|
1884
|
+
# Build the new dataframe with columns reordered
|
|
1885
|
+
select_cols = cols_before + [new_col_name] + cols_after
|
|
1886
|
+
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
1836
1887
|
|
|
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]
|
|
1888
|
+
# Recreate table for display
|
|
1889
|
+
self.setup_table()
|
|
1843
1890
|
|
|
1844
|
-
|
|
1845
|
-
|
|
1891
|
+
# Move cursor to the new column
|
|
1892
|
+
self.move_cursor(column=cidx + 1)
|
|
1846
1893
|
|
|
1847
|
-
|
|
1848
|
-
self.app.push_screen(
|
|
1849
|
-
EditCellScreen(ridx, cidx, self.df),
|
|
1850
|
-
callback=self._do_edit_cell,
|
|
1851
|
-
)
|
|
1894
|
+
self.notify(f"Added link column [$success]{new_col_name}[/]. Use Ctrl/Cmd click to open.", title="Add Link")
|
|
1852
1895
|
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
return
|
|
1896
|
+
except Exception as e:
|
|
1897
|
+
self.notify(f"Error adding link column [$error]{new_col_name}[/]", title="Add Link", severity="error")
|
|
1898
|
+
self.log(f"Error adding link column: {str(e)}")
|
|
1857
1899
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
return
|
|
1900
|
+
def do_delete_column(self, more: str = None) -> None:
|
|
1901
|
+
"""Remove the currently selected column from the table."""
|
|
1902
|
+
# Get the column to remove
|
|
1903
|
+
col_idx = self.cursor_column
|
|
1904
|
+
col_name = self.cursor_col_name
|
|
1905
|
+
col_key = self.cursor_col_key
|
|
1865
1906
|
|
|
1866
|
-
|
|
1907
|
+
col_names_to_remove = []
|
|
1908
|
+
col_keys_to_remove = []
|
|
1867
1909
|
|
|
1868
|
-
#
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
.
|
|
1873
|
-
.
|
|
1874
|
-
.alias(col_name)
|
|
1875
|
-
)
|
|
1910
|
+
# Remove all columns before the current column
|
|
1911
|
+
if more == "before":
|
|
1912
|
+
for i in range(col_idx + 1):
|
|
1913
|
+
col_key = self.get_column_key(i)
|
|
1914
|
+
col_names_to_remove.append(col_key.value)
|
|
1915
|
+
col_keys_to_remove.append(col_key)
|
|
1876
1916
|
|
|
1877
|
-
|
|
1878
|
-
cell_value = self.df.item(ridx, cidx)
|
|
1879
|
-
if cell_value is None:
|
|
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)
|
|
1917
|
+
message = f"Removed column [$success]{col_name}[/] and all columns before"
|
|
1884
1918
|
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1919
|
+
# Remove all columns after the current column
|
|
1920
|
+
elif more == "after":
|
|
1921
|
+
for i in range(col_idx, len(self.columns)):
|
|
1922
|
+
col_key = self.get_column_key(i)
|
|
1923
|
+
col_names_to_remove.append(col_key.value)
|
|
1924
|
+
col_keys_to_remove.append(col_key)
|
|
1889
1925
|
|
|
1890
|
-
|
|
1891
|
-
except Exception as e:
|
|
1892
|
-
self.notify("Error updating cell", title="Edit", severity="error")
|
|
1893
|
-
self.log(f"Error updating cell: {str(e)}")
|
|
1926
|
+
message = f"Removed column [$success]{col_name}[/] and all columns after"
|
|
1894
1927
|
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1928
|
+
# Remove only the current column
|
|
1929
|
+
else:
|
|
1930
|
+
col_names_to_remove.append(col_name)
|
|
1931
|
+
col_keys_to_remove.append(col_key)
|
|
1932
|
+
message = f"Removed column [$success]{col_name}[/]"
|
|
1898
1933
|
|
|
1899
|
-
#
|
|
1900
|
-
self.
|
|
1901
|
-
EditColumnScreen(cidx, self.df),
|
|
1902
|
-
callback=self._do_edit_column,
|
|
1903
|
-
)
|
|
1934
|
+
# Add to history
|
|
1935
|
+
self.add_history(message, dirty=True)
|
|
1904
1936
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
return
|
|
1909
|
-
term, cidx = result
|
|
1937
|
+
# Remove the columns from the table display using the column names as keys
|
|
1938
|
+
for ck in col_keys_to_remove:
|
|
1939
|
+
self.remove_column(ck)
|
|
1910
1940
|
|
|
1911
|
-
|
|
1941
|
+
# Move cursor left if we deleted the last column(s)
|
|
1942
|
+
last_col_idx = len(self.columns) - 1
|
|
1943
|
+
if col_idx > last_col_idx:
|
|
1944
|
+
self.move_cursor(column=last_col_idx)
|
|
1912
1945
|
|
|
1913
|
-
#
|
|
1914
|
-
|
|
1915
|
-
|
|
1946
|
+
# Remove from sorted columns if present
|
|
1947
|
+
for col_name in col_names_to_remove:
|
|
1948
|
+
if col_name in self.sorted_columns:
|
|
1949
|
+
del self.sorted_columns[col_name]
|
|
1916
1950
|
|
|
1917
|
-
#
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
self.
|
|
1924
|
-
return
|
|
1951
|
+
# Remove from matches
|
|
1952
|
+
col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
|
|
1953
|
+
for row_idx in list(self.matches.keys()):
|
|
1954
|
+
self.matches[row_idx].difference_update(col_indices_to_remove)
|
|
1955
|
+
# Remove empty entries
|
|
1956
|
+
if not self.matches[row_idx]:
|
|
1957
|
+
del self.matches[row_idx]
|
|
1925
1958
|
|
|
1926
|
-
#
|
|
1927
|
-
|
|
1928
|
-
dtype = self.df.dtypes[cidx]
|
|
1929
|
-
try:
|
|
1930
|
-
value = DtypeConfig(dtype).convert(term)
|
|
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))
|
|
1959
|
+
# Remove from dataframe
|
|
1960
|
+
self.df = self.df.drop(col_names_to_remove)
|
|
1939
1961
|
|
|
1940
|
-
|
|
1941
|
-
self._add_history(f"Edited column [$accent]{col_name}[/] with expression")
|
|
1962
|
+
self.notify(message, title="Delete")
|
|
1942
1963
|
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
|
1964
|
+
def do_duplicate_column(self) -> None:
|
|
1965
|
+
"""Duplicate the currently selected column, inserting it right after the current column."""
|
|
1966
|
+
cidx = self.cursor_col_idx
|
|
1967
|
+
col_name = self.cursor_col_name
|
|
1954
1968
|
|
|
1955
|
-
|
|
1956
|
-
|
|
1969
|
+
col_idx = self.cursor_column
|
|
1970
|
+
new_col_name = f"{col_name}_copy"
|
|
1957
1971
|
|
|
1958
|
-
#
|
|
1972
|
+
# Add to history
|
|
1973
|
+
self.add_history(f"Duplicated column [$success]{col_name}[/]", dirty=True)
|
|
1959
1974
|
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
col_idx = self.cursor_column
|
|
1975
|
+
# Create new column and reorder columns to insert after current column
|
|
1976
|
+
cols_before = self.df.columns[: cidx + 1]
|
|
1977
|
+
cols_after = self.df.columns[cidx + 1 :]
|
|
1964
1978
|
|
|
1965
|
-
#
|
|
1966
|
-
self.
|
|
1967
|
-
|
|
1968
|
-
callback=self._do_rename_column,
|
|
1979
|
+
# Add the new column and reorder columns for insertion after current column
|
|
1980
|
+
self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
|
|
1981
|
+
list(cols_before) + [new_col_name] + list(cols_after)
|
|
1969
1982
|
)
|
|
1970
1983
|
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1984
|
+
# Update matches to account for new column
|
|
1985
|
+
new_matches = defaultdict(set)
|
|
1986
|
+
for row_idx, cols in self.matches.items():
|
|
1987
|
+
new_cols = set()
|
|
1988
|
+
for col_idx_in_set in cols:
|
|
1989
|
+
if col_idx_in_set <= cidx:
|
|
1990
|
+
new_cols.add(col_idx_in_set)
|
|
1991
|
+
else:
|
|
1992
|
+
new_cols.add(col_idx_in_set + 1)
|
|
1993
|
+
new_matches[row_idx] = new_cols
|
|
1994
|
+
self.matches = new_matches
|
|
1975
1995
|
|
|
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
|
|
1996
|
+
# Recreate table for display
|
|
1997
|
+
self.setup_table()
|
|
1983
1998
|
|
|
1984
|
-
#
|
|
1985
|
-
self.
|
|
1999
|
+
# Move cursor to the new duplicated column
|
|
2000
|
+
self.move_cursor(column=col_idx + 1)
|
|
1986
2001
|
|
|
1987
|
-
#
|
|
1988
|
-
self.df = self.df.rename({col_name: new_name})
|
|
2002
|
+
# self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
|
|
1989
2003
|
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
|
|
2004
|
+
def do_delete_row(self, more: str = None) -> None:
|
|
2005
|
+
"""Delete rows from the table and dataframe.
|
|
1993
2006
|
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2007
|
+
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
2008
|
+
"""
|
|
2009
|
+
old_count = len(self.df)
|
|
2010
|
+
predicates = [True] * len(self.df)
|
|
1998
2011
|
|
|
1999
|
-
#
|
|
2000
|
-
self.
|
|
2012
|
+
# Delete all selected rows
|
|
2013
|
+
if selected_count := self.selected_rows.count(True):
|
|
2014
|
+
history_desc = f"Deleted {selected_count} selected row(s)"
|
|
2001
2015
|
|
|
2002
|
-
|
|
2003
|
-
|
|
2016
|
+
for ridx, selected in enumerate(self.selected_rows):
|
|
2017
|
+
if selected:
|
|
2018
|
+
predicates[ridx] = False
|
|
2004
2019
|
|
|
2005
|
-
#
|
|
2020
|
+
# Delete current row and those above
|
|
2021
|
+
elif more == "above":
|
|
2022
|
+
ridx = self.cursor_row_idx
|
|
2023
|
+
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
|
|
2024
|
+
for i in range(ridx + 1):
|
|
2025
|
+
predicates[i] = False
|
|
2006
2026
|
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2027
|
+
# Delete current row and those below
|
|
2028
|
+
elif more == "below":
|
|
2029
|
+
ridx = self.cursor_row_idx
|
|
2030
|
+
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
|
|
2031
|
+
for i in range(ridx, len(self.df)):
|
|
2032
|
+
if self.visible_rows[i]:
|
|
2033
|
+
predicates[i] = False
|
|
2034
|
+
|
|
2035
|
+
# Delete the row at the cursor
|
|
2036
|
+
else:
|
|
2037
|
+
ridx = self.cursor_row_idx
|
|
2038
|
+
history_desc = f"Deleted row [$success]{ridx + 1}[/]"
|
|
2039
|
+
if self.visible_rows[ridx]:
|
|
2040
|
+
predicates[ridx] = False
|
|
2013
2041
|
|
|
2014
2042
|
# Add to history
|
|
2015
|
-
self.
|
|
2043
|
+
self.add_history(history_desc, dirty=True)
|
|
2016
2044
|
|
|
2017
|
-
#
|
|
2045
|
+
# Apply the filter to remove rows
|
|
2018
2046
|
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)
|
|
2047
|
+
df = self.df.with_row_index(RIDX).filter(predicates)
|
|
2048
|
+
except Exception as e:
|
|
2049
|
+
self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error")
|
|
2050
|
+
self.histories.pop() # Remove last history entry
|
|
2051
|
+
return
|
|
2030
2052
|
|
|
2031
|
-
|
|
2053
|
+
self.df = df.drop(RIDX)
|
|
2032
2054
|
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
raise e
|
|
2055
|
+
# Update selected and visible rows tracking
|
|
2056
|
+
old_row_indices = set(df[RIDX].to_list())
|
|
2057
|
+
self.selected_rows = [selected for i, selected in enumerate(self.selected_rows) if i in old_row_indices]
|
|
2058
|
+
self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
|
|
2038
2059
|
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
cidx = self.cursor_col_idx
|
|
2060
|
+
# Clear all matches since row indices have changed
|
|
2061
|
+
self.matches = defaultdict(set)
|
|
2042
2062
|
|
|
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
|
|
2063
|
+
# Recreate table for display
|
|
2064
|
+
self.setup_table()
|
|
2053
2065
|
|
|
2054
|
-
|
|
2055
|
-
|
|
2066
|
+
deleted_count = old_count - len(self.df)
|
|
2067
|
+
if deleted_count > 0:
|
|
2068
|
+
self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
|
|
2056
2069
|
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
new_col = col_value.alias(new_name)
|
|
2061
|
-
else:
|
|
2062
|
-
new_col = pl.lit(col_value).alias(new_name)
|
|
2070
|
+
def do_duplicate_row(self) -> None:
|
|
2071
|
+
"""Duplicate the currently selected row, inserting it right after the current row."""
|
|
2072
|
+
ridx = self.cursor_row_idx
|
|
2063
2073
|
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
cols_before = cols[: cidx + 1]
|
|
2067
|
-
cols_after = cols[cidx + 1 :]
|
|
2074
|
+
# Get the row to duplicate
|
|
2075
|
+
row_to_duplicate = self.df.slice(ridx, 1)
|
|
2068
2076
|
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
2077
|
+
# Add to history
|
|
2078
|
+
self.add_history(f"Duplicated row [$success]{ridx + 1}[/]", dirty=True)
|
|
2072
2079
|
|
|
2073
|
-
|
|
2074
|
-
|
|
2080
|
+
# Concatenate: rows before + duplicated row + rows after
|
|
2081
|
+
df_before = self.df.slice(0, ridx + 1)
|
|
2082
|
+
df_after = self.df.slice(ridx + 1)
|
|
2075
2083
|
|
|
2076
|
-
|
|
2077
|
-
|
|
2084
|
+
# Combine the parts
|
|
2085
|
+
self.df = pl.concat([df_before, row_to_duplicate, df_after])
|
|
2078
2086
|
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2087
|
+
# Update selected and visible rows tracking to account for new row
|
|
2088
|
+
new_selected_rows = self.selected_rows[: ridx + 1] + [self.selected_rows[ridx]] + self.selected_rows[ridx + 1 :]
|
|
2089
|
+
new_visible_rows = self.visible_rows[: ridx + 1] + [self.visible_rows[ridx]] + self.visible_rows[ridx + 1 :]
|
|
2090
|
+
self.selected_rows = new_selected_rows
|
|
2091
|
+
self.visible_rows = new_visible_rows
|
|
2084
2092
|
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2093
|
+
# Update matches to account for new row
|
|
2094
|
+
new_matches = defaultdict(set)
|
|
2095
|
+
for row_idx, cols in self.matches.items():
|
|
2096
|
+
if row_idx <= ridx:
|
|
2097
|
+
new_matches[row_idx] = cols
|
|
2098
|
+
else:
|
|
2099
|
+
new_matches[row_idx + 1] = cols
|
|
2100
|
+
self.matches = new_matches
|
|
2092
2101
|
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
if result is None:
|
|
2096
|
-
return
|
|
2102
|
+
# Recreate table for display
|
|
2103
|
+
self.setup_table()
|
|
2097
2104
|
|
|
2098
|
-
|
|
2105
|
+
# Move cursor to the new duplicated row
|
|
2106
|
+
self.move_cursor(row=ridx + 1)
|
|
2099
2107
|
|
|
2100
|
-
#
|
|
2101
|
-
self._add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.")
|
|
2108
|
+
# self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
|
|
2102
2109
|
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
new_col = expr.alias(new_col_name)
|
|
2110
|
+
def do_move_column(self, direction: str) -> None:
|
|
2111
|
+
"""Move the current column left or right.
|
|
2106
2112
|
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2113
|
+
Args:
|
|
2114
|
+
direction: "left" to move left, "right" to move right.
|
|
2115
|
+
"""
|
|
2116
|
+
row_idx, col_idx = self.cursor_coordinate
|
|
2117
|
+
col_key = self.cursor_col_key
|
|
2118
|
+
col_name = col_key.value
|
|
2119
|
+
cidx = self.cursor_col_idx
|
|
2111
2120
|
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2121
|
+
# Validate move is possible
|
|
2122
|
+
if direction == "left":
|
|
2123
|
+
if col_idx <= 0:
|
|
2124
|
+
self.notify("Cannot move column left", title="Move", severity="warning")
|
|
2125
|
+
return
|
|
2126
|
+
swap_idx = col_idx - 1
|
|
2127
|
+
elif direction == "right":
|
|
2128
|
+
if col_idx >= len(self.columns) - 1:
|
|
2129
|
+
self.notify("Cannot move column right", title="Move", severity="warning")
|
|
2130
|
+
return
|
|
2131
|
+
swap_idx = col_idx + 1
|
|
2115
2132
|
|
|
2116
|
-
|
|
2117
|
-
|
|
2133
|
+
# Get column to swap
|
|
2134
|
+
_, swap_key = self.coordinate_to_cell_key(Coordinate(row_idx, swap_idx))
|
|
2135
|
+
swap_name = swap_key.value
|
|
2136
|
+
swap_cidx = self.df.columns.index(swap_name)
|
|
2118
2137
|
|
|
2119
|
-
|
|
2120
|
-
|
|
2138
|
+
# Add to history
|
|
2139
|
+
self.add_history(
|
|
2140
|
+
f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])", dirty=True
|
|
2141
|
+
)
|
|
2121
2142
|
|
|
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)}")
|
|
2143
|
+
# Swap columns in the table's internal column locations
|
|
2144
|
+
self.check_idle()
|
|
2126
2145
|
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2146
|
+
(
|
|
2147
|
+
self._column_locations[col_key],
|
|
2148
|
+
self._column_locations[swap_key],
|
|
2149
|
+
) = (
|
|
2150
|
+
self._column_locations.get(swap_key),
|
|
2151
|
+
self._column_locations.get(col_key),
|
|
2131
2152
|
)
|
|
2132
2153
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2154
|
+
self._update_count += 1
|
|
2155
|
+
self.refresh()
|
|
2135
2156
|
|
|
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`)
|
|
2157
|
+
# Restore cursor position on the moved column
|
|
2158
|
+
self.move_cursor(row=row_idx, column=swap_idx)
|
|
2141
2159
|
|
|
2142
|
-
|
|
2143
|
-
|
|
2160
|
+
# Update the dataframe column order
|
|
2161
|
+
cols = list(self.df.columns)
|
|
2162
|
+
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
2163
|
+
self.df = self.df.select(cols)
|
|
2144
2164
|
|
|
2145
|
-
|
|
2146
|
-
result: Tuple of (cidx, new_col_name, link_template) or None if cancelled.
|
|
2165
|
+
# self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
|
|
2147
2166
|
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
"""
|
|
2151
|
-
if result is None:
|
|
2152
|
-
return
|
|
2153
|
-
cidx, new_col_name, link_template = result
|
|
2167
|
+
def do_move_row(self, direction: str) -> None:
|
|
2168
|
+
"""Move the current row up or down.
|
|
2154
2169
|
|
|
2155
|
-
|
|
2170
|
+
Args:
|
|
2171
|
+
direction: "up" to move up, "down" to move down.
|
|
2172
|
+
"""
|
|
2173
|
+
row_idx, col_idx = self.cursor_coordinate
|
|
2156
2174
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2175
|
+
# Validate move is possible
|
|
2176
|
+
if direction == "up":
|
|
2177
|
+
if row_idx <= 0:
|
|
2178
|
+
self.notify("Cannot move row up", title="Move", severity="warning")
|
|
2179
|
+
return
|
|
2180
|
+
swap_idx = row_idx - 1
|
|
2181
|
+
elif direction == "down":
|
|
2182
|
+
if row_idx >= len(self.rows) - 1:
|
|
2183
|
+
self.notify("Cannot move row down", title="Move", severity="warning")
|
|
2184
|
+
return
|
|
2185
|
+
swap_idx = row_idx + 1
|
|
2186
|
+
else:
|
|
2187
|
+
# Invalid direction
|
|
2188
|
+
return
|
|
2160
2189
|
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
link_template = "https://" + link_template
|
|
2190
|
+
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
2191
|
+
swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
|
|
2164
2192
|
|
|
2165
|
-
|
|
2166
|
-
|
|
2193
|
+
# Add to history
|
|
2194
|
+
self.add_history(
|
|
2195
|
+
f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])",
|
|
2196
|
+
dirty=True,
|
|
2197
|
+
)
|
|
2167
2198
|
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
new_col = pl.concat_str(exprs).alias(new_col_name)
|
|
2199
|
+
# Swap rows in the table's internal row locations
|
|
2200
|
+
self.check_idle()
|
|
2171
2201
|
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2202
|
+
(
|
|
2203
|
+
self._row_locations[row_key],
|
|
2204
|
+
self._row_locations[swap_key],
|
|
2205
|
+
) = (
|
|
2206
|
+
self._row_locations.get(swap_key),
|
|
2207
|
+
self._row_locations.get(row_key),
|
|
2208
|
+
)
|
|
2176
2209
|
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
2210
|
+
self._update_count += 1
|
|
2211
|
+
self.refresh()
|
|
2180
2212
|
|
|
2181
|
-
|
|
2182
|
-
|
|
2213
|
+
# Restore cursor position on the moved row
|
|
2214
|
+
self.move_cursor(row=swap_idx, column=col_idx)
|
|
2183
2215
|
|
|
2184
|
-
|
|
2185
|
-
|
|
2216
|
+
# Swap rows in the dataframe
|
|
2217
|
+
ridx = int(row_key.value) # 0-based
|
|
2218
|
+
swap_ridx = int(swap_key.value) # 0-based
|
|
2219
|
+
first, second = sorted([ridx, swap_ridx])
|
|
2186
2220
|
|
|
2187
|
-
|
|
2221
|
+
self.df = pl.concat(
|
|
2222
|
+
[
|
|
2223
|
+
self.df.slice(0, first),
|
|
2224
|
+
self.df.slice(second, 1),
|
|
2225
|
+
self.df.slice(first + 1, second - first - 1),
|
|
2226
|
+
self.df.slice(first, 1),
|
|
2227
|
+
self.df.slice(second + 1),
|
|
2228
|
+
]
|
|
2229
|
+
)
|
|
2188
2230
|
|
|
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
|
|
2231
|
+
# self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
|
|
2192
2232
|
|
|
2193
|
-
|
|
2233
|
+
# Type casting
|
|
2234
|
+
def do_cast_column_dtype(self, dtype: str) -> None:
|
|
2194
2235
|
"""Cast the current column to a different data type.
|
|
2195
2236
|
|
|
2196
2237
|
Args:
|
|
@@ -2215,8 +2256,9 @@ class DataFrameTable(DataTable):
|
|
|
2215
2256
|
return # No change needed
|
|
2216
2257
|
|
|
2217
2258
|
# Add to history
|
|
2218
|
-
self.
|
|
2219
|
-
f"Cast column [$accent]{col_name}[/] from [$success]{current_dtype}[/] to [$success]{target_dtype}[/]"
|
|
2259
|
+
self.add_history(
|
|
2260
|
+
f"Cast column [$accent]{col_name}[/] from [$success]{current_dtype}[/] to [$success]{target_dtype}[/]",
|
|
2261
|
+
dirty=True,
|
|
2220
2262
|
)
|
|
2221
2263
|
|
|
2222
2264
|
try:
|
|
@@ -2224,7 +2266,7 @@ class DataFrameTable(DataTable):
|
|
|
2224
2266
|
self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
|
|
2225
2267
|
|
|
2226
2268
|
# Recreate table for display
|
|
2227
|
-
self.
|
|
2269
|
+
self.setup_table()
|
|
2228
2270
|
|
|
2229
2271
|
self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
|
|
2230
2272
|
except Exception as e:
|
|
@@ -2236,16 +2278,16 @@ class DataFrameTable(DataTable):
|
|
|
2236
2278
|
self.log(f"Error casting column `{col_name}`: {str(e)}")
|
|
2237
2279
|
|
|
2238
2280
|
# Search
|
|
2239
|
-
def
|
|
2281
|
+
def do_search_cursor_value(self) -> None:
|
|
2240
2282
|
"""Search with cursor value in current column."""
|
|
2241
2283
|
cidx = self.cursor_col_idx
|
|
2242
2284
|
|
|
2243
2285
|
# Get the value of the currently selected cell
|
|
2244
2286
|
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
2245
2287
|
|
|
2246
|
-
self.
|
|
2288
|
+
self.search((term, cidx, False, True))
|
|
2247
2289
|
|
|
2248
|
-
def
|
|
2290
|
+
def do_search_expr(self) -> None:
|
|
2249
2291
|
"""Search by expression."""
|
|
2250
2292
|
cidx = self.cursor_col_idx
|
|
2251
2293
|
|
|
@@ -2255,10 +2297,10 @@ class DataFrameTable(DataTable):
|
|
|
2255
2297
|
# Push the search modal screen
|
|
2256
2298
|
self.app.push_screen(
|
|
2257
2299
|
SearchScreen("Search", term, self.df, cidx),
|
|
2258
|
-
callback=self.
|
|
2300
|
+
callback=self.search,
|
|
2259
2301
|
)
|
|
2260
2302
|
|
|
2261
|
-
def
|
|
2303
|
+
def search(self, result) -> None:
|
|
2262
2304
|
"""Search for a term."""
|
|
2263
2305
|
if result is None:
|
|
2264
2306
|
return
|
|
@@ -2326,7 +2368,7 @@ class DataFrameTable(DataTable):
|
|
|
2326
2368
|
return
|
|
2327
2369
|
|
|
2328
2370
|
# Add to history
|
|
2329
|
-
self.
|
|
2371
|
+
self.add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
2330
2372
|
|
|
2331
2373
|
# Update selected rows to include new matches
|
|
2332
2374
|
for m in matches:
|
|
@@ -2336,10 +2378,10 @@ class DataFrameTable(DataTable):
|
|
|
2336
2378
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
|
|
2337
2379
|
|
|
2338
2380
|
# Recreate table for display
|
|
2339
|
-
self.
|
|
2381
|
+
self.setup_table()
|
|
2340
2382
|
|
|
2341
2383
|
# Find
|
|
2342
|
-
def
|
|
2384
|
+
def find_matches(
|
|
2343
2385
|
self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
|
|
2344
2386
|
) -> dict[int, set[int]]:
|
|
2345
2387
|
"""Find matches for a term in the dataframe.
|
|
@@ -2401,7 +2443,7 @@ class DataFrameTable(DataTable):
|
|
|
2401
2443
|
|
|
2402
2444
|
return matches
|
|
2403
2445
|
|
|
2404
|
-
def
|
|
2446
|
+
def do_find_cursor_value(self, scope="column") -> None:
|
|
2405
2447
|
"""Find by cursor value.
|
|
2406
2448
|
|
|
2407
2449
|
Args:
|
|
@@ -2412,11 +2454,11 @@ class DataFrameTable(DataTable):
|
|
|
2412
2454
|
|
|
2413
2455
|
if scope == "column":
|
|
2414
2456
|
cidx = self.cursor_col_idx
|
|
2415
|
-
self.
|
|
2457
|
+
self.find((term, cidx, False, True))
|
|
2416
2458
|
else:
|
|
2417
|
-
self.
|
|
2459
|
+
self.find_global((term, None, False, True))
|
|
2418
2460
|
|
|
2419
|
-
def
|
|
2461
|
+
def do_find_expr(self, scope="column") -> None:
|
|
2420
2462
|
"""Open screen to find by expression.
|
|
2421
2463
|
|
|
2422
2464
|
Args:
|
|
@@ -2429,10 +2471,10 @@ class DataFrameTable(DataTable):
|
|
|
2429
2471
|
# Push the search modal screen
|
|
2430
2472
|
self.app.push_screen(
|
|
2431
2473
|
SearchScreen("Find", term, self.df, cidx),
|
|
2432
|
-
callback=self.
|
|
2474
|
+
callback=self.find if scope == "column" else self.find_global,
|
|
2433
2475
|
)
|
|
2434
2476
|
|
|
2435
|
-
def
|
|
2477
|
+
def find(self, result) -> None:
|
|
2436
2478
|
"""Find a term in current column."""
|
|
2437
2479
|
if result is None:
|
|
2438
2480
|
return
|
|
@@ -2441,7 +2483,7 @@ class DataFrameTable(DataTable):
|
|
|
2441
2483
|
col_name = self.df.columns[cidx]
|
|
2442
2484
|
|
|
2443
2485
|
try:
|
|
2444
|
-
matches = self.
|
|
2486
|
+
matches = self.find_matches(term, cidx, match_nocase, match_whole)
|
|
2445
2487
|
except Exception as e:
|
|
2446
2488
|
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
2447
2489
|
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
@@ -2456,7 +2498,7 @@ class DataFrameTable(DataTable):
|
|
|
2456
2498
|
return
|
|
2457
2499
|
|
|
2458
2500
|
# Add to history
|
|
2459
|
-
self.
|
|
2501
|
+
self.add_history(f"Found [$accent]{term}[/] in column [$success]{col_name}[/]")
|
|
2460
2502
|
|
|
2461
2503
|
# Add to matches and count total
|
|
2462
2504
|
match_count = sum(len(col_idxs) for col_idxs in matches.values())
|
|
@@ -2466,16 +2508,16 @@ class DataFrameTable(DataTable):
|
|
|
2466
2508
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
|
|
2467
2509
|
|
|
2468
2510
|
# Recreate table for display
|
|
2469
|
-
self.
|
|
2511
|
+
self.setup_table()
|
|
2470
2512
|
|
|
2471
|
-
def
|
|
2513
|
+
def find_global(self, result) -> None:
|
|
2472
2514
|
"""Global find a term across all columns."""
|
|
2473
2515
|
if result is None:
|
|
2474
2516
|
return
|
|
2475
2517
|
term, cidx, match_nocase, match_whole = result
|
|
2476
2518
|
|
|
2477
2519
|
try:
|
|
2478
|
-
matches = self.
|
|
2520
|
+
matches = self.find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2479
2521
|
except Exception as e:
|
|
2480
2522
|
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
2481
2523
|
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
@@ -2490,7 +2532,7 @@ class DataFrameTable(DataTable):
|
|
|
2490
2532
|
return
|
|
2491
2533
|
|
|
2492
2534
|
# Add to history
|
|
2493
|
-
self.
|
|
2535
|
+
self.add_history(f"Found [$success]{term}[/] across all columns")
|
|
2494
2536
|
|
|
2495
2537
|
# Add to matches and count total
|
|
2496
2538
|
match_count = sum(len(col_idxs) for col_idxs in matches.values())
|
|
@@ -2502,9 +2544,9 @@ class DataFrameTable(DataTable):
|
|
|
2502
2544
|
)
|
|
2503
2545
|
|
|
2504
2546
|
# Recreate table for display
|
|
2505
|
-
self.
|
|
2547
|
+
self.setup_table()
|
|
2506
2548
|
|
|
2507
|
-
def
|
|
2549
|
+
def do_next_match(self) -> None:
|
|
2508
2550
|
"""Move cursor to the next match."""
|
|
2509
2551
|
if not self.matches:
|
|
2510
2552
|
self.notify("No matches to navigate", title="Next Match", severity="warning")
|
|
@@ -2526,7 +2568,7 @@ class DataFrameTable(DataTable):
|
|
|
2526
2568
|
first_ridx, first_cidx = ordered_matches[0]
|
|
2527
2569
|
self.move_cursor_to(first_ridx, first_cidx)
|
|
2528
2570
|
|
|
2529
|
-
def
|
|
2571
|
+
def do_previous_match(self) -> None:
|
|
2530
2572
|
"""Move cursor to the previous match."""
|
|
2531
2573
|
if not self.matches:
|
|
2532
2574
|
self.notify("No matches to navigate", title="Previous Match", severity="warning")
|
|
@@ -2554,7 +2596,7 @@ class DataFrameTable(DataTable):
|
|
|
2554
2596
|
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
2555
2597
|
self.move_cursor(row=row_idx, column=col_idx)
|
|
2556
2598
|
|
|
2557
|
-
def
|
|
2599
|
+
def do_next_selected_row(self) -> None:
|
|
2558
2600
|
"""Move cursor to the next selected row."""
|
|
2559
2601
|
if not any(self.selected_rows):
|
|
2560
2602
|
self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
|
|
@@ -2576,7 +2618,7 @@ class DataFrameTable(DataTable):
|
|
|
2576
2618
|
first_ridx = selected_row_indices[0]
|
|
2577
2619
|
self.move_cursor_to(first_ridx, self.cursor_col_idx)
|
|
2578
2620
|
|
|
2579
|
-
def
|
|
2621
|
+
def do_previous_selected_row(self) -> None:
|
|
2580
2622
|
"""Move cursor to the previous selected row."""
|
|
2581
2623
|
if not any(self.selected_rows):
|
|
2582
2624
|
self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
|
|
@@ -2599,31 +2641,31 @@ class DataFrameTable(DataTable):
|
|
|
2599
2641
|
self.move_cursor_to(last_ridx, self.cursor_col_idx)
|
|
2600
2642
|
|
|
2601
2643
|
# Replace
|
|
2602
|
-
def
|
|
2644
|
+
def do_replace(self) -> None:
|
|
2603
2645
|
"""Open replace screen for current column."""
|
|
2604
2646
|
# Push the replace modal screen
|
|
2605
2647
|
self.app.push_screen(
|
|
2606
2648
|
FindReplaceScreen(self, title="Find and Replace in Current Column"),
|
|
2607
|
-
callback=self.
|
|
2649
|
+
callback=self.replace,
|
|
2608
2650
|
)
|
|
2609
2651
|
|
|
2610
|
-
def
|
|
2652
|
+
def replace(self, result) -> None:
|
|
2611
2653
|
"""Handle replace in current column."""
|
|
2612
|
-
self.
|
|
2654
|
+
self.handle_replace(result, self.cursor_col_idx)
|
|
2613
2655
|
|
|
2614
|
-
def
|
|
2656
|
+
def do_replace_global(self) -> None:
|
|
2615
2657
|
"""Open replace screen for all columns."""
|
|
2616
2658
|
# Push the replace modal screen
|
|
2617
2659
|
self.app.push_screen(
|
|
2618
2660
|
FindReplaceScreen(self, title="Global Find and Replace"),
|
|
2619
|
-
callback=self.
|
|
2661
|
+
callback=self.replace_global,
|
|
2620
2662
|
)
|
|
2621
2663
|
|
|
2622
|
-
def
|
|
2664
|
+
def replace_global(self, result) -> None:
|
|
2623
2665
|
"""Handle replace across all columns."""
|
|
2624
|
-
self.
|
|
2666
|
+
self.handle_replace(result, None)
|
|
2625
2667
|
|
|
2626
|
-
def
|
|
2668
|
+
def handle_replace(self, result, cidx) -> None:
|
|
2627
2669
|
"""Handle replace result from ReplaceScreen.
|
|
2628
2670
|
|
|
2629
2671
|
Args:
|
|
@@ -2640,14 +2682,14 @@ class DataFrameTable(DataTable):
|
|
|
2640
2682
|
col_name = self.df.columns[cidx]
|
|
2641
2683
|
|
|
2642
2684
|
# Find all matches
|
|
2643
|
-
matches = self.
|
|
2685
|
+
matches = self.find_matches(term_find, cidx, match_nocase, match_whole)
|
|
2644
2686
|
|
|
2645
2687
|
if not matches:
|
|
2646
2688
|
self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
|
|
2647
2689
|
return
|
|
2648
2690
|
|
|
2649
2691
|
# Add to history
|
|
2650
|
-
self.
|
|
2692
|
+
self.add_history(
|
|
2651
2693
|
f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
|
|
2652
2694
|
)
|
|
2653
2695
|
|
|
@@ -2655,11 +2697,11 @@ class DataFrameTable(DataTable):
|
|
|
2655
2697
|
self.matches = {ridx: col_idxs.copy() for ridx, col_idxs in matches.items()}
|
|
2656
2698
|
|
|
2657
2699
|
# Recreate table for display
|
|
2658
|
-
self.
|
|
2700
|
+
self.setup_table()
|
|
2659
2701
|
|
|
2660
2702
|
# Store state for interactive replacement using dataclass
|
|
2661
2703
|
sorted_rows = sorted(self.matches.keys())
|
|
2662
|
-
self.
|
|
2704
|
+
self.replace_state = ReplaceState(
|
|
2663
2705
|
term_find=term_find,
|
|
2664
2706
|
term_replace=term_replace,
|
|
2665
2707
|
match_nocase=match_nocase,
|
|
@@ -2679,10 +2721,10 @@ class DataFrameTable(DataTable):
|
|
|
2679
2721
|
try:
|
|
2680
2722
|
if replace_all:
|
|
2681
2723
|
# Replace all occurrences
|
|
2682
|
-
self.
|
|
2724
|
+
self.replace_all(term_find, term_replace)
|
|
2683
2725
|
else:
|
|
2684
2726
|
# Replace with confirmation for each occurrence
|
|
2685
|
-
self.
|
|
2727
|
+
self.replace_interactive(term_find, term_replace)
|
|
2686
2728
|
|
|
2687
2729
|
except Exception as e:
|
|
2688
2730
|
self.notify(
|
|
@@ -2692,23 +2734,23 @@ class DataFrameTable(DataTable):
|
|
|
2692
2734
|
)
|
|
2693
2735
|
self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
|
|
2694
2736
|
|
|
2695
|
-
def
|
|
2737
|
+
def replace_all(self, term_find: str, term_replace: str) -> None:
|
|
2696
2738
|
"""Replace all occurrences."""
|
|
2697
|
-
state = self.
|
|
2739
|
+
state = self.replace_state
|
|
2698
2740
|
self.app.push_screen(
|
|
2699
2741
|
ConfirmScreen(
|
|
2700
2742
|
"Replace All",
|
|
2701
2743
|
label=f"Replace [$success]{term_find}[/] with [$success]{term_replace or repr('')}[/] for all [$accent]{state.total_occurrence}[/] occurrences?",
|
|
2702
2744
|
),
|
|
2703
|
-
callback=self.
|
|
2745
|
+
callback=self.handle_replace_all_confirmation,
|
|
2704
2746
|
)
|
|
2705
2747
|
|
|
2706
|
-
def
|
|
2748
|
+
def handle_replace_all_confirmation(self, result) -> None:
|
|
2707
2749
|
"""Handle user's confirmation for replace all."""
|
|
2708
2750
|
if result is None:
|
|
2709
2751
|
return
|
|
2710
2752
|
|
|
2711
|
-
state = self.
|
|
2753
|
+
state = self.replace_state
|
|
2712
2754
|
rows = state.rows
|
|
2713
2755
|
cols_per_row = state.cols_per_row
|
|
2714
2756
|
|
|
@@ -2756,7 +2798,11 @@ class DataFrameTable(DataTable):
|
|
|
2756
2798
|
state.replaced_occurrence += len(ridxs)
|
|
2757
2799
|
|
|
2758
2800
|
# Recreate table for display
|
|
2759
|
-
self.
|
|
2801
|
+
self.setup_table()
|
|
2802
|
+
|
|
2803
|
+
# Mark as dirty if any replacements were made
|
|
2804
|
+
if state.replaced_occurrence > 0:
|
|
2805
|
+
self.dirty = True
|
|
2760
2806
|
|
|
2761
2807
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2762
2808
|
self.notify(
|
|
@@ -2764,11 +2810,11 @@ class DataFrameTable(DataTable):
|
|
|
2764
2810
|
title="Replace",
|
|
2765
2811
|
)
|
|
2766
2812
|
|
|
2767
|
-
def
|
|
2813
|
+
def replace_interactive(self, term_find: str, term_replace: str) -> None:
|
|
2768
2814
|
"""Replace with user confirmation for each occurrence."""
|
|
2769
2815
|
try:
|
|
2770
2816
|
# Start with first match
|
|
2771
|
-
self.
|
|
2817
|
+
self.show_next_replace_confirmation()
|
|
2772
2818
|
except Exception as e:
|
|
2773
2819
|
self.notify(
|
|
2774
2820
|
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
|
|
@@ -2777,9 +2823,9 @@ class DataFrameTable(DataTable):
|
|
|
2777
2823
|
)
|
|
2778
2824
|
self.log(f"Error in interactive replace: {str(e)}")
|
|
2779
2825
|
|
|
2780
|
-
def
|
|
2826
|
+
def show_next_replace_confirmation(self) -> None:
|
|
2781
2827
|
"""Show confirmation for next replacement."""
|
|
2782
|
-
state = self.
|
|
2828
|
+
state = self.replace_state
|
|
2783
2829
|
if state.done:
|
|
2784
2830
|
# All done - show final notification
|
|
2785
2831
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
@@ -2787,6 +2833,10 @@ class DataFrameTable(DataTable):
|
|
|
2787
2833
|
if state.skipped_occurrence > 0:
|
|
2788
2834
|
msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
|
|
2789
2835
|
self.notify(msg, title="Replace")
|
|
2836
|
+
|
|
2837
|
+
if state.replaced_occurrence > 0:
|
|
2838
|
+
self.dirty = True
|
|
2839
|
+
|
|
2790
2840
|
return
|
|
2791
2841
|
|
|
2792
2842
|
# Move cursor to next match
|
|
@@ -2801,12 +2851,12 @@ class DataFrameTable(DataTable):
|
|
|
2801
2851
|
|
|
2802
2852
|
self.app.push_screen(
|
|
2803
2853
|
ConfirmScreen("Replace", label=label, maybe="Skip"),
|
|
2804
|
-
callback=self.
|
|
2854
|
+
callback=self.handle_replace_confirmation,
|
|
2805
2855
|
)
|
|
2806
2856
|
|
|
2807
|
-
def
|
|
2857
|
+
def handle_replace_confirmation(self, result) -> None:
|
|
2808
2858
|
"""Handle user's confirmation response."""
|
|
2809
|
-
state = self.
|
|
2859
|
+
state = self.replace_state
|
|
2810
2860
|
if state.done:
|
|
2811
2861
|
return
|
|
2812
2862
|
|
|
@@ -2849,38 +2899,34 @@ class DataFrameTable(DataTable):
|
|
|
2849
2899
|
# Cancel
|
|
2850
2900
|
else:
|
|
2851
2901
|
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
2902
|
|
|
2862
|
-
if
|
|
2863
|
-
|
|
2903
|
+
if not state.done:
|
|
2904
|
+
# Get the new value of the current cell after replacement
|
|
2905
|
+
new_cell_value = self.df.item(ridx, cidx)
|
|
2906
|
+
row_key = str(ridx)
|
|
2907
|
+
col_key = col_name
|
|
2908
|
+
self.update_cell(
|
|
2909
|
+
row_key, col_key, Text(str(new_cell_value), style=HIGHLIGHT_COLOR, justify=DtypeConfig(dtype).justify)
|
|
2910
|
+
)
|
|
2864
2911
|
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
)
|
|
2912
|
+
# Move to next
|
|
2913
|
+
if state.current_cpos + 1 < len(state.cols_per_row[state.current_rpos]):
|
|
2914
|
+
state.current_cpos += 1
|
|
2915
|
+
else:
|
|
2916
|
+
state.current_cpos = 0
|
|
2917
|
+
state.current_rpos += 1
|
|
2872
2918
|
|
|
2873
|
-
|
|
2874
|
-
|
|
2919
|
+
if state.current_rpos >= len(state.rows):
|
|
2920
|
+
state.done = True
|
|
2875
2921
|
|
|
2876
2922
|
# Show next confirmation
|
|
2877
|
-
self.
|
|
2923
|
+
self.show_next_replace_confirmation()
|
|
2878
2924
|
|
|
2879
2925
|
# Selection & Match
|
|
2880
|
-
def
|
|
2926
|
+
def do_toggle_selections(self) -> None:
|
|
2881
2927
|
"""Toggle selected rows highlighting on/off."""
|
|
2882
2928
|
# Add to history
|
|
2883
|
-
self.
|
|
2929
|
+
self.add_history("Toggled row selection")
|
|
2884
2930
|
|
|
2885
2931
|
if False in self.visible_rows:
|
|
2886
2932
|
# Some rows are hidden - invert only selected visible rows and clear selections for hidden rows
|
|
@@ -2898,12 +2944,12 @@ class DataFrameTable(DataTable):
|
|
|
2898
2944
|
self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
|
|
2899
2945
|
|
|
2900
2946
|
# Recreate table for display
|
|
2901
|
-
self.
|
|
2947
|
+
self.setup_table()
|
|
2902
2948
|
|
|
2903
|
-
def
|
|
2949
|
+
def do_toggle_row_selection(self) -> None:
|
|
2904
2950
|
"""Select/deselect current row."""
|
|
2905
2951
|
# Add to history
|
|
2906
|
-
self.
|
|
2952
|
+
self.add_history("Toggled row selection")
|
|
2907
2953
|
|
|
2908
2954
|
ridx = self.cursor_row_idx
|
|
2909
2955
|
self.selected_rows[ridx] = not self.selected_rows[ridx]
|
|
@@ -2924,7 +2970,7 @@ class DataFrameTable(DataTable):
|
|
|
2924
2970
|
|
|
2925
2971
|
self.update_cell(row_key, col_key, cell_text)
|
|
2926
2972
|
|
|
2927
|
-
def
|
|
2973
|
+
def do_clear_selections_and_matches(self) -> None:
|
|
2928
2974
|
"""Clear all selected rows and matches without removing them from the dataframe."""
|
|
2929
2975
|
# Check if any selected rows or matches
|
|
2930
2976
|
if not any(self.selected_rows) and not self.matches:
|
|
@@ -2936,19 +2982,19 @@ class DataFrameTable(DataTable):
|
|
|
2936
2982
|
)
|
|
2937
2983
|
|
|
2938
2984
|
# Add to history
|
|
2939
|
-
self.
|
|
2985
|
+
self.add_history("Cleared all selected rows")
|
|
2940
2986
|
|
|
2941
2987
|
# Clear all selections
|
|
2942
2988
|
self.selected_rows = [False] * len(self.df)
|
|
2943
2989
|
self.matches = defaultdict(set)
|
|
2944
2990
|
|
|
2945
2991
|
# Recreate table for display
|
|
2946
|
-
self.
|
|
2992
|
+
self.setup_table()
|
|
2947
2993
|
|
|
2948
2994
|
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
2949
2995
|
|
|
2950
2996
|
# Filter & View
|
|
2951
|
-
def
|
|
2997
|
+
def do_filter_rows(self) -> None:
|
|
2952
2998
|
"""Keep only the rows with selections and matches, and remove others."""
|
|
2953
2999
|
if not any(self.selected_rows) and not self.matches:
|
|
2954
3000
|
self.notify("No rows to filter", title="Filter", severity="warning")
|
|
@@ -2959,7 +3005,7 @@ class DataFrameTable(DataTable):
|
|
|
2959
3005
|
]
|
|
2960
3006
|
|
|
2961
3007
|
# Add to history
|
|
2962
|
-
self.
|
|
3008
|
+
self.add_history("Filtered to selections and matches", dirty=True)
|
|
2963
3009
|
|
|
2964
3010
|
# Apply filter to dataframe with row indices
|
|
2965
3011
|
df_filtered = self.df.with_row_index(RIDX).filter(filter_expr)
|
|
@@ -2974,13 +3020,13 @@ class DataFrameTable(DataTable):
|
|
|
2974
3020
|
self.df = df_filtered.drop(RIDX)
|
|
2975
3021
|
|
|
2976
3022
|
# Recreate table for display
|
|
2977
|
-
self.
|
|
3023
|
+
self.setup_table()
|
|
2978
3024
|
|
|
2979
3025
|
self.notify(
|
|
2980
3026
|
f"Removed rows without selections or matches. Now showing [$accent]{len(self.df)}[/] rows", title="Filter"
|
|
2981
3027
|
)
|
|
2982
3028
|
|
|
2983
|
-
def
|
|
3029
|
+
def do_view_rows(self) -> None:
|
|
2984
3030
|
"""View rows.
|
|
2985
3031
|
|
|
2986
3032
|
If there are selected rows or matches, view those rows.
|
|
@@ -2999,9 +3045,9 @@ class DataFrameTable(DataTable):
|
|
|
2999
3045
|
ridx = self.cursor_row_idx
|
|
3000
3046
|
term = str(self.df.item(ridx, cidx))
|
|
3001
3047
|
|
|
3002
|
-
self.
|
|
3048
|
+
self.view_rows((term, cidx, False, True))
|
|
3003
3049
|
|
|
3004
|
-
def
|
|
3050
|
+
def do_view_rows_expr(self) -> None:
|
|
3005
3051
|
"""Open the filter screen to enter an expression."""
|
|
3006
3052
|
ridx = self.cursor_row_idx
|
|
3007
3053
|
cidx = self.cursor_col_idx
|
|
@@ -3009,10 +3055,10 @@ class DataFrameTable(DataTable):
|
|
|
3009
3055
|
|
|
3010
3056
|
self.app.push_screen(
|
|
3011
3057
|
FilterScreen(self.df, cidx, cursor_value),
|
|
3012
|
-
callback=self.
|
|
3058
|
+
callback=self.view_rows,
|
|
3013
3059
|
)
|
|
3014
3060
|
|
|
3015
|
-
def
|
|
3061
|
+
def view_rows(self, result) -> None:
|
|
3016
3062
|
"""Show only rows with selections or matches, and do hide others. Do not modify the dataframe."""
|
|
3017
3063
|
if result is None:
|
|
3018
3064
|
return
|
|
@@ -3062,13 +3108,18 @@ class DataFrameTable(DataTable):
|
|
|
3062
3108
|
if False in self.visible_rows:
|
|
3063
3109
|
lf = lf.filter(self.visible_rows)
|
|
3064
3110
|
|
|
3111
|
+
if isinstance(expr, (list, pl.Series)):
|
|
3112
|
+
expr_str = str(list(expr)[:10]) + ("..." if len(expr) > 10 else "")
|
|
3113
|
+
else:
|
|
3114
|
+
expr_str = str(expr)
|
|
3115
|
+
|
|
3065
3116
|
# Apply the filter expression
|
|
3066
3117
|
try:
|
|
3067
3118
|
df_filtered = lf.filter(expr).collect()
|
|
3068
3119
|
except Exception as e:
|
|
3069
3120
|
self.histories.pop() # Remove last history entry
|
|
3070
|
-
self.notify(f"Error applying filter [$error]{
|
|
3071
|
-
self.log(f"Error applying filter `{
|
|
3121
|
+
self.notify(f"Error applying filter [$error]{expr_str}[/]", title="Filter", severity="error")
|
|
3122
|
+
self.log(f"Error applying filter `{expr_str}`: {str(e)}")
|
|
3072
3123
|
return
|
|
3073
3124
|
|
|
3074
3125
|
matched_count = len(df_filtered)
|
|
@@ -3077,7 +3128,7 @@ class DataFrameTable(DataTable):
|
|
|
3077
3128
|
return
|
|
3078
3129
|
|
|
3079
3130
|
# Add to history
|
|
3080
|
-
self.
|
|
3131
|
+
self.add_history(f"Filtered by expression [$success]{expr_str}[/]", dirty=True)
|
|
3081
3132
|
|
|
3082
3133
|
# Mark unfiltered rows as invisible
|
|
3083
3134
|
filtered_row_indices = set(df_filtered[RIDX].to_list())
|
|
@@ -3087,12 +3138,12 @@ class DataFrameTable(DataTable):
|
|
|
3087
3138
|
self.visible_rows[ridx] = False
|
|
3088
3139
|
|
|
3089
3140
|
# Recreate table for display
|
|
3090
|
-
self.
|
|
3141
|
+
self.setup_table()
|
|
3091
3142
|
|
|
3092
3143
|
self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
|
|
3093
3144
|
|
|
3094
3145
|
# Copy & Save
|
|
3095
|
-
def
|
|
3146
|
+
def do_copy_to_clipboard(self, content: str, message: str) -> None:
|
|
3096
3147
|
"""Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
|
|
3097
3148
|
|
|
3098
3149
|
Args:
|
|
@@ -3115,54 +3166,64 @@ class DataFrameTable(DataTable):
|
|
|
3115
3166
|
except FileNotFoundError:
|
|
3116
3167
|
self.notify("Error copying to clipboard", title="Clipboard", severity="error")
|
|
3117
3168
|
|
|
3118
|
-
def
|
|
3169
|
+
def do_save_to_file(
|
|
3170
|
+
self, title: str = "Save to File", all_tabs: bool | None = None, task_after_save: str | None = None
|
|
3171
|
+
) -> None:
|
|
3119
3172
|
"""Open screen to save file."""
|
|
3120
|
-
self.
|
|
3173
|
+
self._task_after_save = task_after_save
|
|
3174
|
+
|
|
3175
|
+
multi_tab = len(self.app.tabs) > 1
|
|
3176
|
+
filename = (
|
|
3177
|
+
"all-tabs.xlsx"
|
|
3178
|
+
if all_tabs or (all_tabs is None and multi_tab)
|
|
3179
|
+
else str(Path(self.filename).with_stem(self.tabname))
|
|
3180
|
+
)
|
|
3181
|
+
self.app.push_screen(
|
|
3182
|
+
SaveFileScreen(filename, title=title, all_tabs=all_tabs, multi_tab=multi_tab),
|
|
3183
|
+
callback=self.save_to_file,
|
|
3184
|
+
)
|
|
3121
3185
|
|
|
3122
|
-
def
|
|
3186
|
+
def save_to_file(self, result) -> None:
|
|
3123
3187
|
"""Handle result from SaveFileScreen."""
|
|
3124
|
-
if
|
|
3188
|
+
if result is None:
|
|
3125
3189
|
return
|
|
3126
|
-
|
|
3127
|
-
ext = filepath.suffix.lower()
|
|
3190
|
+
filename, all_tabs = result
|
|
3128
3191
|
|
|
3129
3192
|
# Whether to save all tabs (for Excel files)
|
|
3130
3193
|
self._all_tabs = all_tabs
|
|
3131
3194
|
|
|
3132
3195
|
# Check if file exists
|
|
3133
|
-
if
|
|
3196
|
+
if Path(filename).exists():
|
|
3134
3197
|
self._pending_filename = filename
|
|
3135
3198
|
self.app.push_screen(
|
|
3136
3199
|
ConfirmScreen("File already exists. Overwrite?"),
|
|
3137
|
-
callback=self.
|
|
3200
|
+
callback=self.confirm_overwrite,
|
|
3138
3201
|
)
|
|
3139
|
-
elif ext in (".xlsx", ".xls"):
|
|
3140
|
-
self._do_save_excel(filename)
|
|
3141
3202
|
else:
|
|
3142
|
-
self.
|
|
3203
|
+
self.save_file(filename)
|
|
3143
3204
|
|
|
3144
|
-
def
|
|
3205
|
+
def confirm_overwrite(self, should_overwrite: bool) -> None:
|
|
3145
3206
|
"""Handle result from ConfirmScreen."""
|
|
3146
3207
|
if should_overwrite:
|
|
3147
|
-
self.
|
|
3208
|
+
self.save_file(self._pending_filename)
|
|
3148
3209
|
else:
|
|
3149
3210
|
# Go back to SaveFileScreen to allow user to enter a different name
|
|
3150
3211
|
self.app.push_screen(
|
|
3151
3212
|
SaveFileScreen(self._pending_filename),
|
|
3152
|
-
callback=self.
|
|
3213
|
+
callback=self.save_to_file,
|
|
3153
3214
|
)
|
|
3154
3215
|
|
|
3155
|
-
def
|
|
3216
|
+
def save_file(self, filename: str) -> None:
|
|
3156
3217
|
"""Actually save the dataframe to a file."""
|
|
3157
3218
|
filepath = Path(filename)
|
|
3158
3219
|
ext = filepath.suffix.lower()
|
|
3159
3220
|
|
|
3160
3221
|
# Add to history
|
|
3161
|
-
self.
|
|
3222
|
+
self.add_history(f"Saved dataframe to [$success]{filename}[/]")
|
|
3162
3223
|
|
|
3163
3224
|
try:
|
|
3164
3225
|
if ext in (".xlsx", ".xls"):
|
|
3165
|
-
self.
|
|
3226
|
+
self.save_excel(filename)
|
|
3166
3227
|
elif ext in (".tsv", ".tab"):
|
|
3167
3228
|
self.df.write_csv(filename, separator="\t")
|
|
3168
3229
|
elif ext == ".json":
|
|
@@ -3174,14 +3235,31 @@ class DataFrameTable(DataTable):
|
|
|
3174
3235
|
|
|
3175
3236
|
self.dataframe = self.df # Update original dataframe
|
|
3176
3237
|
self.filename = filename # Update current filename
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3238
|
+
|
|
3239
|
+
# Reset dirty flag after save
|
|
3240
|
+
if self._all_tabs:
|
|
3241
|
+
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3242
|
+
for table in tabs.values():
|
|
3243
|
+
table.dirty = False
|
|
3244
|
+
else:
|
|
3245
|
+
self.dirty = False
|
|
3246
|
+
|
|
3247
|
+
if self._task_after_save == "close_tab":
|
|
3248
|
+
self.app.do_close_tab()
|
|
3249
|
+
elif self._task_after_save == "quit_app":
|
|
3250
|
+
self.app.exit()
|
|
3251
|
+
|
|
3252
|
+
# From ConfirmScreen callback, so notify accordingly
|
|
3253
|
+
if self._all_tabs:
|
|
3254
|
+
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
|
|
3255
|
+
else:
|
|
3256
|
+
self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
|
|
3257
|
+
|
|
3180
3258
|
except Exception as e:
|
|
3181
|
-
self.notify(f"Error saving [$error]{filename}[/]", title="Save", severity="error")
|
|
3259
|
+
self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error")
|
|
3182
3260
|
self.log(f"Error saving file `{filename}`: {str(e)}")
|
|
3183
3261
|
|
|
3184
|
-
def
|
|
3262
|
+
def save_excel(self, filename: str) -> None:
|
|
3185
3263
|
"""Save to an Excel file."""
|
|
3186
3264
|
import xlsxwriter
|
|
3187
3265
|
|
|
@@ -3192,75 +3270,97 @@ class DataFrameTable(DataTable):
|
|
|
3192
3270
|
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
3193
3271
|
with xlsxwriter.Workbook(filename) as wb:
|
|
3194
3272
|
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3195
|
-
for
|
|
3196
|
-
worksheet = wb.add_worksheet(
|
|
3273
|
+
for table in tabs.values():
|
|
3274
|
+
worksheet = wb.add_worksheet(table.tabname)
|
|
3197
3275
|
table.df.write_excel(workbook=wb, worksheet=worksheet)
|
|
3198
3276
|
|
|
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
3277
|
# SQL Interface
|
|
3208
|
-
def
|
|
3278
|
+
def do_simple_sql(self) -> None:
|
|
3209
3279
|
"""Open the SQL interface screen."""
|
|
3210
3280
|
self.app.push_screen(
|
|
3211
3281
|
SimpleSqlScreen(self),
|
|
3212
|
-
callback=self.
|
|
3282
|
+
callback=self.simple_sql,
|
|
3213
3283
|
)
|
|
3214
3284
|
|
|
3215
|
-
def
|
|
3285
|
+
def simple_sql(self, result) -> None:
|
|
3216
3286
|
"""Handle SQL result result from SimpleSqlScreen."""
|
|
3217
3287
|
if result is None:
|
|
3218
3288
|
return
|
|
3219
|
-
columns, where = result
|
|
3289
|
+
columns, where, view = result
|
|
3220
3290
|
|
|
3221
3291
|
sql = f"SELECT {columns} FROM self"
|
|
3222
3292
|
if where:
|
|
3223
3293
|
sql += f" WHERE {where}"
|
|
3224
3294
|
|
|
3225
|
-
self.
|
|
3295
|
+
self.run_sql(sql, view)
|
|
3226
3296
|
|
|
3227
|
-
def
|
|
3297
|
+
def do_advanced_sql(self) -> None:
|
|
3228
3298
|
"""Open the advanced SQL interface screen."""
|
|
3229
3299
|
self.app.push_screen(
|
|
3230
3300
|
AdvancedSqlScreen(self),
|
|
3231
|
-
callback=self.
|
|
3301
|
+
callback=self.advanced_sql,
|
|
3232
3302
|
)
|
|
3233
3303
|
|
|
3234
|
-
def
|
|
3304
|
+
def advanced_sql(self, result) -> None:
|
|
3235
3305
|
"""Handle SQL result result from AdvancedSqlScreen."""
|
|
3236
3306
|
if result is None:
|
|
3237
3307
|
return
|
|
3308
|
+
sql, view = result
|
|
3238
3309
|
|
|
3239
|
-
self.
|
|
3310
|
+
self.run_sql(sql, view)
|
|
3240
3311
|
|
|
3241
|
-
def
|
|
3312
|
+
def run_sql(self, sql: str, view: bool = True) -> None:
|
|
3242
3313
|
"""Execute a SQL query directly.
|
|
3243
3314
|
|
|
3244
3315
|
Args:
|
|
3245
3316
|
sql: The SQL query string to execute.
|
|
3246
3317
|
"""
|
|
3247
|
-
|
|
3248
|
-
|
|
3318
|
+
|
|
3319
|
+
import re
|
|
3320
|
+
|
|
3321
|
+
RE_FROM_SELF = re.compile(r"\bfrom\s+self\b", re.IGNORECASE)
|
|
3322
|
+
|
|
3323
|
+
sql = RE_FROM_SELF.sub(f", `{RIDX}` FROM self", sql)
|
|
3249
3324
|
|
|
3250
3325
|
# Execute the SQL query
|
|
3251
3326
|
try:
|
|
3252
|
-
|
|
3327
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
3328
|
+
if False in self.visible_rows:
|
|
3329
|
+
lf = lf.filter(self.visible_rows)
|
|
3330
|
+
|
|
3331
|
+
df_filtered = lf.sql(sql).collect()
|
|
3332
|
+
|
|
3333
|
+
if not len(df_filtered):
|
|
3334
|
+
self.notify(
|
|
3335
|
+
f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning"
|
|
3336
|
+
)
|
|
3337
|
+
return
|
|
3338
|
+
|
|
3339
|
+
# Add to history
|
|
3340
|
+
self.add_history(f"SQL Query:\n[$accent]{sql}[/]", dirty=not view)
|
|
3341
|
+
|
|
3342
|
+
if view:
|
|
3343
|
+
# Just view - do not modify the dataframe
|
|
3344
|
+
filtered_row_indices = set(df_filtered[RIDX].to_list())
|
|
3345
|
+
if filtered_row_indices:
|
|
3346
|
+
self.visible_rows = [ridx in filtered_row_indices for ridx in range(len(self.visible_rows))]
|
|
3347
|
+
|
|
3348
|
+
filtered_col_names = set(df_filtered.columns)
|
|
3349
|
+
if filtered_col_names:
|
|
3350
|
+
self.hidden_columns = {
|
|
3351
|
+
col_name for col_name in self.df.columns if col_name not in filtered_col_names
|
|
3352
|
+
}
|
|
3353
|
+
else: # filter - modify the dataframe
|
|
3354
|
+
self.df = df_filtered.drop(RIDX)
|
|
3355
|
+
self.visible_rows = [True] * len(self.df)
|
|
3356
|
+
self.hidden_columns.clear()
|
|
3253
3357
|
except Exception as e:
|
|
3254
|
-
self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error")
|
|
3358
|
+
self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error", timeout=10)
|
|
3255
3359
|
self.log(f"Error executing SQL query `{sql}`: {str(e)}")
|
|
3256
3360
|
return
|
|
3257
3361
|
|
|
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
3362
|
# Recreate table for display
|
|
3263
|
-
self.
|
|
3363
|
+
self.setup_table()
|
|
3264
3364
|
|
|
3265
3365
|
self.notify(
|
|
3266
3366
|
f"SQL query executed successfully. Now showing [$accent]{len(self.df)}[/] rows and [$accent]{len(self.df.columns)}[/] columns.",
|