dataframe-textual 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,8 +104,12 @@ class DataFrameTable(DataTable):
104
104
  - **F** - 📊 Show frequency distribution
105
105
  - **s** - 📈 Show statistics for current column
106
106
  - **S** - 📊 Show statistics for entire dataframe
107
- - **K** - 🔄 Cycle cursor (cell → row → column → cell)
107
+ - **h** - 👁️ Hide current column
108
+ - **H** - 👀 Show all hidden rows/columns
109
+ - **z** - 📌 Freeze rows and columns
108
110
  - **~** - 🏷️ Toggle row labels
111
+ - **,** - 🔢 Toggle thousand separator for numeric display
112
+ - **K** - 🔄 Cycle cursor (cell → row → column → cell)
109
113
 
110
114
  ## ↕️ Sorting
111
115
  - **[** - 🔼 Sort column ascending
@@ -136,7 +140,7 @@ class DataFrameTable(DataTable):
136
140
  - **{** - ⬆️ Go to previous selected row
137
141
  - **}** - ⬇️ Go to next selected row
138
142
  - **"** - 📍 Filter to show only selected rows
139
- - **T** - 🧹 Clear all selections
143
+ - **T** - 🧹 Clear all selections and matches
140
144
 
141
145
  ## ✏️ Edit & Modify
142
146
  - **Double-click** - ✍️ Edit cell or rename column header
@@ -144,13 +148,16 @@ class DataFrameTable(DataTable):
144
148
  - **E** - 📊 Edit entire column with expression
145
149
  - **a** - ➕ Add empty column after current
146
150
  - **A** - ➕ Add column with name and optional expression
147
- - **x** - 🗑️ Delete current row
148
- - **X** - Clear current cell (set to None)
149
- - **D** - 📋 Duplicate current row
151
+ - **x** - Delete current row
152
+ - **X** - Delete row and those below
153
+ - **Ctrl+X** - Delete row and those above
154
+ - **delete** - ❌ Clear current cell (set to NULL)
150
155
  - **-** - ❌ Delete current column
156
+ - **_** - ❌ Delete column and those after
157
+ - **Ctrl+-** - ❌ Delete column and those before
151
158
  - **d** - 📋 Duplicate current column
152
- - **h** - 👁️ Hide current column
153
- - **H** - 👀 Show all hidden columns
159
+ - **D** - 📋 Duplicate current row
160
+
154
161
 
155
162
  ## 🎯 Reorder
156
163
  - **Shift+↑↓** - ⬆️⬇️ Move row up/down
@@ -166,32 +173,39 @@ class DataFrameTable(DataTable):
166
173
  - **@** - 🔗 Make URLs in current column clickable with Ctrl/Cmd
167
174
 
168
175
  ## 💾 Data Management
169
- - **z** - 📌 Freeze rows and columns
170
- - **,** - 🔢 Toggle thousand separator for numeric display
171
176
  - **c** - 📋 Copy cell to clipboard
172
177
  - **Ctrl+c** - 📊 Copy column to clipboard
173
178
  - **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
174
179
  - **Ctrl+s** - 💾 Save current tab to file
175
180
  - **u** - ↩️ Undo last action
176
- - **U** - 🔄 Reset to original data
181
+ - **U** - 🔄 Redo last undone action
182
+ - **Ctrl+U** - 🔁 Reset to initial state
177
183
  """).strip()
178
184
 
179
185
  # fmt: off
180
186
  BINDINGS = [
187
+ # Navigation
181
188
  ("g", "jump_top", "Jump to top"),
182
189
  ("G", "jump_bottom", "Jump to bottom"),
190
+ # Display
183
191
  ("h", "hide_column", "Hide column"),
184
- ("H", "show_column", "Show columns"),
192
+ ("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
193
+ ("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
194
+ ("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
195
+ ("z", "freeze_row_column", "Freeze rows/columns"),
196
+ ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
197
+ # Copy
185
198
  ("c", "copy_cell", "Copy cell to clipboard"),
186
199
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
187
200
  ("ctrl+r", "copy_row", "Copy row to clipboard"),
201
+ # Save
188
202
  ("ctrl+s", "save_to_file", "Save to file"),
203
+ # Detail, Frequency, and Statistics
189
204
  ("enter", "view_row_detail", "View row details"),
190
- # Frequency & Statistics
191
205
  ("F", "show_frequency", "Show frequency"),
192
206
  ("s", "show_statistics", "Show statistics for column"),
193
207
  ("S", "show_statistics('dataframe')", "Show statistics for dataframe"),
194
- # Sorting
208
+ # Sort
195
209
  ("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
196
210
  ("right_square_bracket", "sort_descending", "Sort descending"), # `]`
197
211
  # View
@@ -215,16 +229,23 @@ class DataFrameTable(DataTable):
215
229
  # Selection
216
230
  ("apostrophe", "make_selections", "Toggle row selection"), # `'`
217
231
  ("t", "toggle_selections", "Toggle all row selections"),
218
- ("T", "clear_selections", "Clear selections"),
232
+ ("T", "clear_selections_and_matches", "Clear selections"),
219
233
  ("quotation_mark", "filter_selected_rows", "Filter selected"), # `"`
220
- # Edit
234
+ # Delete
235
+ ("delete", "clear_cell", "Clear cell"),
221
236
  ("minus", "delete_column", "Delete column"), # `-`
237
+ ("underscore", "delete_column_and_after", "Delete column and those after"), # `_`
238
+ ("ctrl+minus", "delete_column_and_before", "Delete column and those before"), # `Ctrl+-`
222
239
  ("x", "delete_row", "Delete row"),
223
- ("X", "clear_cell", "Clear cell"),
240
+ ("X", "delete_row_and_below", "Delete row and those below"),
241
+ ("ctrl+x", "delete_row_and_up", "Delete row and those up"),
242
+ # Duplicate
224
243
  ("d", "duplicate_column", "Duplicate column"),
225
244
  ("D", "duplicate_row", "Duplicate row"),
245
+ # Edit
226
246
  ("e", "edit_cell", "Edit cell"),
227
247
  ("E", "edit_column", "Edit column"),
248
+ # Add
228
249
  ("a", "add_column", "Add column"),
229
250
  ("A", "add_column_expr", "Add column with expression"),
230
251
  # Reorder
@@ -238,14 +259,10 @@ class DataFrameTable(DataTable):
238
259
  ("exclamation_mark", "cast_column_dtype('bool')", "Cast column dtype to bool"), # `!`
239
260
  ("dollar_sign", "cast_column_dtype('string')", "Cast column dtype to string"), # `$`
240
261
  ("at", "make_cell_clickable", "Make cell clickable"), # `@`
241
- # Misc
242
- ("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
243
- ("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
244
- ("z", "freeze_row_column", "Freeze rows/columns"),
245
- ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
246
262
  # Undo/Redo
247
263
  ("u", "undo", "Undo"),
248
- ("U", "reset", "Reset to original"),
264
+ ("U", "redo", "Redo"),
265
+ ("ctrl+u", "reset", "Reset to initial state"),
249
266
  ]
250
267
  # fmt: on
251
268
 
@@ -287,8 +304,10 @@ class DataFrameTable(DataTable):
287
304
  self.fixed_rows = 0 # Number of fixed rows
288
305
  self.fixed_columns = 0 # Number of fixed columns
289
306
 
290
- # History stack for undo/redo
307
+ # History stack for undo
291
308
  self.histories: deque[History] = deque()
309
+ # Current history state for redo
310
+ self.history: History = None
292
311
 
293
312
  # Pending filename for save operations
294
313
  self._pending_filename = ""
@@ -391,17 +410,27 @@ class DataFrameTable(DataTable):
391
410
  matches.append((ridx, cidx))
392
411
  return matches
393
412
 
394
- def on_mount(self) -> None:
395
- """Initialize table display when the widget is mounted.
413
+ def get_row_key(self, row_idx: int) -> RowKey:
414
+ """Get the row key for a given table row index.
396
415
 
397
- Called by Textual when the widget is first added to the display tree.
398
- Currently a placeholder as table setup is deferred until first use.
416
+ Args:
417
+ row_idx: Row index in the table display.
399
418
 
400
419
  Returns:
401
- None
420
+ Corresponding row key as string.
402
421
  """
403
- # self._setup_table()
404
- pass
422
+ return self._row_locations.get_key(row_idx)
423
+
424
+ def get_column_key(self, col_idx: int) -> ColumnKey:
425
+ """Get the column key for a given table column index.
426
+
427
+ Args:
428
+ col_idx: Column index in the table display.
429
+
430
+ Returns:
431
+ Corresponding column key as string.
432
+ """
433
+ return self._column_locations.get_key(col_idx)
405
434
 
406
435
  def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
407
436
  """Determine if the given cell should be highlighted because of the cursor.
@@ -475,10 +504,10 @@ class DataFrameTable(DataTable):
475
504
  self.refresh_row(new_row)
476
505
  elif self.cursor_type == "row":
477
506
  self.refresh_row(old_coordinate.row)
478
- self.refresh_row(new_coordinate.row)
507
+ self._highlight_row(new_coordinate.row)
479
508
  elif self.cursor_type == "column":
480
509
  self.refresh_column(old_coordinate.column)
481
- self.refresh_column(new_coordinate.column)
510
+ self._highlight_column(new_coordinate.column)
482
511
 
483
512
  # Handle scrolling if needed
484
513
  if self._require_update_dimensions:
@@ -486,6 +515,30 @@ class DataFrameTable(DataTable):
486
515
  else:
487
516
  self._scroll_cursor_into_view()
488
517
 
518
+ def move_cursor_to(self, ridx: int, cidx: int) -> None:
519
+ """Move cursor based on the dataframe indices.
520
+
521
+ Args:
522
+ ridx: Row index (0-based) in the dataframe.
523
+ cidx: Column index (0-based) in the dataframe.
524
+ """
525
+ row_key = str(ridx)
526
+ col_key = self.df.columns[cidx]
527
+ row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
528
+ self.move_cursor(row=row_idx, column=col_idx)
529
+
530
+ def on_mount(self) -> None:
531
+ """Initialize table display when the widget is mounted.
532
+
533
+ Called by Textual when the widget is first added to the display tree.
534
+ Currently a placeholder as table setup is deferred until first use.
535
+
536
+ Returns:
537
+ None
538
+ """
539
+ # self._setup_table()
540
+ pass
541
+
489
542
  def on_key(self, event) -> None:
490
543
  """Handle key press events for pagination.
491
544
 
@@ -544,13 +597,21 @@ class DataFrameTable(DataTable):
544
597
  """Delete the current column."""
545
598
  self._delete_column()
546
599
 
600
+ def action_delete_column_and_after(self) -> None:
601
+ """Delete the current column and those after."""
602
+ self._delete_column(more="after")
603
+
604
+ def action_delete_column_and_before(self) -> None:
605
+ """Delete the current column and those before."""
606
+ self._delete_column(more="before")
607
+
547
608
  def action_hide_column(self) -> None:
548
609
  """Hide the current column."""
549
610
  self._hide_column()
550
611
 
551
- def action_show_column(self) -> None:
552
- """Show all hidden columns."""
553
- self._show_column()
612
+ def action_show_hidden_rows_columns(self) -> None:
613
+ """Show all hidden rows/columns."""
614
+ self._show_hidden_rows_columns()
554
615
 
555
616
  def action_sort_ascending(self) -> None:
556
617
  """Sort by current column in ascending order."""
@@ -656,6 +717,14 @@ class DataFrameTable(DataTable):
656
717
  """Delete the current row."""
657
718
  self._delete_row()
658
719
 
720
+ def action_delete_row_and_below(self) -> None:
721
+ """Delete the current row and those below."""
722
+ self._delete_row(more="below")
723
+
724
+ def action_delete_row_and_up(self) -> None:
725
+ """Delete the current row and those above."""
726
+ self._delete_row(more="above")
727
+
659
728
  def action_duplicate_column(self) -> None:
660
729
  """Duplicate the current column."""
661
730
  self._duplicate_column()
@@ -668,10 +737,14 @@ class DataFrameTable(DataTable):
668
737
  """Undo the last action."""
669
738
  self._undo()
670
739
 
740
+ def action_redo(self) -> None:
741
+ """Redo the last undone action."""
742
+ self._redo()
743
+
671
744
  def action_reset(self) -> None:
672
- """Reset to the original data."""
745
+ """Reset to the initial state."""
673
746
  self._setup_table(reset=True)
674
- self.notify("Restored original display", title="Reset")
747
+ self.notify("Restored initial state", title="Reset")
675
748
 
676
749
  def action_move_column_left(self) -> None:
677
750
  """Move the current column to the left."""
@@ -689,9 +762,9 @@ class DataFrameTable(DataTable):
689
762
  """Move the current row down."""
690
763
  self._move_row("down")
691
764
 
692
- def action_clear_selections(self) -> None:
693
- """Clear all row selections."""
694
- self._clear_selections()
765
+ def action_clear_selections_and_matches(self) -> None:
766
+ """Clear all row selections and matches."""
767
+ self._clear_selections_and_matches()
695
768
 
696
769
  def action_cycle_cursor_type(self) -> None:
697
770
  """Cycle through cursor types."""
@@ -781,41 +854,6 @@ class DataFrameTable(DataTable):
781
854
  """Go to the previous selected row."""
782
855
  self._previous_selected_row()
783
856
 
784
- def _make_cell_clickable(self) -> None:
785
- """Make cells with URLs in the current column clickable.
786
-
787
- Scans all loaded rows in the current column for cells containing URLs
788
- (starting with 'http://' or 'https://') and applies Textual link styling
789
- to make them clickable. Does not modify the dataframe.
790
-
791
- Returns:
792
- None
793
- """
794
- cidx = self.cursor_col_idx
795
- col_key = self.cursor_col_key
796
- dtype = self.df.dtypes[cidx]
797
-
798
- # Only process string columns
799
- if dtype != pl.String:
800
- return
801
-
802
- # Count how many URLs were made clickable
803
- url_count = 0
804
-
805
- # Iterate through all loaded rows and make URLs clickable
806
- for row in self.ordered_rows:
807
- cell_text: Text = self.get_cell(row.key, col_key)
808
- if cell_text.plain.startswith(("http://", "https://")):
809
- cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
810
- self.update_cell(row.key, col_key, cell_text)
811
- url_count += 1
812
-
813
- if url_count:
814
- self.notify(
815
- f"Made [$accent]{url_count}[/] cell(s) clickable in column [$success]{col_key.value}[/]",
816
- title="Make Clickable",
817
- )
818
-
819
857
  def on_mouse_scroll_down(self, event) -> None:
820
858
  """Load more rows when scrolling down with mouse."""
821
859
  self._check_and_load_more()
@@ -909,6 +947,7 @@ class DataFrameTable(DataTable):
909
947
  for row_idx, row in enumerate(df_slice.rows(), start):
910
948
  if not self.visible_rows[row_idx]:
911
949
  continue # Skip hidden rows
950
+
912
951
  vals, dtypes = [], []
913
952
  for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
914
953
  if col in self.hidden_columns:
@@ -916,6 +955,7 @@ class DataFrameTable(DataTable):
916
955
  vals.append(val)
917
956
  dtypes.append(dtype)
918
957
  formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
958
+
919
959
  # Always add labels so they can be shown/hidden via CSS
920
960
  self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
921
961
 
@@ -937,51 +977,59 @@ class DataFrameTable(DataTable):
937
977
  if bottom_visible_row >= self.loaded_rows - 10:
938
978
  self._load_rows(self.loaded_rows + self.BATCH_SIZE)
939
979
 
940
- def _do_highlight(self, clear: bool = False) -> None:
980
+ def _do_highlight(self, force: bool = False) -> None:
941
981
  """Update all rows, highlighting selected ones and restoring others to default.
942
982
 
943
983
  Args:
944
- clear: If True, clear all highlights.
984
+ force: If True, clear all highlights and restore default styles.
945
985
  """
946
- if clear:
947
- self.selected_rows = [False] * len(self.df)
948
- self.matches = defaultdict(set)
949
-
950
986
  # Ensure all selected rows or matches are loaded
951
987
  stop = rindex(self.selected_rows, True) + 1
952
988
  stop = max(stop, max(self.matches.keys(), default=0) + 1)
953
989
 
954
990
  self._load_rows(stop)
955
- self._highlight_table()
991
+ self._highlight_table(force)
956
992
 
957
- def _highlight_table(self) -> None:
993
+ def _highlight_table(self, force: bool = False) -> None:
958
994
  """Highlight selected rows/cells in red."""
995
+ if not force and not any(self.selected_rows) and not self.matches:
996
+ return # Nothing to highlight
997
+
959
998
  # Update all rows based on selected state
960
999
  for row in self.ordered_rows:
961
- row_idx = int(row.key.value) # 0-based index
962
- is_selected = self.selected_rows[row_idx]
963
- match_cols = self.matches.get(row_idx, set())
1000
+ ridx = int(row.key.value) # 0-based index
1001
+ is_selected = self.selected_rows[ridx]
1002
+ match_cols = self.matches.get(ridx, set())
1003
+
1004
+ if not force and not is_selected and not match_cols:
1005
+ continue # No highlight needed for this row
964
1006
 
965
1007
  # Update all cells in this row
966
1008
  for col_idx, col in enumerate(self.ordered_columns):
967
- cell_text: Text = self.get_cell(row.key, col.key)
1009
+ if not force and not is_selected and col_idx not in match_cols:
1010
+ continue # No highlight needed for this cell
968
1011
 
969
- # Get style config based on dtype
970
- dtype = self.df.dtypes[col_idx]
971
- dc = DtypeConfig(dtype)
972
- cell_text.style = "red" if is_selected or col_idx in match_cols else dc.style
1012
+ cell_text: Text = self.get_cell(row.key, col.key)
1013
+ need_update = False
1014
+
1015
+ if is_selected or col_idx in match_cols:
1016
+ cell_text.style = "red"
1017
+ need_update = True
1018
+ elif force:
1019
+ # Restore original style based on dtype
1020
+ dtype = self.df.schema[col.key.value]
1021
+ dc = DtypeConfig(dtype)
1022
+ cell_text.style = dc.style
1023
+ need_update = True
973
1024
 
974
1025
  # Update the cell in the table
975
- self.update_cell(row.key, col.key, cell_text)
1026
+ if need_update:
1027
+ self.update_cell(row.key, col.key, cell_text)
976
1028
 
977
1029
  # History & Undo
978
- def _add_history(self, description: str) -> None:
979
- """Add the current state to the history stack.
980
-
981
- Args:
982
- description: Description of the action for this history entry.
983
- """
984
- history = History(
1030
+ def _create_history(self, description: str) -> None:
1031
+ """Create the initial history state."""
1032
+ return History(
985
1033
  description=description,
986
1034
  df=self.df,
987
1035
  filename=self.filename,
@@ -995,16 +1043,12 @@ class DataFrameTable(DataTable):
995
1043
  cursor_coordinate=self.cursor_coordinate,
996
1044
  matches={k: v.copy() for k, v in self.matches.items()},
997
1045
  )
998
- self.histories.append(history)
999
1046
 
1000
- def _undo(self) -> None:
1001
- """Undo the last action."""
1002
- if not self.histories:
1003
- self.notify("No actions to undo", title="Undo", severity="warning")
1047
+ def _apply_history(self, history: History) -> None:
1048
+ """Apply the current history state to the table."""
1049
+ if history is None:
1004
1050
  return
1005
1051
 
1006
- history = self.histories.pop()
1007
-
1008
1052
  # Restore state
1009
1053
  self.df = history.df
1010
1054
  self.filename = history.filename
@@ -1016,13 +1060,54 @@ class DataFrameTable(DataTable):
1016
1060
  self.fixed_rows = history.fixed_rows
1017
1061
  self.fixed_columns = history.fixed_columns
1018
1062
  self.cursor_coordinate = history.cursor_coordinate
1019
- self.matches = {k: v.copy() for k, v in history.matches.items()}
1063
+ self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
1020
1064
 
1021
1065
  # Recreate the table for display
1022
1066
  self._setup_table()
1023
1067
 
1068
+ def _add_history(self, description: str) -> None:
1069
+ """Add the current state to the history stack.
1070
+
1071
+ Args:
1072
+ description: Description of the action for this history entry.
1073
+ """
1074
+ history = self._create_history(description)
1075
+ self.histories.append(history)
1076
+
1077
+ def _undo(self) -> None:
1078
+ """Undo the last action."""
1079
+ if not self.histories:
1080
+ self.notify("No actions to undo", title="Undo", severity="warning")
1081
+ return
1082
+
1083
+ # Save current state for redo
1084
+ self.history = self._create_history("Redo state")
1085
+
1086
+ # Pop the last history state for undo
1087
+ history = self.histories.pop()
1088
+
1089
+ # Restore state
1090
+ self._apply_history(history)
1091
+
1024
1092
  # self.notify(f"Reverted: {history.description}", title="Undo")
1025
1093
 
1094
+ def _redo(self) -> None:
1095
+ """Redo the last undone action."""
1096
+ if self.history is None:
1097
+ self.notify("No actions to redo", title="Redo", severity="warning")
1098
+ return
1099
+
1100
+ # Save current state for undo
1101
+ self._add_history("Undo state")
1102
+
1103
+ # Restore state
1104
+ self._apply_history(self.history)
1105
+
1106
+ # Clear redo state
1107
+ self.history = None
1108
+
1109
+ # self.notify(f"Reapplied: {history.description}", title="Redo")
1110
+
1026
1111
  # View
1027
1112
  def _view_row_detail(self) -> None:
1028
1113
  """Open a modal screen to view the selected row's details."""
@@ -1071,9 +1156,9 @@ class DataFrameTable(DataTable):
1071
1156
  self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
1072
1157
 
1073
1158
  # Apply the pin settings to the table
1074
- if fixed_rows > 0:
1159
+ if fixed_rows >= 0:
1075
1160
  self.fixed_rows = fixed_rows
1076
- if fixed_columns > 0:
1161
+ if fixed_columns >= 0:
1077
1162
  self.fixed_columns = fixed_columns
1078
1163
 
1079
1164
  self.notify(
@@ -1082,38 +1167,69 @@ class DataFrameTable(DataTable):
1082
1167
  )
1083
1168
 
1084
1169
  # Delete & Move
1085
- def _delete_column(self) -> None:
1170
+ def _delete_column(self, more: str = None) -> None:
1086
1171
  """Remove the currently selected column from the table."""
1087
1172
  # Get the column to remove
1088
1173
  col_idx = self.cursor_column
1089
1174
  col_name = self.cursor_col_name
1090
1175
  col_key = self.cursor_col_key
1091
1176
 
1177
+ col_names_to_remove = []
1178
+ col_keys_to_remove = []
1179
+
1180
+ # Remove all columns before the current column
1181
+ if more == "before":
1182
+ for i in range(col_idx + 1):
1183
+ col_key = self.get_column_key(i)
1184
+ col_names_to_remove.append(col_key.value)
1185
+ col_keys_to_remove.append(col_key)
1186
+
1187
+ descr = f"Removed column [$success]{col_name}[/] and all columns before"
1188
+
1189
+ # Remove all columns after the current column
1190
+ elif more == "after":
1191
+ for i in range(col_idx, len(self.columns)):
1192
+ col_key = self.get_column_key(i)
1193
+ col_names_to_remove.append(col_key.value)
1194
+ col_keys_to_remove.append(col_key)
1195
+
1196
+ descr = f"Removed column [$success]{col_name}[/] and all columns after"
1197
+
1198
+ # Remove only the current column
1199
+ else:
1200
+ col_names_to_remove.append(col_name)
1201
+ col_keys_to_remove.append(col_key)
1202
+ descr = f"Removed column [$success]{col_name}[/]"
1203
+
1092
1204
  # Add to history
1093
- self._add_history(f"Removed column [$success]{col_name}[/]")
1205
+ self._add_history(descr)
1094
1206
 
1095
- # Remove the column from the table display using the column name as key
1096
- self.remove_column(col_key)
1207
+ # Remove the columns from the table display using the column names as keys
1208
+ for ck in col_keys_to_remove:
1209
+ self.remove_column(ck)
1097
1210
 
1098
- # Move cursor left if we deleted the last column
1099
- if col_idx >= len(self.columns):
1100
- self.move_cursor(column=len(self.columns) - 1)
1211
+ # Move cursor left if we deleted the last column(s)
1212
+ last_col_idx = len(self.columns) - 1
1213
+ if col_idx > last_col_idx:
1214
+ self.move_cursor(column=last_col_idx)
1101
1215
 
1102
1216
  # Remove from sorted columns if present
1103
- if col_name in self.sorted_columns:
1104
- del self.sorted_columns[col_name]
1217
+ for col_name in col_names_to_remove:
1218
+ if col_name in self.sorted_columns:
1219
+ del self.sorted_columns[col_name]
1105
1220
 
1106
1221
  # Remove from matches
1222
+ col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
1107
1223
  for row_idx in list(self.matches.keys()):
1108
- self.matches[row_idx].discard(col_idx)
1224
+ self.matches[row_idx].difference_update(col_indices_to_remove)
1109
1225
  # Remove empty entries
1110
1226
  if not self.matches[row_idx]:
1111
1227
  del self.matches[row_idx]
1112
1228
 
1113
1229
  # Remove from dataframe
1114
- self.df = self.df.drop(col_name)
1230
+ self.df = self.df.drop(col_names_to_remove)
1115
1231
 
1116
- self.notify(f"Removed column [$success]{col_name}[/]", title="Delete")
1232
+ # self.notify(descr, title="Delete")
1117
1233
 
1118
1234
  def _hide_column(self) -> None:
1119
1235
  """Hide the currently selected column from the table display."""
@@ -1136,28 +1252,32 @@ class DataFrameTable(DataTable):
1136
1252
 
1137
1253
  # self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
1138
1254
 
1139
- def _show_column(self) -> None:
1140
- """Show all hidden columns by recreating the table with all dataframe columns."""
1255
+ def _show_hidden_rows_columns(self) -> None:
1256
+ """Show all hidden rows/columns by recreating the table."""
1141
1257
  # Get currently visible columns
1142
1258
  visible_cols = set(col.key for col in self.ordered_columns)
1143
1259
 
1144
- # Find hidden columns (in dataframe but not in table)
1145
- hidden_cols = [col for col in self.df.columns if col not in visible_cols]
1260
+ hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
1261
+ hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
1146
1262
 
1147
- if not hidden_cols:
1148
- self.notify("No hidden columns to show", title="Column", severity="warning")
1263
+ if not hidden_row_count and not hidden_col_count:
1264
+ self.notify("No hidden columns or rows to show", title="Show", severity="warning")
1149
1265
  return
1150
1266
 
1151
1267
  # Add to history
1152
- self._add_history(f"Showed {len(hidden_cols)} hidden column(s)")
1268
+ self._add_history("Showed hidden rows/columns")
1153
1269
 
1154
- # Clear hidden columns tracking
1270
+ # Clear hidden rows/columns tracking
1271
+ self.visible_rows = [True] * len(self.df)
1155
1272
  self.hidden_columns.clear()
1156
1273
 
1157
- # Recreate table with all columns
1274
+ # Recreate table for display
1158
1275
  self._setup_table()
1159
1276
 
1160
- self.notify(f"Showed [$accent]{len(hidden_cols)}[/] hidden column(s)", title="Column")
1277
+ self.notify(
1278
+ f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] hidden column(s)",
1279
+ title="Show",
1280
+ )
1161
1281
 
1162
1282
  def _duplicate_column(self) -> None:
1163
1283
  """Duplicate the currently selected column, inserting it right after the current column."""
@@ -1179,6 +1299,18 @@ class DataFrameTable(DataTable):
1179
1299
  list(cols_before) + [new_col_name] + list(cols_after)
1180
1300
  )
1181
1301
 
1302
+ # Update matches to account for new column
1303
+ new_matches = defaultdict(set)
1304
+ for row_idx, cols in self.matches.items():
1305
+ new_cols = set()
1306
+ for col_idx_in_set in cols:
1307
+ if col_idx_in_set <= cidx:
1308
+ new_cols.add(col_idx_in_set)
1309
+ else:
1310
+ new_cols.add(col_idx_in_set + 1)
1311
+ new_matches[row_idx] = new_cols
1312
+ self.matches = new_matches
1313
+
1182
1314
  # Recreate the table for display
1183
1315
  self._setup_table()
1184
1316
 
@@ -1190,7 +1322,7 @@ class DataFrameTable(DataTable):
1190
1322
  title="Duplicate",
1191
1323
  )
1192
1324
 
1193
- def _delete_row(self) -> None:
1325
+ def _delete_row(self, more: str = None) -> None:
1194
1326
  """Delete rows from the table and dataframe.
1195
1327
 
1196
1328
  Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
@@ -1206,11 +1338,27 @@ class DataFrameTable(DataTable):
1206
1338
  if selected:
1207
1339
  predicates[ridx] = False
1208
1340
 
1341
+ # Delete current row and those above
1342
+ elif more == "above":
1343
+ ridx = self.cursor_row_idx
1344
+ history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
1345
+ for i in range(ridx + 1):
1346
+ predicates[i] = False
1347
+
1348
+ # Delete current row and those below
1349
+ elif more == "below":
1350
+ ridx = self.cursor_row_idx
1351
+ history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
1352
+ for i in range(ridx, len(self.df)):
1353
+ if self.visible_rows[i]:
1354
+ predicates[i] = False
1355
+
1209
1356
  # Delete the row at the cursor
1210
1357
  else:
1211
1358
  ridx = self.cursor_row_idx
1212
1359
  history_desc = f"Deleted row [$success]{ridx + 1}[/]"
1213
- predicates[ridx] = False
1360
+ if self.visible_rows[ridx]:
1361
+ predicates[ridx] = False
1214
1362
 
1215
1363
  # Add to history
1216
1364
  self._add_history(history_desc)
@@ -1238,7 +1386,7 @@ class DataFrameTable(DataTable):
1238
1386
 
1239
1387
  deleted_count = old_count - len(self.df)
1240
1388
  if deleted_count > 1:
1241
- self.notify(f"Deleted {deleted_count} row(s)", title="Delete")
1389
+ self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
1242
1390
 
1243
1391
  def _duplicate_row(self) -> None:
1244
1392
  """Duplicate the currently selected row, inserting it right after the current row."""
@@ -1263,8 +1411,14 @@ class DataFrameTable(DataTable):
1263
1411
  self.selected_rows = new_selected_rows
1264
1412
  self.visible_rows = new_visible_rows
1265
1413
 
1266
- # Clear all matches since row indices have changed
1267
- self.matches = defaultdict(set)
1414
+ # Update matches to account for new row
1415
+ new_matches = defaultdict(set)
1416
+ for row_idx, cols in self.matches.items():
1417
+ if row_idx <= ridx:
1418
+ new_matches[row_idx] = cols
1419
+ else:
1420
+ new_matches[row_idx + 1] = cols
1421
+ self.matches = new_matches
1268
1422
 
1269
1423
  # Recreate the table display
1270
1424
  self._setup_table()
@@ -2072,18 +2226,6 @@ class DataFrameTable(DataTable):
2072
2226
  title="Global Find",
2073
2227
  )
2074
2228
 
2075
- def _move_cursor(self, ridx: int, cidx: int) -> None:
2076
- """Move cursor based on the dataframe indices.
2077
-
2078
- Args:
2079
- ridx: Row index (0-based) in the dataframe.
2080
- cidx: Column index (0-based) in the dataframe.
2081
- """
2082
- row_key = str(ridx)
2083
- col_key = self.df.columns[cidx]
2084
- row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
2085
- self.move_cursor(row=row_idx, column=col_idx)
2086
-
2087
2229
  def _next_match(self) -> None:
2088
2230
  """Move cursor to the next match."""
2089
2231
  if not self.matches:
@@ -2099,12 +2241,12 @@ class DataFrameTable(DataTable):
2099
2241
  # Find the next match after current position
2100
2242
  for ridx, cidx in ordered_matches:
2101
2243
  if (ridx, cidx) > current_pos:
2102
- self._move_cursor(ridx, cidx)
2244
+ self.move_cursor_to(ridx, cidx)
2103
2245
  return
2104
2246
 
2105
2247
  # If no next match, wrap around to the first match
2106
2248
  first_ridx, first_cidx = ordered_matches[0]
2107
- self._move_cursor(first_ridx, first_cidx)
2249
+ self.move_cursor_to(first_ridx, first_cidx)
2108
2250
 
2109
2251
  def _previous_match(self) -> None:
2110
2252
  """Move cursor to the previous match."""
@@ -2149,12 +2291,12 @@ class DataFrameTable(DataTable):
2149
2291
  # Find the next selected row after current position
2150
2292
  for ridx in selected_row_indices:
2151
2293
  if ridx > current_ridx:
2152
- self._move_cursor(ridx, self.cursor_col_idx)
2294
+ self.move_cursor_to(ridx, self.cursor_col_idx)
2153
2295
  return
2154
2296
 
2155
2297
  # If no next selected row, wrap around to the first selected row
2156
2298
  first_ridx = selected_row_indices[0]
2157
- self._move_cursor(first_ridx, self.cursor_col_idx)
2299
+ self.move_cursor_to(first_ridx, self.cursor_col_idx)
2158
2300
 
2159
2301
  def _previous_selected_row(self) -> None:
2160
2302
  """Move cursor to the previous selected row."""
@@ -2171,12 +2313,12 @@ class DataFrameTable(DataTable):
2171
2313
  # Find the previous selected row before current position
2172
2314
  for ridx in reversed(selected_row_indices):
2173
2315
  if ridx < current_ridx:
2174
- self._move_cursor(ridx, self.cursor_col_idx)
2316
+ self.move_cursor_to(ridx, self.cursor_col_idx)
2175
2317
  return
2176
2318
 
2177
2319
  # If no previous selected row, wrap around to the last selected row
2178
2320
  last_ridx = selected_row_indices[-1]
2179
- self._move_cursor(last_ridx, self.cursor_col_idx)
2321
+ self.move_cursor_to(last_ridx, self.cursor_col_idx)
2180
2322
 
2181
2323
  def _replace(self) -> None:
2182
2324
  """Open replace screen for current column."""
@@ -2459,8 +2601,8 @@ class DataFrameTable(DataTable):
2459
2601
  title="Toggle",
2460
2602
  )
2461
2603
 
2462
- # Refresh the highlighting (also restores default styles for unselected rows)
2463
- self._do_highlight()
2604
+ # Refresh the highlighting
2605
+ self._do_highlight(force=True)
2464
2606
 
2465
2607
  def _make_selections(self) -> None:
2466
2608
  """Make selections based on current matches or toggle current row selection."""
@@ -2481,10 +2623,10 @@ class DataFrameTable(DataTable):
2481
2623
  self.notify(f"Selected [$accent]{new_selected_count}[/] rows", title="Toggle")
2482
2624
 
2483
2625
  # Refresh the highlighting (also restores default styles for unselected rows)
2484
- self._do_highlight()
2626
+ self._do_highlight(force=True)
2485
2627
 
2486
- def _clear_selections(self) -> None:
2487
- """Clear all selected rows without removing them from the dataframe."""
2628
+ def _clear_selections_and_matches(self) -> None:
2629
+ """Clear all selected rows and matches without removing them from the dataframe."""
2488
2630
  # Check if any selected rows or matches
2489
2631
  if not any(self.selected_rows) and not self.matches:
2490
2632
  self.notify("No selections to clear", title="Clear", severity="warning")
@@ -2497,8 +2639,12 @@ class DataFrameTable(DataTable):
2497
2639
  # Save current state to history
2498
2640
  self._add_history("Cleared all selected rows")
2499
2641
 
2500
- # Clear all selections and refresh highlighting
2501
- self._do_highlight(clear=True)
2642
+ # Clear all selections
2643
+ self.selected_rows = [False] * len(self.df)
2644
+ self.matches = defaultdict(set)
2645
+
2646
+ # Refresh the highlighting to remove all highlights
2647
+ self._do_highlight(force=True)
2502
2648
 
2503
2649
  self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
2504
2650
 
@@ -2766,3 +2912,38 @@ class DataFrameTable(DataTable):
2766
2912
  f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2767
2913
  title="Save",
2768
2914
  )
2915
+
2916
+ def _make_cell_clickable(self) -> None:
2917
+ """Make cells with URLs in the current column clickable.
2918
+
2919
+ Scans all loaded rows in the current column for cells containing URLs
2920
+ (starting with 'http://' or 'https://') and applies Textual link styling
2921
+ to make them clickable. Does not modify the dataframe.
2922
+
2923
+ Returns:
2924
+ None
2925
+ """
2926
+ cidx = self.cursor_col_idx
2927
+ col_key = self.cursor_col_key
2928
+ dtype = self.df.dtypes[cidx]
2929
+
2930
+ # Only process string columns
2931
+ if dtype != pl.String:
2932
+ return
2933
+
2934
+ # Count how many URLs were made clickable
2935
+ url_count = 0
2936
+
2937
+ # Iterate through all loaded rows and make URLs clickable
2938
+ for row in self.ordered_rows:
2939
+ cell_text: Text = self.get_cell(row.key, col_key)
2940
+ if cell_text.plain.startswith(("http://", "https://")):
2941
+ cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
2942
+ self.update_cell(row.key, col_key, cell_text)
2943
+ url_count += 1
2944
+
2945
+ if url_count:
2946
+ self.notify(
2947
+ f"Made [$accent]{url_count}[/] cell(s) clickable in column [$success]{col_key.value}[/]",
2948
+ title="Make Clickable",
2949
+ )