dataframe-textual 1.5.0__py3-none-any.whl → 1.9.0__py3-none-any.whl

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