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.
@@ -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
- ## 👁️ View & Display
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
- ## 🔍 Search & Filter
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 & Filtering
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 to show only selected rows
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 & WHERE clause)
173
+ - **l** - 💬 Open simple SQL interface (select columns & where clause)
163
174
  - **L** - 🔎 Open advanced SQL interface (full SQL queries)
164
175
 
165
- ## ✏️ Edit & Modify
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 Conversion
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
- ## 🔗 URL Handling
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 Conversion
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
- def __init__(self, df: pl.DataFrame, filename: str = "", name: str = "", **kwargs) -> None:
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
- name: Optional display name for the table tab. Defaults to "" (uses filename stem).
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__(name=(name or Path(filename).stem), **kwargs)
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 _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
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._load_rows(stop=ridx + self.BATCH_SIZE)
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._setup_table()
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._check_and_load_more()
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._rename_column()
624
+ self.do_rename_column()
604
625
  else:
605
- self._edit_cell()
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._load_rows(move_to_end=True)
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._check_and_load_more()
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._view_row_detail()
648
+ self.do_view_row_detail()
628
649
 
629
650
  def action_delete_column(self) -> None:
630
651
  """Delete the current column."""
631
- self._delete_column()
652
+ self.do_delete_column()
632
653
 
633
654
  def action_hide_column(self) -> None:
634
655
  """Hide the current column."""
635
- self._hide_column()
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._expand_column()
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._show_hidden_rows_columns()
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._sort_by_column(descending=False)
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._sort_by_column(descending=True)
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._save_to_file()
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._show_frequency()
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._show_statistics(scope)
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._view_rows()
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._view_rows_expr()
696
+ self.do_view_rows_expr()
676
697
 
677
698
  def action_edit_cell(self) -> None:
678
699
  """Edit the current cell."""
679
- self._edit_cell()
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._edit_column()
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._add_column()
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._add_column_expr()
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._rename_column()
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._clear_cell()
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._search_cursor_value()
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._search_expr()
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._find_cursor_value(scope=scope)
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._find_expr(scope=scope)
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._replace()
752
+ self.do_replace()
728
753
 
729
754
  def action_replace_global(self) -> None:
730
755
  """Replace values across all columns."""
731
- self._replace_global()
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._toggle_row_selection()
760
+ self.do_toggle_row_selection()
736
761
 
737
762
  def action_toggle_selections(self) -> None:
738
763
  """Toggle all row selections."""
739
- self._toggle_selections()
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._filter_rows()
768
+ self.do_filter_rows()
744
769
 
745
770
  def action_delete_row(self) -> None:
746
771
  """Delete the current row."""
747
- self._delete_row()
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._delete_row(more="below")
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._delete_row(more="above")
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._duplicate_column()
784
+ self.do_duplicate_column()
760
785
 
761
786
  def action_duplicate_row(self) -> None:
762
787
  """Duplicate the current row."""
763
- self._duplicate_row()
788
+ self.do_duplicate_row()
764
789
 
765
790
  def action_undo(self) -> None:
766
791
  """Undo the last action."""
767
- self._undo()
792
+ self.do_undo()
768
793
 
769
794
  def action_redo(self) -> None:
770
795
  """Redo the last undone action."""
771
- self._redo()
796
+ self.do_redo()
772
797
 
773
798
  def action_reset(self) -> None:
774
799
  """Reset to the initial state."""
775
- self._setup_table(reset=True)
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._move_column("left")
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._move_column("right")
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._move_row("up")
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._move_row("down")
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._clear_selections_and_matches()
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._cycle_cursor_type()
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._freeze_row_column()
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._cast_column_dtype(dtype)
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._copy_to_clipboard(cell_str, f"Copied: [$success]{cell_str[:50]}[/]")
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._copy_to_clipboard(
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._copy_to_clipboard(
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._setup_table()
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._next_match()
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._previous_match()
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._next_selected_row()
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._previous_selected_row()
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._simple_sql()
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._advanced_sql()
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._check_and_load_more()
916
+ self.check_and_load_more()
897
917
 
898
918
  # Setup & Loading
899
- def _setup_table(self, reset: bool = False) -> None:
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._setup_columns()
940
- self._load_rows(stop)
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 _determine_column_widths(self) -> dict[str, int]:
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 _setup_columns(self) -> None:
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._determine_column_widths()
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 _load_rows(self, stop: int | None = None, move_to_end: bool = False) -> None:
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._load_rows_async(stop, move_to_end=move_to_end)
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._load_rows_async(stop, move_to_end=move_to_end)
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 _load_rows_async(self, stop: int, move_to_end: bool = False) -> None:
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._load_rows_batch(next_stop)
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._load_rows_async(stop, move_to_end=move_to_end)
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 _load_rows_batch(self, stop: int) -> None:
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 _check_and_load_more(self) -> None:
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._load_rows(self.loaded_rows + self.BATCH_SIZE)
1192
+ self.load_rows(self.loaded_rows + self.BATCH_SIZE)
1168
1193
 
1169
1194
  # Highlighting
1170
- def _do_highlight(self, force: bool = False) -> None:
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._load_rows(stop)
1181
- self._highlight_table(force)
1205
+ self.load_rows(stop)
1206
+ self.highlight_table(force)
1182
1207
 
1183
- def _highlight_table(self, force: bool = False) -> None:
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 _create_history(self, description: str) -> None:
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 _apply_history(self, history: History) -> None:
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._setup_table()
1283
+ self.setup_table()
1257
1284
 
1258
- def _add_history(self, description: str) -> None:
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._create_history(description)
1292
+ history = self.create_history(description)
1265
1293
  self.histories.append(history)
1266
1294
 
1267
- def _undo(self) -> None:
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._create_history(history.description)
1309
+ self.history = self.create_history(history.description)
1278
1310
 
1279
1311
  # Restore state
1280
- self._apply_history(history)
1312
+ self.apply_history(history)
1281
1313
 
1282
1314
  self.notify(f"Reverted: {history.description}", title="Undo")
1283
1315
 
1284
- def _redo(self) -> None:
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._add_history(description)
1325
+ self.add_history(description)
1294
1326
 
1295
1327
  # Restore state
1296
- self._apply_history(self.history)
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 _cycle_cursor_type(self) -> None:
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 _view_row_detail(self) -> None:
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 _show_frequency(self) -> None:
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 _show_statistics(self, scope: str = "column") -> None:
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 _freeze_row_column(self) -> None:
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._do_freeze)
1385
+ self.app.push_screen(FreezeScreen(), callback=self.freeze_row_column)
1342
1386
 
1343
- def _do_freeze(self, result: tuple[int, int] | None) -> None:
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._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
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 _hide_column(self) -> None:
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._add_history(f"Hid column [$success]{col_name}[/]")
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 _expand_column(self) -> None:
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 _show_hidden_rows_columns(self) -> None:
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._add_history("Showed hidden rows/columns")
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._setup_table()
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
- def _make_cell_clickable(self) -> None:
1451
- """Make cells with URLs in the current column clickable.
1494
+ # Sort
1495
+ def do_sort_by_column(self, descending: bool = False) -> None:
1496
+ """Sort by the currently selected column.
1452
1497
 
1453
- Scans all loaded rows in the current column for cells containing URLs
1454
- (starting with 'http://' or 'https://') and applies Textual link styling
1455
- to make them clickable. Does not modify the dataframe.
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
- Returns:
1458
- None
1502
+ Args:
1503
+ descending: If True, sort in descending order. If False, ascending order.
1459
1504
  """
1460
- cidx = self.cursor_col_idx
1461
- col_key = self.cursor_col_key
1462
- dtype = self.df.dtypes[cidx]
1463
-
1464
- # Only process string columns
1465
- if dtype != pl.String:
1466
- return
1505
+ col_name = self.cursor_col_name
1506
+ col_idx = self.cursor_column
1467
1507
 
1468
- # Count how many URLs were made clickable
1469
- url_count = 0
1508
+ # Check if this column is already in the sort keys
1509
+ old_desc = self.sorted_columns.get(col_name)
1470
1510
 
1471
- # Iterate through all loaded rows and make URLs clickable
1472
- for row in self.ordered_rows:
1473
- cell_text: Text = self.get_cell(row.key, col_key)
1474
- if cell_text.plain.startswith(("http://", "https://")):
1475
- cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
1476
- self.update_cell(row.key, col_key, cell_text)
1477
- url_count += 1
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
- if url_count:
1480
- self.notify(
1481
- f"Use Ctrl/Cmd click to open the links in column [$success]{col_key.value}[/]", title="Hyperlink"
1482
- )
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
- # Delete & Move
1485
- def _delete_column(self, more: str = None) -> None:
1486
- """Remove the currently selected column from the table."""
1487
- # Get the column to remove
1488
- col_idx = self.cursor_column
1489
- col_name = self.cursor_col_name
1490
- col_key = self.cursor_col_key
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
- col_names_to_remove = []
1493
- col_keys_to_remove = []
1537
+ # Update the dataframe
1538
+ self.df = df_sorted.drop(RIDX)
1494
1539
 
1495
- # Remove all columns before the current column
1496
- if more == "before":
1497
- for i in range(col_idx + 1):
1498
- col_key = self.get_column_key(i)
1499
- col_names_to_remove.append(col_key.value)
1500
- col_keys_to_remove.append(col_key)
1540
+ # Recreate table for display
1541
+ self.setup_table()
1501
1542
 
1502
- message = f"Removed column [$success]{col_name}[/] and all columns before"
1543
+ # Restore cursor position on the sorted column
1544
+ self.move_cursor(column=col_idx, row=0)
1503
1545
 
1504
- # Remove all columns after the current column
1505
- elif more == "after":
1506
- for i in range(col_idx, len(self.columns)):
1507
- col_key = self.get_column_key(i)
1508
- col_names_to_remove.append(col_key.value)
1509
- col_keys_to_remove.append(col_key)
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
- message = f"Removed column [$success]{col_name}[/] and all columns after"
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
- # Remove only the current column
1514
- else:
1515
- col_names_to_remove.append(col_name)
1516
- col_keys_to_remove.append(col_key)
1517
- message = f"Removed column [$success]{col_name}[/]"
1558
+ def edit_cell(self, result) -> None:
1559
+ """Handle result from EditCellScreen."""
1560
+ if result is None:
1561
+ return
1518
1562
 
1519
- # Add to history
1520
- self._add_history(message)
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
- # Remove the columns from the table display using the column names as keys
1523
- for ck in col_keys_to_remove:
1524
- self.remove_column(ck)
1571
+ col_name = self.df.columns[cidx]
1525
1572
 
1526
- # Move cursor left if we deleted the last column(s)
1527
- last_col_idx = len(self.columns) - 1
1528
- if col_idx > last_col_idx:
1529
- self.move_cursor(column=last_col_idx)
1573
+ # Add to history
1574
+ self.add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]", dirty=True)
1530
1575
 
1531
- # Remove from sorted columns if present
1532
- for col_name in col_names_to_remove:
1533
- if col_name in self.sorted_columns:
1534
- del self.sorted_columns[col_name]
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
- # Remove from matches
1537
- col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
1538
- for row_idx in list(self.matches.keys()):
1539
- self.matches[row_idx].difference_update(col_indices_to_remove)
1540
- # Remove empty entries
1541
- if not self.matches[row_idx]:
1542
- del self.matches[row_idx]
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
- # Remove from dataframe
1545
- self.df = self.df.drop(col_names_to_remove)
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
- self.notify(message, title="Delete")
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 _duplicate_column(self) -> None:
1550
- """Duplicate the currently selected column, inserting it right after the current column."""
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
- # Add the new column and reorder columns for insertion after current column
1565
- self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
1566
- list(cols_before) + [new_col_name] + list(cols_after)
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
- # Update matches to account for new column
1570
- new_matches = defaultdict(set)
1571
- for row_idx, cols in self.matches.items():
1572
- new_cols = set()
1573
- for col_idx_in_set in cols:
1574
- if col_idx_in_set <= cidx:
1575
- new_cols.add(col_idx_in_set)
1576
- else:
1577
- new_cols.add(col_idx_in_set + 1)
1578
- new_matches[row_idx] = new_cols
1579
- self.matches = new_matches
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
- # Recreate table for display
1582
- self._setup_table()
1619
+ col_name = self.df.columns[cidx]
1583
1620
 
1584
- # Move cursor to the new duplicated column
1585
- self.move_cursor(column=col_idx + 1)
1621
+ # Null case
1622
+ if term is None or term == NULL:
1623
+ expr = pl.lit(None)
1586
1624
 
1587
- # self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
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
- def _delete_row(self, more: str = None) -> None:
1590
- """Delete rows from the table and dataframe.
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
- Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
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
- # Delete all selected rows
1598
- if selected_count := self.selected_rows.count(True):
1599
- history_desc = f"Deleted {selected_count} selected row(s)"
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
- for ridx, selected in enumerate(self.selected_rows):
1602
- if selected:
1603
- predicates[ridx] = False
1663
+ # Recreate table for display
1664
+ self.setup_table()
1604
1665
 
1605
- # Delete current row and those above
1606
- elif more == "above":
1607
- ridx = self.cursor_row_idx
1608
- history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
1609
- for i in range(ridx + 1):
1610
- predicates[i] = False
1666
+ # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
1611
1667
 
1612
- # Delete current row and those below
1613
- elif more == "below":
1614
- ridx = self.cursor_row_idx
1615
- history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
1616
- for i in range(ridx, len(self.df)):
1617
- if self.visible_rows[i]:
1618
- predicates[i] = False
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
- # Delete the row at the cursor
1621
- else:
1622
- ridx = self.cursor_row_idx
1623
- history_desc = f"Deleted row [$success]{ridx + 1}[/]"
1624
- if self.visible_rows[ridx]:
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
- # Add to history
1628
- self._add_history(history_desc)
1679
+ def rename_column(self, result) -> None:
1680
+ """Handle result from RenameColumnScreen."""
1681
+ if result is None:
1682
+ return
1629
1683
 
1630
- # Apply the filter to remove rows
1631
- try:
1632
- df = self.df.with_row_index(RIDX).filter(predicates)
1633
- except Exception as e:
1634
- self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error")
1635
- self.histories.pop() # Remove last history entry
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
- self.df = df.drop(RIDX)
1692
+ # Add to history
1693
+ self.add_history(f"Renamed column [$accent]{col_name}[/] to [$success]{new_name}[/]", dirty=True)
1639
1694
 
1640
- # Update selected and visible rows tracking
1641
- old_row_indices = set(df[RIDX].to_list())
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
- # Clear all matches since row indices have changed
1646
- self.matches = defaultdict(set)
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._setup_table()
1708
+ self.setup_table()
1650
1709
 
1651
- deleted_count = old_count - len(self.df)
1652
- if deleted_count > 0:
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
- def _duplicate_row(self) -> None:
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
- # Get the row to duplicate
1660
- row_to_duplicate = self.df.slice(ridx, 1)
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._add_history(f"Duplicated row [$success]{ridx + 1}[/]")
1723
+ self.add_history(f"Cleared cell [$success]({ridx + 1}, {col_name})[/]", dirty=True)
1664
1724
 
1665
- # Concatenate: rows before + duplicated row + rows after
1666
- df_before = self.df.slice(0, ridx + 1)
1667
- df_after = self.df.slice(ridx + 1)
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
- # Combine the parts
1670
- self.df = pl.concat([df_before, row_to_duplicate, df_after])
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
- # Update selected and visible rows tracking to account for new row
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
- # Update matches to account for new row
1679
- new_matches = defaultdict(set)
1680
- for row_idx, cols in self.matches.items():
1681
- if row_idx <= ridx:
1682
- new_matches[row_idx] = cols
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
- new_matches[row_idx + 1] = cols
1685
- self.matches = new_matches
1770
+ new_col = pl.lit(col_value).alias(new_name)
1686
1771
 
1687
- # Recreate table for display
1688
- self._setup_table()
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
- # Move cursor to the new duplicated row
1691
- self.move_cursor(row=ridx + 1)
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
- # self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
1781
+ # Recreate table for display
1782
+ self.setup_table()
1694
1783
 
1695
- def _move_column(self, direction: str) -> None:
1696
- """Move the current column left or right.
1784
+ # Move cursor to the new column
1785
+ self.move_cursor(column=cidx + 1)
1697
1786
 
1698
- Args:
1699
- direction: "left" to move left, "right" to move right.
1700
- """
1701
- row_idx, col_idx = self.cursor_coordinate
1702
- col_key = self.cursor_col_key
1703
- col_name = col_key.value
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
- # Validate move is possible
1707
- if direction == "left":
1708
- if col_idx <= 0:
1709
- self.notify("Cannot move column left", title="Move", severity="warning")
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
- # Get column to swap
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._add_history(f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])")
1809
+ self.add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.", dirty=True)
1725
1810
 
1726
- # Swap columns in the table's internal column locations
1727
- self.check_idle()
1811
+ try:
1812
+ # Create the column
1813
+ new_col = expr.alias(new_col_name)
1728
1814
 
1729
- (
1730
- self._column_locations[col_key],
1731
- self._column_locations[swap_key],
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
- self._update_count += 1
1738
- self.refresh()
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
- # Restore cursor position on the moved column
1741
- self.move_cursor(row=row_idx, column=swap_idx)
1824
+ # Recreate table for display
1825
+ self.setup_table()
1742
1826
 
1743
- # Update the dataframe column order
1744
- cols = list(self.df.columns)
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
- # self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
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 _move_row(self, direction: str) -> None:
1751
- """Move the current row up or down.
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
- direction: "up" to move up, "down" to move down.
1854
+ result: Tuple of (cidx, new_col_name, link_template) or None if cancelled.
1755
1855
  """
1756
- row_idx, col_idx = self.cursor_coordinate
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
- row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
1774
- swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
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
- # Swap rows in the table's internal row locations
1782
- self.check_idle()
1864
+ try:
1865
+ # Hack to support PubChem link
1866
+ link_template = link_template.replace("PC", "pubchem.ncbi.nlm.nih.gov")
1783
1867
 
1784
- (
1785
- self._row_locations[row_key],
1786
- self._row_locations[swap_key],
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
- self._update_count += 1
1793
- self.refresh()
1872
+ # Parse template placeholders into Polars expressions
1873
+ parts = parse_placeholders(link_template, self.df.columns, cidx)
1794
1874
 
1795
- # Restore cursor position on the moved row
1796
- self.move_cursor(row=swap_idx, column=col_idx)
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
- # Swap rows in the dataframe
1799
- ridx = int(row_key.value) # 0-based
1800
- swap_ridx = int(swap_key.value) # 0-based
1801
- first, second = sorted([ridx, swap_ridx])
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
- self.df = pl.concat(
1804
- [
1805
- self.df.slice(0, first),
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
- # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
1888
+ # Recreate table for display
1889
+ self.setup_table()
1814
1890
 
1815
- # Sort
1816
- def _sort_by_column(self, descending: bool = False) -> None:
1817
- """Sort by the currently selected column.
1891
+ # Move cursor to the new column
1892
+ self.move_cursor(column=cidx + 1)
1818
1893
 
1819
- Supports multi-column sorting:
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
- Args:
1824
- descending: If True, sort in descending order. If False, ascending order.
1825
- """
1826
- col_name = self.cursor_col_name
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
- # Check if this column is already in the sort keys
1830
- old_desc = self.sorted_columns.get(col_name)
1907
+ col_names_to_remove = []
1908
+ col_keys_to_remove = []
1831
1909
 
1832
- # Add to history
1833
- self._add_history(f"Sorted on column [$success]{col_name}[/]")
1834
- if old_desc is None:
1835
- # Add new column to sort
1836
- self.sorted_columns[col_name] = descending
1837
- elif old_desc == descending:
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
- # Apply multi-column sort
1846
- if sort_cols := list(self.sorted_columns.keys()):
1847
- descending_flags = list(self.sorted_columns.values())
1848
- df_sorted = self.df.with_row_index(RIDX).sort(sort_cols, descending=descending_flags, nulls_last=True)
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
- # No sort columns - restore original order
1851
- df_sorted = self.df.with_row_index(RIDX)
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
- # Updated selected_rows and visible_rows to match new order
1854
- old_row_indices = df_sorted[RIDX].to_list()
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
- # Update the dataframe
1859
- self.df = df_sorted.drop(RIDX)
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
- # Recreate table for display
1862
- self._setup_table()
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
- # Restore cursor position on the sorted column
1865
- self.move_cursor(column=col_idx, row=0)
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
- # Edit
1868
- def _edit_cell(self, ridx: int = None, cidx: int = None) -> None:
1869
- """Open modal to edit the selected cell."""
1870
- ridx = self.cursor_row_idx if ridx is None else ridx
1871
- cidx = self.cursor_col_idx if cidx is None else cidx
1872
- col_name = self.df.columns[cidx]
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
- # Add to history
1875
- self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
1959
+ # Remove from dataframe
1960
+ self.df = self.df.drop(col_names_to_remove)
1876
1961
 
1877
- # Push the edit modal screen
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 _do_edit_cell(self, result) -> None:
1884
- """Handle result from EditCellScreen."""
1885
- if result is None:
1886
- return
1964
+ def do_duplicate_column(self) -> None:
1965
+ """Duplicate the currently selected column, inserting it right after the current column."""
1966
+ cidx = self.cursor_col_idx
1967
+ col_name = self.cursor_col_name
1887
1968
 
1888
- ridx, cidx, new_value = result
1889
- if new_value is None:
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
- col_name = self.df.columns[cidx]
1972
+ # Add to history
1973
+ self.add_history(f"Duplicated column [$success]{col_name}[/]", dirty=True)
1897
1974
 
1898
- # Update the cell in the dataframe
1899
- try:
1900
- self.df = self.df.with_columns(
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
- # Update the display
1908
- cell_value = self.df.item(ridx, cidx)
1909
- if cell_value is None:
1910
- cell_value = NULL_DISPLAY
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
- # string as keys
1916
- row_key = str(ridx)
1917
- col_key = col_name
1918
- self.update_cell(row_key, col_key, formatted_value, update_width=True)
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
- # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1921
- except Exception as e:
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
- def _edit_column(self) -> None:
1926
- """Open modal to edit the entire column with an expression."""
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
- # Push the edit column modal screen
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 _do_edit_column(self, result) -> None:
1936
- """Edit a column."""
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
- col_name = self.df.columns[cidx]
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
- # Null case
1944
- if term is None or term == NULL:
1945
- expr = pl.lit(None)
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
- # Check if term is a valid expression
1948
- elif tentative_expr(term):
1949
- try:
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
- # Otherwise, treat term as a literal value
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
- dtype = self.df.dtypes[cidx]
1959
- try:
1960
- value = DtypeConfig(dtype).convert(term)
1961
- expr = pl.lit(value)
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._add_history(f"Edited column [$accent]{col_name}[/] with expression")
2043
+ self.add_history(history_desc, dirty=True)
1972
2044
 
2045
+ # Apply the filter to remove rows
1973
2046
  try:
1974
- # Apply the expression to the column
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
- f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
1979
- title="Edit",
1980
- severity="error",
1981
- )
1982
- self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
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
- # Recreate table for display
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
- def _do_rename_column(self, result) -> None:
2002
- """Handle result from RenameColumnScreen."""
2003
- if result is None:
2004
- return
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
- col_idx, col_name, new_name = result
2007
- if new_name is None:
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
- # Add to history
2015
- self._add_history(f"Renamed column [$accent]{col_name}[/] to [$success]{new_name}[/]")
2063
+ # Recreate table for display
2064
+ self.setup_table()
2016
2065
 
2017
- # Rename the column in the dataframe
2018
- self.df = self.df.rename({col_name: new_name})
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
- # Update sorted_columns if this column was sorted
2021
- if col_name in self.sorted_columns:
2022
- self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
2070
+ def do_duplicate_row(self) -> None:
2071
+ """Duplicate the currently selected row, inserting it right after the current row."""
2072
+ ridx = self.cursor_row_idx
2023
2073
 
2024
- # Update hidden_columns if this column was hidden
2025
- if col_name in self.hidden_columns:
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
- # Recreate table for display
2030
- self._setup_table()
2077
+ # Add to history
2078
+ self.add_history(f"Duplicated row [$success]{ridx + 1}[/]", dirty=True)
2031
2079
 
2032
- # Move cursor to the renamed column
2033
- self.move_cursor(column=col_idx)
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
- # self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
2084
+ # Combine the parts
2085
+ self.df = pl.concat([df_before, row_to_duplicate, df_after])
2036
2086
 
2037
- def _clear_cell(self) -> None:
2038
- """Clear the current cell by setting its value to None."""
2039
- row_key, col_key = self.cursor_key
2040
- ridx = self.cursor_row_idx
2041
- cidx = self.cursor_col_idx
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
- # Add to history
2045
- self._add_history(f"Cleared cell [$success]({ridx + 1}, {col_name})[/]")
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
- # Update the cell to None in the dataframe
2048
- try:
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
- # Update the display
2057
- dtype = self.df.dtypes[cidx]
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
- self.update_cell(row_key, col_key, formatted_value)
2108
+ # self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
2062
2109
 
2063
- # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
2064
- except Exception as e:
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
- def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
2070
- """Add acolumn after the current column."""
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
- if not col_name:
2074
- # Generate a unique column name
2075
- base_name = "new_col"
2076
- new_name = base_name
2077
- counter = 1
2078
- while new_name in self.df.columns:
2079
- new_name = f"{base_name}_{counter}"
2080
- counter += 1
2081
- else:
2082
- new_name = col_name
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._add_history(f"Added column [$success]{new_name}[/] after column {cidx + 1}")
2139
+ self.add_history(
2140
+ f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])", dirty=True
2141
+ )
2086
2142
 
2087
- try:
2088
- # Create an empty column (all None values)
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
- # Get columns up to current, the new column, then remaining columns
2095
- cols = self.df.columns
2096
- cols_before = cols[: cidx + 1]
2097
- cols_after = cols[cidx + 1 :]
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
- # Build the new dataframe with columns reordered
2100
- select_cols = cols_before + [new_name] + cols_after
2101
- self.df = self.df.with_columns(new_col).select(select_cols)
2154
+ self._update_count += 1
2155
+ self.refresh()
2102
2156
 
2103
- # Recreate table for display
2104
- self._setup_table()
2157
+ # Restore cursor position on the moved column
2158
+ self.move_cursor(row=row_idx, column=swap_idx)
2105
2159
 
2106
- # Move cursor to the new column
2107
- self.move_cursor(column=cidx + 1)
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
- # self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
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 _add_column_expr(self) -> None:
2116
- """Open screen to add a new column with optional expression."""
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
- def _do_add_column_expr(self, result: tuple[int, str, str, pl.Expr] | None) -> None:
2124
- """Add a new column with an expression."""
2125
- if result is None:
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
- cidx, col_name, expr = result
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._add_history(f"Added column [$success]{col_name}[/] with expression {expr}.")
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
- try:
2134
- # Create the column
2135
- new_col = expr.alias(col_name)
2199
+ # Swap rows in the table's internal row locations
2200
+ self.check_idle()
2136
2201
 
2137
- # Get columns up to current, the new column, then remaining columns
2138
- cols = self.df.columns
2139
- cols_before = cols[: cidx + 1]
2140
- cols_after = cols[cidx + 1 :]
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
- # Build the new dataframe with columns reordered
2143
- select_cols = cols_before + [col_name] + cols_after
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
- # Recreate table for display
2147
- self._setup_table()
2213
+ # Restore cursor position on the moved row
2214
+ self.move_cursor(row=swap_idx, column=col_idx)
2148
2215
 
2149
- # Move cursor to the new column
2150
- self.move_cursor(column=cidx + 1)
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
- # self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
2153
- except Exception as e:
2154
- self.notify("Error adding column", title="Add Column", severity="error")
2155
- self.log(f"Error adding column `{col_name}`: {str(e)}")
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 Casting
2158
- def _cast_column_dtype(self, dtype: str) -> None:
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._add_history(
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._setup_table()
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 _search_cursor_value(self) -> None:
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._do_search((term, cidx, False, True))
2288
+ self.search((term, cidx, False, True))
2212
2289
 
2213
- def _search_expr(self) -> None:
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._do_search,
2300
+ callback=self.search,
2224
2301
  )
2225
2302
 
2226
- def _do_search(self, result) -> None:
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._add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
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._setup_table()
2381
+ self.setup_table()
2305
2382
 
2306
2383
  # Find
2307
- def _find_matches(
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 _find_cursor_value(self, scope="column") -> None:
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._do_find((term, cidx, False, True))
2457
+ self.find((term, cidx, False, True))
2381
2458
  else:
2382
- self._do_find_global((term, None, False, True))
2459
+ self.find_global((term, None, False, True))
2383
2460
 
2384
- def _find_expr(self, scope="column") -> None:
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._do_find if scope == "column" else self._do_find_global,
2474
+ callback=self.find if scope == "column" else self.find_global,
2398
2475
  )
2399
2476
 
2400
- def _do_find(self, result) -> None:
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._find_matches(term, cidx, match_nocase, match_whole)
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._add_history(f"Found [$accent]{term}[/] in column [$success]{col_name}[/]")
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._setup_table()
2511
+ self.setup_table()
2435
2512
 
2436
- def _do_find_global(self, result) -> None:
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._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
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._add_history(f"Found [$success]{term}[/] across all columns")
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._setup_table()
2547
+ self.setup_table()
2471
2548
 
2472
- def _next_match(self) -> None:
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 _previous_match(self) -> None:
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 _next_selected_row(self) -> None:
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 _previous_selected_row(self) -> None:
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 _replace(self) -> None:
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._do_replace,
2649
+ callback=self.replace,
2573
2650
  )
2574
2651
 
2575
- def _do_replace(self, result) -> None:
2652
+ def replace(self, result) -> None:
2576
2653
  """Handle replace in current column."""
2577
- self._handle_replace(result, self.cursor_col_idx)
2654
+ self.handle_replace(result, self.cursor_col_idx)
2578
2655
 
2579
- def _replace_global(self) -> None:
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._do_replace_global,
2661
+ callback=self.replace_global,
2585
2662
  )
2586
2663
 
2587
- def _do_replace_global(self, result) -> None:
2664
+ def replace_global(self, result) -> None:
2588
2665
  """Handle replace across all columns."""
2589
- self._handle_replace(result, None)
2666
+ self.handle_replace(result, None)
2590
2667
 
2591
- def _handle_replace(self, result, cidx) -> None:
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._find_matches(term_find, cidx, match_nocase, match_whole)
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._add_history(
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._setup_table()
2700
+ self.setup_table()
2624
2701
 
2625
2702
  # Store state for interactive replacement using dataclass
2626
- self._replace_state = ReplaceState(
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=sorted(list(self.matches.keys())),
2633
- cols_per_row=[sorted(list(self.matches[ridx])) for ridx in sorted(self.matches.keys())],
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._do_replace_all(term_find, term_replace)
2724
+ self.replace_all(term_find, term_replace)
2647
2725
  else:
2648
2726
  # Replace with confirmation for each occurrence
2649
- self._do_replace_interactive(term_find, term_replace)
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 _do_replace_all(self, term_find: str, term_replace: str) -> None:
2737
+ def replace_all(self, term_find: str, term_replace: str) -> None:
2660
2738
  """Replace all occurrences."""
2661
- state = self._replace_state
2739
+ state = self.replace_state
2662
2740
  self.app.push_screen(
2663
2741
  ConfirmScreen(
2664
2742
  "Replace All",
2665
- label=f"Replace [$accent]{term_find}[/] with [$success]{term_replace}[/] for all [$accent]{state.total_occurrence}[/] occurrences?",
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._handle_replace_all_confirmation,
2745
+ callback=self.handle_replace_all_confirmation,
2668
2746
  )
2669
2747
 
2670
- def _handle_replace_all_confirmation(self, result) -> None:
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._replace_state
2753
+ state = self.replace_state
2676
2754
  rows = state.rows
2677
2755
  cols_per_row = state.cols_per_row
2678
2756
 
2679
- # Replace in each matched row/column
2680
- for ridx, col_idxs in zip(rows, cols_per_row):
2681
- for cidx in col_idxs:
2682
- col_name = self.df.columns[cidx]
2683
- dtype = self.df.dtypes[cidx]
2684
-
2685
- # Only applicable to string columns for substring matches
2686
- if dtype == pl.String and not state.match_whole:
2687
- term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
2688
- self.df = self.df.with_columns(
2689
- pl.when(pl.arange(0, len(self.df)) == ridx)
2690
- .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
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
- state.replaced_occurrence += 1
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._setup_table()
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 _do_replace_interactive(self, term_find: str, term_replace: str) -> None:
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._show_next_replace_confirmation()
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 _show_next_replace_confirmation(self) -> None:
2826
+ def show_next_replace_confirmation(self) -> None:
2733
2827
  """Show confirmation for next replacement."""
2734
- state = self._replace_state
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._handle_replace_confirmation,
2854
+ callback=self.handle_replace_confirmation,
2757
2855
  )
2758
2856
 
2759
- def _handle_replace_confirmation(self, result) -> None:
2857
+ def handle_replace_confirmation(self, result) -> None:
2760
2858
  """Handle user's confirmation response."""
2761
- state = self._replace_state
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
- # Move to next
2808
- if state.current_cpos + 1 < len(state.cols_per_row[state.current_rpos]):
2809
- state.current_cpos += 1
2810
- else:
2811
- state.current_cpos = 0
2812
- state.current_rpos += 1
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
- if state.current_rpos >= len(state.rows):
2815
- state.done = True
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
- # Recreate table for display
2818
- self._setup_table()
2919
+ if state.current_rpos >= len(state.rows):
2920
+ state.done = True
2819
2921
 
2820
2922
  # Show next confirmation
2821
- self._show_next_replace_confirmation()
2923
+ self.show_next_replace_confirmation()
2822
2924
 
2823
2925
  # Selection & Match
2824
- def _toggle_selections(self) -> None:
2926
+ def do_toggle_selections(self) -> None:
2825
2927
  """Toggle selected rows highlighting on/off."""
2826
2928
  # Add to history
2827
- self._add_history("Toggled row selection")
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._setup_table()
2947
+ self.setup_table()
2846
2948
 
2847
- def _toggle_row_selection(self) -> None:
2949
+ def do_toggle_row_selection(self) -> None:
2848
2950
  """Select/deselect current row."""
2849
2951
  # Add to history
2850
- self._add_history("Toggled row selection")
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 _clear_selections_and_matches(self) -> None:
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._add_history("Cleared all selected rows")
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._setup_table()
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 _filter_rows(self) -> None:
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._add_history("Filtered to selections and matches")
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._setup_table()
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 _view_rows(self) -> None:
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._do_view_rows((term, cidx, False, True))
3048
+ self.view_rows((term, cidx, False, True))
2947
3049
 
2948
- def _view_rows_expr(self) -> None:
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._do_view_rows,
3058
+ callback=self.view_rows,
2957
3059
  )
2958
3060
 
2959
- def _do_view_rows(self, result) -> None:
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]{expr}[/]", title="Filter", severity="error")
3015
- self.log(f"Error applying filter `{expr}`: {str(e)}")
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._add_history(f"Filtered by expression [$success]{expr}[/]")
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._setup_table()
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 _copy_to_clipboard(self, content: str, message: str) -> None:
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 _save_to_file(self) -> None:
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.app.push_screen(SaveFileScreen(self.filename), callback=self._do_save_file)
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 _do_save_file(self, filename: str | None, all_tabs: bool = False) -> None:
3186
+ def save_to_file(self, result) -> None:
3067
3187
  """Handle result from SaveFileScreen."""
3068
- if filename is None:
3188
+ if result is None:
3069
3189
  return
3070
- filepath = Path(filename)
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 filepath.exists():
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._on_overwrite_screen,
3200
+ callback=self.confirm_overwrite,
3082
3201
  )
3083
- elif ext in (".xlsx", ".xls"):
3084
- self._do_save_excel(filename)
3085
3202
  else:
3086
- self._do_save(filename)
3203
+ self.save_file(filename)
3087
3204
 
3088
- def _on_overwrite_screen(self, should_overwrite: bool) -> None:
3205
+ def confirm_overwrite(self, should_overwrite: bool) -> None:
3089
3206
  """Handle result from ConfirmScreen."""
3090
3207
  if should_overwrite:
3091
- self._do_save(self._pending_filename)
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._do_save_file,
3213
+ callback=self.save_to_file,
3097
3214
  )
3098
3215
 
3099
- def _do_save(self, filename: str) -> None:
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._add_history(f"Saved dataframe to [$success]{filename}[/]")
3222
+ self.add_history(f"Saved dataframe to [$success]{filename}[/]")
3106
3223
 
3107
3224
  try:
3108
3225
  if ext in (".xlsx", ".xls"):
3109
- self._do_save_excel(filename)
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
- if not self._all_tabs:
3122
- extra = "current tab with " if len(self.app.tabs) > 1 else ""
3123
- self.notify(f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save")
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 _do_save_excel(self, filename: str) -> None:
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 tab, table in tabs.items():
3140
- worksheet = wb.add_worksheet(tab.name)
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 _simple_sql(self) -> None:
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._do_simple_sql,
3282
+ callback=self.simple_sql,
3157
3283
  )
3158
3284
 
3159
- def _do_simple_sql(self, result) -> None:
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._do_sql(sql)
3295
+ self.run_sql(sql, view)
3170
3296
 
3171
- def _advanced_sql(self) -> None:
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._do_advanced_sql,
3301
+ callback=self.advanced_sql,
3176
3302
  )
3177
3303
 
3178
- def _do_advanced_sql(self, result) -> None:
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._do_sql(result)
3310
+ self.run_sql(sql, view)
3184
3311
 
3185
- def _do_sql(self, sql: str) -> None:
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
- # Add to history
3192
- self._add_history(f"SQL Query:\n[$accent]{sql}[/]")
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
- self.df = self.df.sql(sql)
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._setup_table()
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.",