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

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