dataframe-textual 1.1.5__py3-none-any.whl → 1.3.9__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.
@@ -9,6 +9,7 @@ from typing import Any
9
9
 
10
10
  import polars as pl
11
11
  from rich.text import Text
12
+ from textual import work
12
13
  from textual.coordinate import Coordinate
13
14
  from textual.events import Click
14
15
  from textual.widgets import DataTable, TabPane
@@ -30,9 +31,11 @@ from .common import (
30
31
  format_row,
31
32
  get_next_item,
32
33
  rindex,
34
+ sleep_async,
33
35
  tentative_expr,
34
36
  validate_expr,
35
37
  )
38
+ from .sql_screen import AdvancedSqlScreen, SimpleSqlScreen
36
39
  from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
37
40
  from .yes_no_screen import (
38
41
  AddColumnScreen,
@@ -104,8 +107,12 @@ class DataFrameTable(DataTable):
104
107
  - **F** - 📊 Show frequency distribution
105
108
  - **s** - 📈 Show statistics for current column
106
109
  - **S** - 📊 Show statistics for entire dataframe
107
- - **K** - 🔄 Cycle cursor (cell → row → column → cell)
110
+ - **h** - 👁️ Hide current column
111
+ - **H** - 👀 Show all hidden rows/columns
112
+ - **z** - 📌 Freeze rows and columns
108
113
  - **~** - 🏷️ Toggle row labels
114
+ - **,** - 🔢 Toggle thousand separator for numeric display
115
+ - **K** - 🔄 Cycle cursor (cell → row → column → cell)
109
116
 
110
117
  ## ↕️ Sorting
111
118
  - **[** - 🔼 Sort column ascending
@@ -136,7 +143,11 @@ class DataFrameTable(DataTable):
136
143
  - **{** - ⬆️ Go to previous selected row
137
144
  - **}** - ⬇️ Go to next selected row
138
145
  - **"** - 📍 Filter to show only selected rows
139
- - **T** - 🧹 Clear all selections
146
+ - **T** - 🧹 Clear all selections and matches
147
+
148
+ ## 🔍 SQL Interface
149
+ - **l** - 💬 Open simple SQL interface (select columns & WHERE clause)
150
+ - **L** - 🔎 Open advanced SQL interface (full SQL queries)
140
151
 
141
152
  ## ✏️ Edit & Modify
142
153
  - **Double-click** - ✍️ Edit cell or rename column header
@@ -144,13 +155,15 @@ class DataFrameTable(DataTable):
144
155
  - **E** - 📊 Edit entire column with expression
145
156
  - **a** - ➕ Add empty column after current
146
157
  - **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
158
+ - **x** - Delete current row
159
+ - **X** - Delete row and those below
160
+ - **Ctrl+X** - Delete row and those above
161
+ - **delete** - ❌ Clear current cell (set to NULL)
150
162
  - **-** - ❌ Delete current column
163
+ - **_** - ❌ Delete column and those after
164
+ - **Ctrl+_** - ❌ Delete column and those before
151
165
  - **d** - 📋 Duplicate current column
152
- - **h** - 👁️ Hide current column
153
- - **H** - 👀 Show all hidden columns
166
+ - **D** - 📋 Duplicate current row
154
167
 
155
168
  ## 🎯 Reorder
156
169
  - **Shift+↑↓** - ⬆️⬇️ Move row up/down
@@ -166,32 +179,39 @@ class DataFrameTable(DataTable):
166
179
  - **@** - 🔗 Make URLs in current column clickable with Ctrl/Cmd
167
180
 
168
181
  ## 💾 Data Management
169
- - **z** - 📌 Freeze rows and columns
170
- - **,** - 🔢 Toggle thousand separator for numeric display
171
182
  - **c** - 📋 Copy cell to clipboard
172
183
  - **Ctrl+c** - 📊 Copy column to clipboard
173
184
  - **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
174
185
  - **Ctrl+s** - 💾 Save current tab to file
175
186
  - **u** - ↩️ Undo last action
176
- - **U** - 🔄 Reset to original data
187
+ - **U** - 🔄 Redo last undone action
188
+ - **Ctrl+U** - 🔁 Reset to initial state
177
189
  """).strip()
178
190
 
179
191
  # fmt: off
180
192
  BINDINGS = [
193
+ # Navigation
181
194
  ("g", "jump_top", "Jump to top"),
182
195
  ("G", "jump_bottom", "Jump to bottom"),
196
+ # Display
183
197
  ("h", "hide_column", "Hide column"),
184
- ("H", "show_column", "Show columns"),
198
+ ("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
199
+ ("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
200
+ ("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
201
+ ("z", "freeze_row_column", "Freeze rows/columns"),
202
+ ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
203
+ # Copy
185
204
  ("c", "copy_cell", "Copy cell to clipboard"),
186
205
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
187
206
  ("ctrl+r", "copy_row", "Copy row to clipboard"),
207
+ # Save
188
208
  ("ctrl+s", "save_to_file", "Save to file"),
209
+ # Detail, Frequency, and Statistics
189
210
  ("enter", "view_row_detail", "View row details"),
190
- # Frequency & Statistics
191
211
  ("F", "show_frequency", "Show frequency"),
192
212
  ("s", "show_statistics", "Show statistics for column"),
193
213
  ("S", "show_statistics('dataframe')", "Show statistics for dataframe"),
194
- # Sorting
214
+ # Sort
195
215
  ("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
196
216
  ("right_square_bracket", "sort_descending", "Sort descending"), # `]`
197
217
  # View
@@ -215,16 +235,23 @@ class DataFrameTable(DataTable):
215
235
  # Selection
216
236
  ("apostrophe", "make_selections", "Toggle row selection"), # `'`
217
237
  ("t", "toggle_selections", "Toggle all row selections"),
218
- ("T", "clear_selections", "Clear selections"),
238
+ ("T", "clear_selections_and_matches", "Clear selections"),
219
239
  ("quotation_mark", "filter_selected_rows", "Filter selected"), # `"`
220
- # Edit
240
+ # Delete
241
+ ("delete", "clear_cell", "Clear cell"),
221
242
  ("minus", "delete_column", "Delete column"), # `-`
243
+ ("underscore", "delete_column_and_after", "Delete column and those after"), # `_`
244
+ ("ctrl+underscore", "delete_column_and_before", "Delete column and those before"), # `Ctrl+_`
222
245
  ("x", "delete_row", "Delete row"),
223
- ("X", "clear_cell", "Clear cell"),
246
+ ("X", "delete_row_and_below", "Delete row and those below"),
247
+ ("ctrl+x", "delete_row_and_up", "Delete row and those up"),
248
+ # Duplicate
224
249
  ("d", "duplicate_column", "Duplicate column"),
225
250
  ("D", "duplicate_row", "Duplicate row"),
251
+ # Edit
226
252
  ("e", "edit_cell", "Edit cell"),
227
253
  ("E", "edit_column", "Edit column"),
254
+ # Add
228
255
  ("a", "add_column", "Add column"),
229
256
  ("A", "add_column_expr", "Add column with expression"),
230
257
  # Reorder
@@ -233,19 +260,18 @@ class DataFrameTable(DataTable):
233
260
  ("shift+up", "move_row_up", "Move row up"),
234
261
  ("shift+down", "move_row_down", "Move row down"),
235
262
  # Type Conversion
236
- ("number_sign", "cast_column_dtype('int')", "Cast column dtype to int"), # `#`
237
- ("percent_sign", "cast_column_dtype('float')", "Cast column dtype to float"), # `%`
238
- ("exclamation_mark", "cast_column_dtype('bool')", "Cast column dtype to bool"), # `!`
239
- ("dollar_sign", "cast_column_dtype('string')", "Cast column dtype to string"), # `$`
263
+ ("number_sign", "cast_column_dtype('pl.Int64')", "Cast column dtype to integer"), # `#`
264
+ ("percent_sign", "cast_column_dtype('pl.Float64')", "Cast column dtype to float"), # `%`
265
+ ("exclamation_mark", "cast_column_dtype('pl.Boolean')", "Cast column dtype to bool"), # `!`
266
+ ("dollar_sign", "cast_column_dtype('pl.String')", "Cast column dtype to string"), # `$`
240
267
  ("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"), # `,`
268
+ # Sql
269
+ ("l", "simple_sql", "Simple SQL interface"),
270
+ ("L", "advanced_sql", "Advanced SQL interface"),
246
271
  # Undo/Redo
247
272
  ("u", "undo", "Undo"),
248
- ("U", "reset", "Reset to original"),
273
+ ("U", "redo", "Redo"),
274
+ ("ctrl+u", "reset", "Reset to initial state"),
249
275
  ]
250
276
  # fmt: on
251
277
 
@@ -287,8 +313,10 @@ class DataFrameTable(DataTable):
287
313
  self.fixed_rows = 0 # Number of fixed rows
288
314
  self.fixed_columns = 0 # Number of fixed columns
289
315
 
290
- # History stack for undo/redo
316
+ # History stack for undo
291
317
  self.histories: deque[History] = deque()
318
+ # Current history state for redo
319
+ self.history: History = None
292
320
 
293
321
  # Pending filename for save operations
294
322
  self._pending_filename = ""
@@ -391,17 +419,27 @@ class DataFrameTable(DataTable):
391
419
  matches.append((ridx, cidx))
392
420
  return matches
393
421
 
394
- def on_mount(self) -> None:
395
- """Initialize table display when the widget is mounted.
422
+ def get_row_key(self, row_idx: int) -> RowKey:
423
+ """Get the row key for a given table row index.
396
424
 
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.
425
+ Args:
426
+ row_idx: Row index in the table display.
399
427
 
400
428
  Returns:
401
- None
429
+ Corresponding row key as string.
402
430
  """
403
- # self._setup_table()
404
- pass
431
+ return self._row_locations.get_key(row_idx)
432
+
433
+ def get_column_key(self, col_idx: int) -> ColumnKey:
434
+ """Get the column key for a given table column index.
435
+
436
+ Args:
437
+ col_idx: Column index in the table display.
438
+
439
+ Returns:
440
+ Corresponding column key as string.
441
+ """
442
+ return self._column_locations.get_key(col_idx)
405
443
 
406
444
  def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
407
445
  """Determine if the given cell should be highlighted because of the cursor.
@@ -486,6 +524,30 @@ class DataFrameTable(DataTable):
486
524
  else:
487
525
  self._scroll_cursor_into_view()
488
526
 
527
+ def move_cursor_to(self, ridx: int, cidx: int) -> None:
528
+ """Move cursor based on the dataframe indices.
529
+
530
+ Args:
531
+ ridx: Row index (0-based) in the dataframe.
532
+ cidx: Column index (0-based) in the dataframe.
533
+ """
534
+ row_key = str(ridx)
535
+ col_key = self.df.columns[cidx]
536
+ row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
537
+ self.move_cursor(row=row_idx, column=col_idx)
538
+
539
+ def on_mount(self) -> None:
540
+ """Initialize table display when the widget is mounted.
541
+
542
+ Called by Textual when the widget is first added to the display tree.
543
+ Currently a placeholder as table setup is deferred until first use.
544
+
545
+ Returns:
546
+ None
547
+ """
548
+ # self._setup_table()
549
+ pass
550
+
489
551
  def on_key(self, event) -> None:
490
552
  """Handle key press events for pagination.
491
553
 
@@ -544,13 +606,21 @@ class DataFrameTable(DataTable):
544
606
  """Delete the current column."""
545
607
  self._delete_column()
546
608
 
609
+ def action_delete_column_and_after(self) -> None:
610
+ """Delete the current column and those after."""
611
+ self._delete_column(more="after")
612
+
613
+ def action_delete_column_and_before(self) -> None:
614
+ """Delete the current column and those before."""
615
+ self._delete_column(more="before")
616
+
547
617
  def action_hide_column(self) -> None:
548
618
  """Hide the current column."""
549
619
  self._hide_column()
550
620
 
551
- def action_show_column(self) -> None:
552
- """Show all hidden columns."""
553
- self._show_column()
621
+ def action_show_hidden_rows_columns(self) -> None:
622
+ """Show all hidden rows/columns."""
623
+ self._show_hidden_rows_columns()
554
624
 
555
625
  def action_sort_ascending(self) -> None:
556
626
  """Sort by current column in ascending order."""
@@ -656,6 +726,14 @@ class DataFrameTable(DataTable):
656
726
  """Delete the current row."""
657
727
  self._delete_row()
658
728
 
729
+ def action_delete_row_and_below(self) -> None:
730
+ """Delete the current row and those below."""
731
+ self._delete_row(more="below")
732
+
733
+ def action_delete_row_and_up(self) -> None:
734
+ """Delete the current row and those above."""
735
+ self._delete_row(more="above")
736
+
659
737
  def action_duplicate_column(self) -> None:
660
738
  """Duplicate the current column."""
661
739
  self._duplicate_column()
@@ -668,10 +746,14 @@ class DataFrameTable(DataTable):
668
746
  """Undo the last action."""
669
747
  self._undo()
670
748
 
749
+ def action_redo(self) -> None:
750
+ """Redo the last undone action."""
751
+ self._redo()
752
+
671
753
  def action_reset(self) -> None:
672
- """Reset to the original data."""
754
+ """Reset to the initial state."""
673
755
  self._setup_table(reset=True)
674
- self.notify("Restored original display", title="Reset")
756
+ self.notify("Restored initial state", title="Reset")
675
757
 
676
758
  def action_move_column_left(self) -> None:
677
759
  """Move the current column to the left."""
@@ -689,9 +771,9 @@ class DataFrameTable(DataTable):
689
771
  """Move the current row down."""
690
772
  self._move_row("down")
691
773
 
692
- def action_clear_selections(self) -> None:
693
- """Clear all row selections."""
694
- self._clear_selections()
774
+ def action_clear_selections_and_matches(self) -> None:
775
+ """Clear all row selections and matches."""
776
+ self._clear_selections_and_matches()
695
777
 
696
778
  def action_cycle_cursor_type(self) -> None:
697
779
  """Cycle through cursor types."""
@@ -781,40 +863,13 @@ class DataFrameTable(DataTable):
781
863
  """Go to the previous selected row."""
782
864
  self._previous_selected_row()
783
865
 
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.
866
+ def action_simple_sql(self) -> None:
867
+ """Open the SQL interface screen."""
868
+ self._simple_sql()
790
869
 
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
- )
870
+ def action_advanced_sql(self) -> None:
871
+ """Open the advanced SQL interface screen."""
872
+ self._advanced_sql()
818
873
 
819
874
  def on_mouse_scroll_down(self, event) -> None:
820
875
  """Load more rows when scrolling down with mouse."""
@@ -827,6 +882,9 @@ class DataFrameTable(DataTable):
827
882
  Row keys are 0-based indices, which map directly to dataframe row indices.
828
883
  Column keys are header names from the dataframe.
829
884
  """
885
+ self.loaded_rows = 0
886
+ self.show_row_labels = True
887
+
830
888
  # Reset to original dataframe
831
889
  if reset:
832
890
  self.df = self.lazyframe.collect()
@@ -849,12 +907,15 @@ class DataFrameTable(DataTable):
849
907
  stop = row_idx + 1
850
908
  break
851
909
 
910
+ # Ensure all selected rows or matches are loaded
911
+ stop = max(stop, rindex(self.selected_rows, True) + 1)
912
+ stop = max(stop, max(self.matches.keys(), default=0) + 1)
913
+
852
914
  # Save current cursor position before clearing
853
915
  row_idx, col_idx = self.cursor_coordinate
854
916
 
855
917
  self._setup_columns()
856
918
  self._load_rows(stop)
857
- self._do_highlight()
858
919
 
859
920
  # Restore cursor position
860
921
  if row_idx < len(self.rows) and col_idx < len(self.columns):
@@ -866,9 +927,7 @@ class DataFrameTable(DataTable):
866
927
  Column keys are header names from the dataframe.
867
928
  Column labels contain column names from the dataframe, with sort indicators if applicable.
868
929
  """
869
- self.loaded_rows = 0
870
930
  self.clear(columns=True)
871
- self.show_row_labels = True
872
931
 
873
932
  # Add columns with justified headers
874
933
  for col, dtype in zip(self.df.columns, self.df.dtypes):
@@ -906,23 +965,33 @@ class DataFrameTable(DataTable):
906
965
  start = self.loaded_rows
907
966
  df_slice = self.df.slice(start, stop - start)
908
967
 
909
- for row_idx, row in enumerate(df_slice.rows(), start):
910
- if not self.visible_rows[row_idx]:
968
+ for ridx, row in enumerate(df_slice.rows(), start):
969
+ if not self.visible_rows[ridx]:
911
970
  continue # Skip hidden rows
912
- vals, dtypes = [], []
971
+
972
+ is_selected = self.selected_rows[ridx]
973
+ match_cols = self.matches.get(ridx, set())
974
+
975
+ vals, dtypes, styles = [], [], []
913
976
  for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
914
977
  if col in self.hidden_columns:
915
978
  continue # Skip hidden columns
979
+
916
980
  vals.append(val)
917
981
  dtypes.append(dtype)
918
- formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
982
+ # Highlight entire row if selected or has matches
983
+ styles.append("red" if is_selected or col in match_cols else None)
984
+
985
+ formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
986
+
919
987
  # Always add labels so they can be shown/hidden via CSS
920
- self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
988
+ self.add_row(*formatted_row, key=str(ridx), label=str(ridx + 1))
921
989
 
922
990
  # Update loaded rows count
923
991
  self.loaded_rows = stop
924
992
 
925
993
  # self.notify(f"Loaded [$accent]{stop}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
994
+ # self.log(f"Loaded {stop}/{len(self.df)} rows from {self.name}")
926
995
 
927
996
  def _check_and_load_more(self) -> None:
928
997
  """Check if we need to load more rows and load them."""
@@ -937,51 +1006,116 @@ class DataFrameTable(DataTable):
937
1006
  if bottom_visible_row >= self.loaded_rows - 10:
938
1007
  self._load_rows(self.loaded_rows + self.BATCH_SIZE)
939
1008
 
940
- def _do_highlight(self, clear: bool = False) -> None:
1009
+ def _do_highlight(self, force: bool = False) -> None:
941
1010
  """Update all rows, highlighting selected ones and restoring others to default.
942
1011
 
943
1012
  Args:
944
- clear: If True, clear all highlights.
1013
+ force: If True, clear all highlights and restore default styles.
945
1014
  """
946
- if clear:
947
- self.selected_rows = [False] * len(self.df)
948
- self.matches = defaultdict(set)
949
-
950
1015
  # Ensure all selected rows or matches are loaded
951
1016
  stop = rindex(self.selected_rows, True) + 1
952
1017
  stop = max(stop, max(self.matches.keys(), default=0) + 1)
953
1018
 
954
1019
  self._load_rows(stop)
955
- self._highlight_table()
1020
+ self._highlight_table(force)
956
1021
 
957
- def _highlight_table(self) -> None:
1022
+ def _highlight_table(self, force: bool = False) -> None:
958
1023
  """Highlight selected rows/cells in red."""
1024
+ if not force and not any(self.selected_rows) and not self.matches:
1025
+ return # Nothing to highlight
1026
+
959
1027
  # Update all rows based on selected state
960
1028
  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())
1029
+ ridx = int(row.key.value) # 0-based index
1030
+ is_selected = self.selected_rows[ridx]
1031
+ match_cols = self.matches.get(ridx, set())
1032
+
1033
+ if not force and not is_selected and not match_cols:
1034
+ continue # No highlight needed for this row
964
1035
 
965
1036
  # Update all cells in this row
966
1037
  for col_idx, col in enumerate(self.ordered_columns):
967
- cell_text: Text = self.get_cell(row.key, col.key)
1038
+ if not force and not is_selected and col_idx not in match_cols:
1039
+ continue # No highlight needed for this cell
968
1040
 
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
1041
+ cell_text: Text = self.get_cell(row.key, col.key)
1042
+ need_update = False
1043
+
1044
+ if is_selected or col_idx in match_cols:
1045
+ cell_text.style = "red"
1046
+ need_update = True
1047
+ elif force:
1048
+ # Restore original style based on dtype
1049
+ dtype = self.df.schema[col.key.value]
1050
+ dc = DtypeConfig(dtype)
1051
+ cell_text.style = dc.style
1052
+ need_update = True
973
1053
 
974
1054
  # Update the cell in the table
975
- self.update_cell(row.key, col.key, cell_text)
1055
+ if need_update:
1056
+ self.update_cell(row.key, col.key, cell_text)
976
1057
 
977
- # History & Undo
978
- def _add_history(self, description: str) -> None:
979
- """Add the current state to the history stack.
1058
+ @work(exclusive=True, description="Loading rows asynchronously...")
1059
+ async def _load_rows_async(self, stop: int | None = None) -> None:
1060
+ """Asynchronously load a batch of rows into the table.
980
1061
 
981
1062
  Args:
982
- description: Description of the action for this history entry.
1063
+ stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
983
1064
  """
984
- history = History(
1065
+ if stop >= (total := len(self.df)):
1066
+ stop = total
1067
+
1068
+ if stop > self.loaded_rows:
1069
+ # Load incrementally with smaller chunks to prevent UI freezing
1070
+ chunk_size = min(100, stop - self.loaded_rows) # Load max 100 rows at a time
1071
+ next_stop = min(self.loaded_rows + chunk_size, stop)
1072
+ self._load_rows(next_stop)
1073
+
1074
+ # If there's more to load, schedule the next chunk with longer delay
1075
+ if next_stop < stop:
1076
+ # Use longer delay and call work method instead of set_timer
1077
+ await sleep_async(0.1) # 100ms delay to yield to UI
1078
+ self._load_rows_async(stop) # Recursive call within work context
1079
+
1080
+ # self.log(f"Async loaded {stop}/{len(self.df)} rows from {self.name}")
1081
+
1082
+ @work(exclusive=True, description="Doing highlight...")
1083
+ async def _do_highlight_async(self) -> None:
1084
+ """Perform the highlighting preparation in a worker."""
1085
+ try:
1086
+ # Calculate what needs to be loaded without actually loading
1087
+ stop = rindex(self.selected_rows, True) + 1
1088
+ stop = max(stop, max(self.matches.keys(), default=0) + 1)
1089
+
1090
+ # Call the highlighting method (runs in background worker)
1091
+ self._highlight_async(stop)
1092
+
1093
+ except Exception as e:
1094
+ self.notify(f"Error preparing highlight: {str(e)}", title="Search", severity="error")
1095
+
1096
+ @work(exclusive=True, description="Highlighting matches...")
1097
+ async def _highlight_async(self, stop: int) -> None:
1098
+ """Perform highlighting with async loading to avoid blocking."""
1099
+ # Load rows in smaller chunks to avoid blocking
1100
+ if stop > self.loaded_rows:
1101
+ # Load incrementally to avoid one big block
1102
+ chunk_size = min(100, stop - self.loaded_rows) # Load max 100 rows at a time
1103
+ next_stop = min(self.loaded_rows + chunk_size, stop)
1104
+ self._load_rows(next_stop)
1105
+
1106
+ # If there's more to load, yield to event loop with delay
1107
+ if next_stop < stop:
1108
+ await sleep_async(0.05) # 50ms delay to allow UI updates
1109
+ self._highlight_async(stop)
1110
+ return
1111
+
1112
+ # Now do the actual highlighting
1113
+ self._highlight_table(force=False)
1114
+
1115
+ # History & Undo
1116
+ def _create_history(self, description: str) -> None:
1117
+ """Create the initial history state."""
1118
+ return History(
985
1119
  description=description,
986
1120
  df=self.df,
987
1121
  filename=self.filename,
@@ -995,16 +1129,12 @@ class DataFrameTable(DataTable):
995
1129
  cursor_coordinate=self.cursor_coordinate,
996
1130
  matches={k: v.copy() for k, v in self.matches.items()},
997
1131
  )
998
- self.histories.append(history)
999
1132
 
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")
1133
+ def _apply_history(self, history: History) -> None:
1134
+ """Apply the current history state to the table."""
1135
+ if history is None:
1004
1136
  return
1005
1137
 
1006
- history = self.histories.pop()
1007
-
1008
1138
  # Restore state
1009
1139
  self.df = history.df
1010
1140
  self.filename = history.filename
@@ -1016,12 +1146,55 @@ class DataFrameTable(DataTable):
1016
1146
  self.fixed_rows = history.fixed_rows
1017
1147
  self.fixed_columns = history.fixed_columns
1018
1148
  self.cursor_coordinate = history.cursor_coordinate
1019
- self.matches = {k: v.copy() for k, v in history.matches.items()}
1149
+ self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
1020
1150
 
1021
1151
  # Recreate the table for display
1022
1152
  self._setup_table()
1023
1153
 
1024
- # self.notify(f"Reverted: {history.description}", title="Undo")
1154
+ def _add_history(self, description: str) -> None:
1155
+ """Add the current state to the history stack.
1156
+
1157
+ Args:
1158
+ description: Description of the action for this history entry.
1159
+ """
1160
+ history = self._create_history(description)
1161
+ self.histories.append(history)
1162
+
1163
+ def _undo(self) -> None:
1164
+ """Undo the last action."""
1165
+ if not self.histories:
1166
+ self.notify("No actions to undo", title="Undo", severity="warning")
1167
+ return
1168
+
1169
+ # Pop the last history state for undo
1170
+ history = self.histories.pop()
1171
+
1172
+ # Save current state for redo
1173
+ self.history = self._create_history(history.description)
1174
+
1175
+ # Restore state
1176
+ self._apply_history(history)
1177
+
1178
+ self.notify(f"Reverted: {history.description}", title="Undo")
1179
+
1180
+ def _redo(self) -> None:
1181
+ """Redo the last undone action."""
1182
+ if self.history is None:
1183
+ self.notify("No actions to redo", title="Redo", severity="warning")
1184
+ return
1185
+
1186
+ description = self.history.description
1187
+
1188
+ # Save current state for undo
1189
+ self._add_history(description)
1190
+
1191
+ # Restore state
1192
+ self._apply_history(self.history)
1193
+
1194
+ # Clear redo state
1195
+ self.history = None
1196
+
1197
+ self.notify(f"Reapplied: {description}", title="Redo")
1025
1198
 
1026
1199
  # View
1027
1200
  def _view_row_detail(self) -> None:
@@ -1071,49 +1244,77 @@ class DataFrameTable(DataTable):
1071
1244
  self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
1072
1245
 
1073
1246
  # Apply the pin settings to the table
1074
- if fixed_rows > 0:
1247
+ if fixed_rows >= 0:
1075
1248
  self.fixed_rows = fixed_rows
1076
- if fixed_columns > 0:
1249
+ if fixed_columns >= 0:
1077
1250
  self.fixed_columns = fixed_columns
1078
1251
 
1079
- self.notify(
1080
- f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
1081
- title="Pin",
1082
- )
1252
+ # self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
1083
1253
 
1084
1254
  # Delete & Move
1085
- def _delete_column(self) -> None:
1255
+ def _delete_column(self, more: str = None) -> None:
1086
1256
  """Remove the currently selected column from the table."""
1087
1257
  # Get the column to remove
1088
1258
  col_idx = self.cursor_column
1089
1259
  col_name = self.cursor_col_name
1090
1260
  col_key = self.cursor_col_key
1091
1261
 
1262
+ col_names_to_remove = []
1263
+ col_keys_to_remove = []
1264
+
1265
+ # Remove all columns before the current column
1266
+ if more == "before":
1267
+ for i in range(col_idx + 1):
1268
+ col_key = self.get_column_key(i)
1269
+ col_names_to_remove.append(col_key.value)
1270
+ col_keys_to_remove.append(col_key)
1271
+
1272
+ message = f"Removed column [$success]{col_name}[/] and all columns before"
1273
+
1274
+ # Remove all columns after the current column
1275
+ elif more == "after":
1276
+ for i in range(col_idx, len(self.columns)):
1277
+ col_key = self.get_column_key(i)
1278
+ col_names_to_remove.append(col_key.value)
1279
+ col_keys_to_remove.append(col_key)
1280
+
1281
+ message = f"Removed column [$success]{col_name}[/] and all columns after"
1282
+
1283
+ # Remove only the current column
1284
+ else:
1285
+ col_names_to_remove.append(col_name)
1286
+ col_keys_to_remove.append(col_key)
1287
+ message = f"Removed column [$success]{col_name}[/]"
1288
+
1092
1289
  # Add to history
1093
- self._add_history(f"Removed column [$success]{col_name}[/]")
1290
+ self._add_history(message)
1094
1291
 
1095
- # Remove the column from the table display using the column name as key
1096
- self.remove_column(col_key)
1292
+ # Remove the columns from the table display using the column names as keys
1293
+ for ck in col_keys_to_remove:
1294
+ self.remove_column(ck)
1097
1295
 
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)
1296
+ # Move cursor left if we deleted the last column(s)
1297
+ last_col_idx = len(self.columns) - 1
1298
+ if col_idx > last_col_idx:
1299
+ self.move_cursor(column=last_col_idx)
1101
1300
 
1102
1301
  # Remove from sorted columns if present
1103
- if col_name in self.sorted_columns:
1104
- del self.sorted_columns[col_name]
1302
+ for col_name in col_names_to_remove:
1303
+ if col_name in self.sorted_columns:
1304
+ del self.sorted_columns[col_name]
1105
1305
 
1106
1306
  # Remove from matches
1307
+ col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
1107
1308
  for row_idx in list(self.matches.keys()):
1108
- self.matches[row_idx].discard(col_idx)
1309
+ self.matches[row_idx].difference_update(col_indices_to_remove)
1109
1310
  # Remove empty entries
1110
1311
  if not self.matches[row_idx]:
1111
1312
  del self.matches[row_idx]
1112
1313
 
1113
1314
  # Remove from dataframe
1114
- self.df = self.df.drop(col_name)
1315
+ self.df = self.df.drop(col_names_to_remove)
1115
1316
 
1116
- self.notify(f"Removed column [$success]{col_name}[/]", title="Delete")
1317
+ self.notify(message, title="Delete")
1117
1318
 
1118
1319
  def _hide_column(self) -> None:
1119
1320
  """Hide the currently selected column from the table display."""
@@ -1136,28 +1337,32 @@ class DataFrameTable(DataTable):
1136
1337
 
1137
1338
  # self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
1138
1339
 
1139
- def _show_column(self) -> None:
1140
- """Show all hidden columns by recreating the table with all dataframe columns."""
1340
+ def _show_hidden_rows_columns(self) -> None:
1341
+ """Show all hidden rows/columns by recreating the table."""
1141
1342
  # Get currently visible columns
1142
1343
  visible_cols = set(col.key for col in self.ordered_columns)
1143
1344
 
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]
1345
+ hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
1346
+ hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
1146
1347
 
1147
- if not hidden_cols:
1148
- self.notify("No hidden columns to show", title="Column", severity="warning")
1348
+ if not hidden_row_count and not hidden_col_count:
1349
+ self.notify("No hidden columns or rows to show", title="Show", severity="warning")
1149
1350
  return
1150
1351
 
1151
1352
  # Add to history
1152
- self._add_history(f"Showed {len(hidden_cols)} hidden column(s)")
1353
+ self._add_history("Showed hidden rows/columns")
1153
1354
 
1154
- # Clear hidden columns tracking
1355
+ # Clear hidden rows/columns tracking
1356
+ self.visible_rows = [True] * len(self.df)
1155
1357
  self.hidden_columns.clear()
1156
1358
 
1157
- # Recreate table with all columns
1359
+ # Recreate table for display
1158
1360
  self._setup_table()
1159
1361
 
1160
- self.notify(f"Showed [$accent]{len(hidden_cols)}[/] hidden column(s)", title="Column")
1362
+ self.notify(
1363
+ f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
1364
+ title="Show",
1365
+ )
1161
1366
 
1162
1367
  def _duplicate_column(self) -> None:
1163
1368
  """Duplicate the currently selected column, inserting it right after the current column."""
@@ -1179,18 +1384,27 @@ class DataFrameTable(DataTable):
1179
1384
  list(cols_before) + [new_col_name] + list(cols_after)
1180
1385
  )
1181
1386
 
1387
+ # Update matches to account for new column
1388
+ new_matches = defaultdict(set)
1389
+ for row_idx, cols in self.matches.items():
1390
+ new_cols = set()
1391
+ for col_idx_in_set in cols:
1392
+ if col_idx_in_set <= cidx:
1393
+ new_cols.add(col_idx_in_set)
1394
+ else:
1395
+ new_cols.add(col_idx_in_set + 1)
1396
+ new_matches[row_idx] = new_cols
1397
+ self.matches = new_matches
1398
+
1182
1399
  # Recreate the table for display
1183
1400
  self._setup_table()
1184
1401
 
1185
1402
  # Move cursor to the new duplicated column
1186
1403
  self.move_cursor(column=col_idx + 1)
1187
1404
 
1188
- self.notify(
1189
- f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
1190
- title="Duplicate",
1191
- )
1405
+ # self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
1192
1406
 
1193
- def _delete_row(self) -> None:
1407
+ def _delete_row(self, more: str = None) -> None:
1194
1408
  """Delete rows from the table and dataframe.
1195
1409
 
1196
1410
  Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
@@ -1206,11 +1420,27 @@ class DataFrameTable(DataTable):
1206
1420
  if selected:
1207
1421
  predicates[ridx] = False
1208
1422
 
1423
+ # Delete current row and those above
1424
+ elif more == "above":
1425
+ ridx = self.cursor_row_idx
1426
+ history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
1427
+ for i in range(ridx + 1):
1428
+ predicates[i] = False
1429
+
1430
+ # Delete current row and those below
1431
+ elif more == "below":
1432
+ ridx = self.cursor_row_idx
1433
+ history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
1434
+ for i in range(ridx, len(self.df)):
1435
+ if self.visible_rows[i]:
1436
+ predicates[i] = False
1437
+
1209
1438
  # Delete the row at the cursor
1210
1439
  else:
1211
1440
  ridx = self.cursor_row_idx
1212
1441
  history_desc = f"Deleted row [$success]{ridx + 1}[/]"
1213
- predicates[ridx] = False
1442
+ if self.visible_rows[ridx]:
1443
+ predicates[ridx] = False
1214
1444
 
1215
1445
  # Add to history
1216
1446
  self._add_history(history_desc)
@@ -1237,8 +1467,8 @@ class DataFrameTable(DataTable):
1237
1467
  self._setup_table()
1238
1468
 
1239
1469
  deleted_count = old_count - len(self.df)
1240
- if deleted_count > 1:
1241
- self.notify(f"Deleted {deleted_count} row(s)", title="Delete")
1470
+ if deleted_count > 0:
1471
+ self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
1242
1472
 
1243
1473
  def _duplicate_row(self) -> None:
1244
1474
  """Duplicate the currently selected row, inserting it right after the current row."""
@@ -1263,8 +1493,14 @@ class DataFrameTable(DataTable):
1263
1493
  self.selected_rows = new_selected_rows
1264
1494
  self.visible_rows = new_visible_rows
1265
1495
 
1266
- # Clear all matches since row indices have changed
1267
- self.matches = defaultdict(set)
1496
+ # Update matches to account for new row
1497
+ new_matches = defaultdict(set)
1498
+ for row_idx, cols in self.matches.items():
1499
+ if row_idx <= ridx:
1500
+ new_matches[row_idx] = cols
1501
+ else:
1502
+ new_matches[row_idx + 1] = cols
1503
+ self.matches = new_matches
1268
1504
 
1269
1505
  # Recreate the table display
1270
1506
  self._setup_table()
@@ -1349,7 +1585,7 @@ class DataFrameTable(DataTable):
1349
1585
  return
1350
1586
  swap_idx = row_idx + 1
1351
1587
  else:
1352
- self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
1588
+ # Invalid direction
1353
1589
  return
1354
1590
 
1355
1591
  row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
@@ -1499,7 +1735,7 @@ class DataFrameTable(DataTable):
1499
1735
  col_key = col_name
1500
1736
  self.update_cell(row_key, col_key, formatted_value, update_width=True)
1501
1737
 
1502
- self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1738
+ # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1503
1739
  except Exception as e:
1504
1740
  self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
1505
1741
 
@@ -1528,7 +1764,7 @@ class DataFrameTable(DataTable):
1528
1764
  # Check if term is a valid expression
1529
1765
  elif tentative_expr(term):
1530
1766
  try:
1531
- expr = validate_expr(term, self.df, cidx)
1767
+ expr = validate_expr(term, self.df.columns, cidx)
1532
1768
  except Exception as e:
1533
1769
  self.notify(f"Error validating expression [$error]{term}[/]: {str(e)}", title="Edit", severity="error")
1534
1770
  return
@@ -1541,7 +1777,7 @@ class DataFrameTable(DataTable):
1541
1777
  expr = pl.lit(value)
1542
1778
  except Exception:
1543
1779
  self.notify(
1544
- f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1780
+ f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1545
1781
  title="Edit",
1546
1782
  severity="error",
1547
1783
  )
@@ -1554,16 +1790,13 @@ class DataFrameTable(DataTable):
1554
1790
  # Apply the expression to the column
1555
1791
  self.df = self.df.with_columns(expr.alias(col_name))
1556
1792
  except Exception as e:
1557
- self.notify(f"Failed to apply expression: [$error]{str(e)}[/]", title="Edit", severity="error")
1793
+ self.notify(f"Error applying expression: [$error]{str(e)}[/]", title="Edit", severity="error")
1558
1794
  return
1559
1795
 
1560
1796
  # Recreate the table for display
1561
1797
  self._setup_table()
1562
1798
 
1563
- self.notify(
1564
- f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
1565
- title="Edit",
1566
- )
1799
+ # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
1567
1800
 
1568
1801
  def _rename_column(self) -> None:
1569
1802
  """Open modal to rename the selected column."""
@@ -1610,10 +1843,7 @@ class DataFrameTable(DataTable):
1610
1843
  # Move cursor to the renamed column
1611
1844
  self.move_cursor(column=col_idx)
1612
1845
 
1613
- self.notify(
1614
- f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
1615
- title="Column",
1616
- )
1846
+ # self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
1617
1847
 
1618
1848
  def _clear_cell(self) -> None:
1619
1849
  """Clear the current cell by setting its value to None."""
@@ -1641,9 +1871,9 @@ class DataFrameTable(DataTable):
1641
1871
 
1642
1872
  self.update_cell(row_key, col_key, formatted_value)
1643
1873
 
1644
- self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
1874
+ # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
1645
1875
  except Exception as e:
1646
- self.notify(f"Failed to clear cell: {str(e)}", title="Clear", severity="error")
1876
+ self.notify(f"Error clearing cell: {str(e)}", title="Clear", severity="error")
1647
1877
  raise e
1648
1878
 
1649
1879
  def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
@@ -1686,9 +1916,9 @@ class DataFrameTable(DataTable):
1686
1916
  # Move cursor to the new column
1687
1917
  self.move_cursor(column=cidx + 1)
1688
1918
 
1689
- self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1919
+ # self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1690
1920
  except Exception as e:
1691
- self.notify(f"Failed to add column: {str(e)}", title="Add Column", severity="error")
1921
+ self.notify(f"Error adding column: {str(e)}", title="Add Column", severity="error")
1692
1922
  raise e
1693
1923
 
1694
1924
  def _add_column_expr(self) -> None:
@@ -1730,53 +1960,32 @@ class DataFrameTable(DataTable):
1730
1960
 
1731
1961
  # self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
1732
1962
  except Exception as e:
1733
- self.notify(f"Failed to add column: [$error]{str(e)}[/]", title="Add Column", severity="error")
1963
+ self.notify(f"Error adding column: [$error]{str(e)}[/]", title="Add Column", severity="error")
1734
1964
  raise e
1735
1965
 
1736
- def _string_to_polars_dtype(self, dtype_str: str) -> pl.DataType:
1737
- """Convert string type name to Polars DataType.
1738
-
1739
- Args:
1740
- dtype_str: String representation of the type ("string", "int", "float", "bool")
1741
-
1742
- Returns:
1743
- Corresponding Polars DataType
1744
-
1745
- Raises:
1746
- ValueError: If the type string is not recognized
1747
- """
1748
- dtype_map = {
1749
- "string": pl.String,
1750
- "int": pl.Int64,
1751
- "float": pl.Float64,
1752
- "bool": pl.Boolean,
1753
- }
1754
-
1755
- dtype_lower = dtype_str.lower().strip()
1756
- return dtype_map.get(dtype_lower)
1757
-
1758
- def _cast_column_dtype(self, dtype: str | pl.DataType = pl.String) -> None:
1966
+ def _cast_column_dtype(self, dtype: str) -> None:
1759
1967
  """Cast the current column to a different data type.
1760
1968
 
1761
1969
  Args:
1762
- dtype: Target data type (string like "int", "float", "bool", "string" or Polars DataType)
1970
+ dtype: Target data type (string representation, e.g., "pl.String", "pl.Int64")
1763
1971
  """
1764
1972
  cidx = self.cursor_col_idx
1765
1973
  col_name = self.cursor_col_name
1766
1974
  current_dtype = self.df.dtypes[cidx]
1767
1975
 
1768
- # Convert string dtype to Polars DataType if needed
1769
- if isinstance(dtype, str):
1770
- target_dtype = self._string_to_polars_dtype(dtype)
1771
- if target_dtype is None:
1772
- self.notify(
1773
- f"Use string for unknown data type: {dtype}. Supported types: {', '.join(self._string_to_polars_dtype.keys())}",
1774
- title="Cast",
1775
- severity="warning",
1776
- )
1777
- target_dtype = pl.String
1778
- else:
1779
- target_dtype = dtype
1976
+ try:
1977
+ target_dtype = eval(dtype)
1978
+ except Exception:
1979
+ self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
1980
+ return
1981
+
1982
+ if current_dtype == target_dtype:
1983
+ self.notify(
1984
+ f"Column [$accent]{col_name}[/] is already of type [$success]{target_dtype}[/]",
1985
+ title="Cast",
1986
+ severity="warning",
1987
+ )
1988
+ return # No change needed
1780
1989
 
1781
1990
  # Add to history
1782
1991
  self._add_history(
@@ -1790,13 +1999,13 @@ class DataFrameTable(DataTable):
1790
1999
  # Recreate the table display
1791
2000
  self._setup_table()
1792
2001
 
2002
+ self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
2003
+ except Exception as e:
1793
2004
  self.notify(
1794
- f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]",
2005
+ f"Error casting column [$accent]{col_name}[/] to [$success]{target_dtype}[/]: {str(e)}",
1795
2006
  title="Cast",
2007
+ severity="error",
1796
2008
  )
1797
- except Exception as e:
1798
- self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
1799
- raise e
1800
2009
 
1801
2010
  def _search_cursor_value(self) -> None:
1802
2011
  """Search with cursor value in current column."""
@@ -1805,7 +2014,7 @@ class DataFrameTable(DataTable):
1805
2014
  # Get the value of the currently selected cell
1806
2015
  term = NULL if self.cursor_value is None else str(self.cursor_value)
1807
2016
 
1808
- self._do_search((term, cidx, False, False))
2017
+ self._do_search((term, cidx, False, True))
1809
2018
 
1810
2019
  def _search_expr(self) -> None:
1811
2020
  """Search by expression."""
@@ -1824,6 +2033,7 @@ class DataFrameTable(DataTable):
1824
2033
  """Search for a term."""
1825
2034
  if result is None:
1826
2035
  return
2036
+
1827
2037
  term, cidx, match_nocase, match_whole = result
1828
2038
  col_name = self.df.columns[cidx]
1829
2039
 
@@ -1833,12 +2043,10 @@ class DataFrameTable(DataTable):
1833
2043
  # Support for polars expressions
1834
2044
  elif tentative_expr(term):
1835
2045
  try:
1836
- expr = validate_expr(term, self.df, cidx)
2046
+ expr = validate_expr(term, self.df.columns, cidx)
1837
2047
  except Exception as e:
1838
2048
  self.notify(
1839
- f"Failed to validate Polars expression [$error]{term}[/]: {str(e)}",
1840
- title="Search",
1841
- severity="error",
2049
+ f"Error validating expression [$error]{term}[/]: {str(e)}", title="Search", severity="error"
1842
2050
  )
1843
2051
  return
1844
2052
 
@@ -1862,7 +2070,7 @@ class DataFrameTable(DataTable):
1862
2070
  term = f"(?i){term}"
1863
2071
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
1864
2072
  self.notify(
1865
- f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2073
+ f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1866
2074
  title="Search",
1867
2075
  severity="warning",
1868
2076
  )
@@ -1877,7 +2085,7 @@ class DataFrameTable(DataTable):
1877
2085
  matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
1878
2086
  except Exception as e:
1879
2087
  self.notify(
1880
- f"Error applying search filter: [$error]{str(e)}[/]",
2088
+ f"Error applying search filter [$accent]{term}[/]: [$error]{str(e)}[/]",
1881
2089
  title="Search",
1882
2090
  severity="error",
1883
2091
  )
@@ -1886,7 +2094,7 @@ class DataFrameTable(DataTable):
1886
2094
  match_count = len(matches)
1887
2095
  if match_count == 0:
1888
2096
  self.notify(
1889
- f"No matches found for [$warning]{term}[/]. Try [$accent](?i)abc[/] for case-insensitive search.",
2097
+ f"No matches found for [$accent]{term}[/]. Try [$warning](?i)abc[/] for case-insensitive search.",
1890
2098
  title="Search",
1891
2099
  severity="warning",
1892
2100
  )
@@ -1899,11 +2107,12 @@ class DataFrameTable(DataTable):
1899
2107
  for m in matches:
1900
2108
  self.selected_rows[m] = True
1901
2109
 
1902
- # Highlight matches
1903
- self._do_highlight()
1904
-
2110
+ # Show notification immediately, then start highlighting
1905
2111
  self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
1906
2112
 
2113
+ # Start highlighting in a worker to avoid blocking the UI
2114
+ self._do_highlight_async()
2115
+
1907
2116
  def _find_matches(
1908
2117
  self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
1909
2118
  ) -> dict[int, set[int]]:
@@ -1941,7 +2150,7 @@ class DataFrameTable(DataTable):
1941
2150
  expr = pl.col(col_name).is_null()
1942
2151
  elif tentative_expr(term):
1943
2152
  try:
1944
- expr = validate_expr(term, self.df, col_idx)
2153
+ expr = validate_expr(term, self.df.columns, col_idx)
1945
2154
  except Exception as e:
1946
2155
  raise Exception(f"Error validating Polars expression: {str(e)}")
1947
2156
  else:
@@ -1973,9 +2182,9 @@ class DataFrameTable(DataTable):
1973
2182
 
1974
2183
  if scope == "column":
1975
2184
  cidx = self.cursor_col_idx
1976
- self._do_find((term, cidx, False, False))
2185
+ self._do_find((term, cidx, False, True))
1977
2186
  else:
1978
- self._do_find_global((term, None, False, False))
2187
+ self._do_find_global((term, None, False, True))
1979
2188
 
1980
2189
  def _find_expr(self, scope="column") -> None:
1981
2190
  """Open screen to find by expression.
@@ -2004,16 +2213,12 @@ class DataFrameTable(DataTable):
2004
2213
  try:
2005
2214
  matches = self._find_matches(term, cidx, match_nocase, match_whole)
2006
2215
  except Exception as e:
2007
- self.notify(
2008
- f"Error finding matches: [$error]{str(e)}[/]",
2009
- title="Find",
2010
- severity="error",
2011
- )
2216
+ self.notify(f"Error finding matches for [$error]{term}[/]: {str(e)}", title="Find", severity="error")
2012
2217
  return
2013
2218
 
2014
2219
  if not matches:
2015
2220
  self.notify(
2016
- f"No matches found for [$warning]{term}[/] in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
2221
+ f"No matches found for [$accent]{term}[/] in current column. Try [$warning](?i)abc[/] for case-insensitive search.",
2017
2222
  title="Find",
2018
2223
  severity="warning",
2019
2224
  )
@@ -2027,11 +2232,11 @@ class DataFrameTable(DataTable):
2027
2232
  for ridx, col_idxs in matches.items():
2028
2233
  self.matches[ridx].update(col_idxs)
2029
2234
 
2030
- # Highlight matches
2031
- self._do_highlight()
2032
-
2033
2235
  self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
2034
2236
 
2237
+ # Start highlighting in a worker to avoid blocking the UI
2238
+ self._do_highlight_async()
2239
+
2035
2240
  def _do_find_global(self, result) -> None:
2036
2241
  """Global find a term across all columns."""
2037
2242
  if result is None:
@@ -2041,16 +2246,12 @@ class DataFrameTable(DataTable):
2041
2246
  try:
2042
2247
  matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
2043
2248
  except Exception as e:
2044
- self.notify(
2045
- f"Error finding matches: [$error]{str(e)}[/]",
2046
- title="Find",
2047
- severity="error",
2048
- )
2249
+ self.notify(f"Error finding matches for [$error]{term}[/]: {str(e)}", title="Find", severity="error")
2049
2250
  return
2050
2251
 
2051
2252
  if not matches:
2052
2253
  self.notify(
2053
- f"No matches found for [$warning]{term}[/] in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
2254
+ f"No matches found for [$accent]{term}[/] in any column. Try [$warning](?i)abc[/] for case-insensitive search.",
2054
2255
  title="Global Find",
2055
2256
  severity="warning",
2056
2257
  )
@@ -2064,25 +2265,12 @@ class DataFrameTable(DataTable):
2064
2265
  for ridx, col_idxs in matches.items():
2065
2266
  self.matches[ridx].update(col_idxs)
2066
2267
 
2067
- # Highlight matches
2068
- self._do_highlight()
2069
-
2070
2268
  self.notify(
2071
- f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
2072
- title="Global Find",
2269
+ f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
2073
2270
  )
2074
2271
 
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)
2272
+ # Start highlighting in a worker to avoid blocking the UI
2273
+ self._do_highlight_async()
2086
2274
 
2087
2275
  def _next_match(self) -> None:
2088
2276
  """Move cursor to the next match."""
@@ -2099,12 +2287,12 @@ class DataFrameTable(DataTable):
2099
2287
  # Find the next match after current position
2100
2288
  for ridx, cidx in ordered_matches:
2101
2289
  if (ridx, cidx) > current_pos:
2102
- self._move_cursor(ridx, cidx)
2290
+ self.move_cursor_to(ridx, cidx)
2103
2291
  return
2104
2292
 
2105
2293
  # If no next match, wrap around to the first match
2106
2294
  first_ridx, first_cidx = ordered_matches[0]
2107
- self._move_cursor(first_ridx, first_cidx)
2295
+ self.move_cursor_to(first_ridx, first_cidx)
2108
2296
 
2109
2297
  def _previous_match(self) -> None:
2110
2298
  """Move cursor to the previous match."""
@@ -2149,12 +2337,12 @@ class DataFrameTable(DataTable):
2149
2337
  # Find the next selected row after current position
2150
2338
  for ridx in selected_row_indices:
2151
2339
  if ridx > current_ridx:
2152
- self._move_cursor(ridx, self.cursor_col_idx)
2340
+ self.move_cursor_to(ridx, self.cursor_col_idx)
2153
2341
  return
2154
2342
 
2155
2343
  # If no next selected row, wrap around to the first selected row
2156
2344
  first_ridx = selected_row_indices[0]
2157
- self._move_cursor(first_ridx, self.cursor_col_idx)
2345
+ self.move_cursor_to(first_ridx, self.cursor_col_idx)
2158
2346
 
2159
2347
  def _previous_selected_row(self) -> None:
2160
2348
  """Move cursor to the previous selected row."""
@@ -2171,12 +2359,12 @@ class DataFrameTable(DataTable):
2171
2359
  # Find the previous selected row before current position
2172
2360
  for ridx in reversed(selected_row_indices):
2173
2361
  if ridx < current_ridx:
2174
- self._move_cursor(ridx, self.cursor_col_idx)
2362
+ self.move_cursor_to(ridx, self.cursor_col_idx)
2175
2363
  return
2176
2364
 
2177
2365
  # If no previous selected row, wrap around to the last selected row
2178
2366
  last_ridx = selected_row_indices[-1]
2179
- self._move_cursor(last_ridx, self.cursor_col_idx)
2367
+ self.move_cursor_to(last_ridx, self.cursor_col_idx)
2180
2368
 
2181
2369
  def _replace(self) -> None:
2182
2370
  """Open replace screen for current column."""
@@ -2222,16 +2410,12 @@ class DataFrameTable(DataTable):
2222
2410
  matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
2223
2411
 
2224
2412
  if not matches:
2225
- self.notify(
2226
- f"No matches found for [$warning]{term_find}[/]",
2227
- title="Replace",
2228
- severity="warning",
2229
- )
2413
+ self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
2230
2414
  return
2231
2415
 
2232
2416
  # Add to history
2233
2417
  self._add_history(
2234
- f"Replacing [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
2418
+ f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
2235
2419
  )
2236
2420
 
2237
2421
  # Update matches
@@ -2454,13 +2638,10 @@ class DataFrameTable(DataTable):
2454
2638
 
2455
2639
  # Check if we're highlighting or un-highlighting
2456
2640
  if new_selected_count := self.selected_rows.count(True):
2457
- self.notify(
2458
- f"Toggled selection for [$accent]{new_selected_count}[/] rows",
2459
- title="Toggle",
2460
- )
2641
+ self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
2461
2642
 
2462
- # Refresh the highlighting (also restores default styles for unselected rows)
2463
- self._do_highlight()
2643
+ # Refresh the highlighting
2644
+ self._do_highlight(force=True)
2464
2645
 
2465
2646
  def _make_selections(self) -> None:
2466
2647
  """Make selections based on current matches or toggle current row selection."""
@@ -2481,10 +2662,10 @@ class DataFrameTable(DataTable):
2481
2662
  self.notify(f"Selected [$accent]{new_selected_count}[/] rows", title="Toggle")
2482
2663
 
2483
2664
  # Refresh the highlighting (also restores default styles for unselected rows)
2484
- self._do_highlight()
2665
+ self._do_highlight(force=True)
2485
2666
 
2486
- def _clear_selections(self) -> None:
2487
- """Clear all selected rows without removing them from the dataframe."""
2667
+ def _clear_selections_and_matches(self) -> None:
2668
+ """Clear all selected rows and matches without removing them from the dataframe."""
2488
2669
  # Check if any selected rows or matches
2489
2670
  if not any(self.selected_rows) and not self.matches:
2490
2671
  self.notify("No selections to clear", title="Clear", severity="warning")
@@ -2497,8 +2678,12 @@ class DataFrameTable(DataTable):
2497
2678
  # Save current state to history
2498
2679
  self._add_history("Cleared all selected rows")
2499
2680
 
2500
- # Clear all selections and refresh highlighting
2501
- self._do_highlight(clear=True)
2681
+ # Clear all selections
2682
+ self.selected_rows = [False] * len(self.df)
2683
+ self.matches = defaultdict(set)
2684
+
2685
+ # Refresh the highlighting to remove all highlights
2686
+ self._do_highlight(force=True)
2502
2687
 
2503
2688
  self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
2504
2689
 
@@ -2519,15 +2704,12 @@ class DataFrameTable(DataTable):
2519
2704
  # Recreate the table for display
2520
2705
  self._setup_table()
2521
2706
 
2522
- self.notify(
2523
- f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
2524
- title="Filter",
2525
- )
2707
+ self.notify(f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows", title="Filter")
2526
2708
 
2527
2709
  def _view_rows(self) -> None:
2528
2710
  """View rows.
2529
2711
 
2530
- If there are selected rows, view those rows.
2712
+ If there are selected rows or matches, view those rows.
2531
2713
  Otherwise, view based on the value of the currently selected cell.
2532
2714
  """
2533
2715
 
@@ -2543,7 +2725,7 @@ class DataFrameTable(DataTable):
2543
2725
  ridx = self.cursor_row_idx
2544
2726
  term = str(self.df.item(ridx, cidx))
2545
2727
 
2546
- self._do_view_rows((term, cidx, False, False))
2728
+ self._do_view_rows((term, cidx, False, True))
2547
2729
 
2548
2730
  def _view_rows_expr(self) -> None:
2549
2731
  """Open the filter screen to enter an expression."""
@@ -2572,10 +2754,10 @@ class DataFrameTable(DataTable):
2572
2754
  elif tentative_expr(term):
2573
2755
  # Support for polars expressions
2574
2756
  try:
2575
- expr = validate_expr(term, self.df, cidx)
2757
+ expr = validate_expr(term, self.df.columns, cidx)
2576
2758
  except Exception as e:
2577
2759
  self.notify(
2578
- f"Error validating Polars expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
2760
+ f"Error validating expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
2579
2761
  )
2580
2762
  return
2581
2763
  else:
@@ -2597,9 +2779,7 @@ class DataFrameTable(DataTable):
2597
2779
  term = f"(?i){term}"
2598
2780
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2599
2781
  self.notify(
2600
- f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
2601
- title="Filter",
2602
- severity="warning",
2782
+ f"Unknown column type [$warning]{dtype}[/]. Cast to string.", title="Filter", severity="warning"
2603
2783
  )
2604
2784
 
2605
2785
  # Lazyframe with row indices
@@ -2613,17 +2793,13 @@ class DataFrameTable(DataTable):
2613
2793
  try:
2614
2794
  df_filtered = lf.filter(expr).collect()
2615
2795
  except Exception as e:
2616
- self.notify(f"Failed to apply filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
2796
+ self.notify(f"Error applying filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
2617
2797
  self.histories.pop() # Remove last history entry
2618
2798
  return
2619
2799
 
2620
2800
  matched_count = len(df_filtered)
2621
2801
  if not matched_count:
2622
- self.notify(
2623
- f"No rows match the expression: [$success]{expr}[/]",
2624
- title="Filter",
2625
- severity="warning",
2626
- )
2802
+ self.notify(f"No rows match the expression: [$success]{expr}[/]", title="Filter", severity="warning")
2627
2803
  return
2628
2804
 
2629
2805
  # Add to history
@@ -2638,11 +2814,9 @@ class DataFrameTable(DataTable):
2638
2814
 
2639
2815
  # Recreate the table for display
2640
2816
  self._setup_table()
2817
+ self._do_highlight()
2641
2818
 
2642
- self.notify(
2643
- f"Filtered to [$accent]{matched_count}[/] matching rows",
2644
- title="Filter",
2645
- )
2819
+ self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
2646
2820
 
2647
2821
  def _cycle_cursor_type(self) -> None:
2648
2822
  """Cycle through cursor types: cell -> row -> column -> cell."""
@@ -2716,6 +2890,9 @@ class DataFrameTable(DataTable):
2716
2890
  filepath = Path(filename)
2717
2891
  ext = filepath.suffix.lower()
2718
2892
 
2893
+ # Add to history
2894
+ self._add_history(f"Saved dataframe to [$success]{filename}[/]")
2895
+
2719
2896
  try:
2720
2897
  if ext in (".xlsx", ".xls"):
2721
2898
  self._do_save_excel(filename)
@@ -2732,12 +2909,9 @@ class DataFrameTable(DataTable):
2732
2909
  self.filename = filename # Update current filename
2733
2910
  if not self._all_tabs:
2734
2911
  extra = "current tab with " if len(self.app.tabs) > 1 else ""
2735
- self.notify(
2736
- f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2737
- title="Save",
2738
- )
2912
+ self.notify(f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save")
2739
2913
  except Exception as e:
2740
- self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
2914
+ self.notify(f"Error saving [$error]{filename}[/]: {str(e)}", title="Save", severity="error")
2741
2915
  raise e
2742
2916
 
2743
2917
  def _do_save_excel(self, filename: str) -> None:
@@ -2757,12 +2931,103 @@ class DataFrameTable(DataTable):
2757
2931
 
2758
2932
  # From ConfirmScreen callback, so notify accordingly
2759
2933
  if self._all_tabs is True:
2934
+ self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save")
2935
+ else:
2760
2936
  self.notify(
2761
- f"Saved all tabs to [$success]{filename}[/]",
2762
- title="Save",
2937
+ f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save"
2763
2938
  )
2764
- else:
2939
+
2940
+ def _make_cell_clickable(self) -> None:
2941
+ """Make cells with URLs in the current column clickable.
2942
+
2943
+ Scans all loaded rows in the current column for cells containing URLs
2944
+ (starting with 'http://' or 'https://') and applies Textual link styling
2945
+ to make them clickable. Does not modify the dataframe.
2946
+
2947
+ Returns:
2948
+ None
2949
+ """
2950
+ cidx = self.cursor_col_idx
2951
+ col_key = self.cursor_col_key
2952
+ dtype = self.df.dtypes[cidx]
2953
+
2954
+ # Only process string columns
2955
+ if dtype != pl.String:
2956
+ return
2957
+
2958
+ # Count how many URLs were made clickable
2959
+ url_count = 0
2960
+
2961
+ # Iterate through all loaded rows and make URLs clickable
2962
+ for row in self.ordered_rows:
2963
+ cell_text: Text = self.get_cell(row.key, col_key)
2964
+ if cell_text.plain.startswith(("http://", "https://")):
2965
+ cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
2966
+ self.update_cell(row.key, col_key, cell_text)
2967
+ url_count += 1
2968
+
2969
+ if url_count:
2765
2970
  self.notify(
2766
- f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2767
- title="Save",
2971
+ f"Use Ctrl/Cmd click to open the links in column [$success]{col_key.value}[/]", title="Hyperlink"
2768
2972
  )
2973
+
2974
+ def _simple_sql(self) -> None:
2975
+ """Open the SQL interface screen."""
2976
+ self.app.push_screen(
2977
+ SimpleSqlScreen(self),
2978
+ callback=self._do_simple_sql,
2979
+ )
2980
+
2981
+ def _do_simple_sql(self, result) -> None:
2982
+ """Handle SQL result result from SimpleSqlScreen."""
2983
+ if result is None:
2984
+ return
2985
+ columns, where = result
2986
+
2987
+ sql = f"SELECT {columns} FROM self"
2988
+ if where:
2989
+ sql += f" WHERE {where}"
2990
+
2991
+ self._do_sql(sql)
2992
+
2993
+ def _advanced_sql(self) -> None:
2994
+ """Open the advanced SQL interface screen."""
2995
+ self.app.push_screen(
2996
+ AdvancedSqlScreen(self),
2997
+ callback=self._do_advanced_sql,
2998
+ )
2999
+
3000
+ def _do_advanced_sql(self, result) -> None:
3001
+ """Handle SQL result result from AdvancedSqlScreen."""
3002
+ if result is None:
3003
+ return
3004
+
3005
+ self._do_sql(result)
3006
+
3007
+ def _do_sql(self, sql: str) -> None:
3008
+ """Execute a SQL query directly.
3009
+
3010
+ Args:
3011
+ sql: The SQL query string to execute.
3012
+ """
3013
+ # Add to history
3014
+ self._add_history(f"SQL Query:\n[$accent]{sql}[/]")
3015
+
3016
+ # Execute the SQL query
3017
+ try:
3018
+ self.df = self.df.sql(sql)
3019
+ except Exception as e:
3020
+ self.notify(f"Error executing SQL query [$error]{sql}[/]: {str(e)}", title="SQL Query", severity="error")
3021
+ return
3022
+
3023
+ if not len(self.df):
3024
+ self.notify(f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning")
3025
+ return
3026
+
3027
+ # Recreate the table display
3028
+ self._setup_table()
3029
+
3030
+ self.notify(
3031
+ f"SQL query executed successfully. Now showing [$accent]{len(self.df)}[/] rows and [$accent]{len(self.df.columns)}[/] columns.",
3032
+ title="SQL Query",
3033
+ )