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