dataframe-textual 1.0.0__py3-none-any.whl → 1.4.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.
@@ -9,8 +9,10 @@ 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
15
+ from textual.render import measure
14
16
  from textual.widgets import DataTable, TabPane
15
17
  from textual.widgets._data_table import (
16
18
  CellDoesNotExist,
@@ -30,9 +32,11 @@ from .common import (
30
32
  format_row,
31
33
  get_next_item,
32
34
  rindex,
35
+ sleep_async,
33
36
  tentative_expr,
34
37
  validate_expr,
35
38
  )
39
+ from .sql_screen import AdvancedSqlScreen, SimpleSqlScreen
36
40
  from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
37
41
  from .yes_no_screen import (
38
42
  AddColumnScreen,
@@ -47,6 +51,15 @@ from .yes_no_screen import (
47
51
  SearchScreen,
48
52
  )
49
53
 
54
+ # Color for highlighting selections and matches
55
+ HIGHLIGHT_COLOR = "red"
56
+
57
+ # Warning threshold for loading rows
58
+ WARN_ROWS_THRESHOLD = 50_000
59
+
60
+ # Maximum width for string columns before truncation
61
+ STRING_WIDTH_CAP = 35
62
+
50
63
 
51
64
  @dataclass
52
65
  class History:
@@ -97,6 +110,8 @@ class DataFrameTable(DataTable):
97
110
  - **↑↓←→** - 🎯 Move cursor (cell/row/column)
98
111
  - **g** - ⬆️ Jump to first row
99
112
  - **G** - ⬇️ Jump to last row
113
+ - **Ctrl+F** - 📜 Page down
114
+ - **Ctrl+B** - 📜 Page up
100
115
  - **PgUp/PgDn** - 📜 Page up/down
101
116
 
102
117
  ## 👁️ View & Display
@@ -104,8 +119,13 @@ class DataFrameTable(DataTable):
104
119
  - **F** - 📊 Show frequency distribution
105
120
  - **s** - 📈 Show statistics for current column
106
121
  - **S** - 📊 Show statistics for entire dataframe
107
- - **K** - 🔄 Cycle cursor (cell → row → column → cell)
122
+ - **h** - 👁️ Hide current column
123
+ - **H** - 👀 Show all hidden rows/columns
124
+ - **_** - 📏 Expand column to full width
125
+ - **z** - 📌 Freeze rows and columns
108
126
  - **~** - 🏷️ Toggle row labels
127
+ - **,** - 🔢 Toggle thousand separator for numeric display
128
+ - **K** - 🔄 Cycle cursor (cell → row → column → cell)
109
129
 
110
130
  ## ↕️ Sorting
111
131
  - **[** - 🔼 Sort column ascending
@@ -117,8 +137,8 @@ class DataFrameTable(DataTable):
117
137
  - **\\\\** - 🔎 Search in current column using cursor value
118
138
  - **/** - 🔎 Find in current column with cursor value
119
139
  - **?** - 🔎 Find in current column with expression
120
- - **f** - 🌐 Global find using cursor value
121
- - **Ctrl+f** - 🌐 Global find with expression
140
+ - **;** - 🌐 Global find using cursor value
141
+ - **:** - 🌐 Global find with expression
122
142
  - **n** - ⬇️ Go to next match
123
143
  - **N** - ⬆️ Go to previous match
124
144
  - **v** - 👁️ View/filter rows by cell or selected rows
@@ -136,7 +156,11 @@ class DataFrameTable(DataTable):
136
156
  - **{** - ⬆️ Go to previous selected row
137
157
  - **}** - ⬇️ Go to next selected row
138
158
  - **"** - 📍 Filter to show only selected rows
139
- - **T** - 🧹 Clear all selections
159
+ - **T** - 🧹 Clear all selections and matches
160
+
161
+ ## 🔍 SQL Interface
162
+ - **l** - 💬 Open simple SQL interface (select columns & WHERE clause)
163
+ - **L** - 🔎 Open advanced SQL interface (full SQL queries)
140
164
 
141
165
  ## ✏️ Edit & Modify
142
166
  - **Double-click** - ✍️ Edit cell or rename column header
@@ -144,13 +168,13 @@ class DataFrameTable(DataTable):
144
168
  - **E** - 📊 Edit entire column with expression
145
169
  - **a** - ➕ Add empty column after current
146
170
  - **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
171
+ - **x** - Delete current row
172
+ - **X** - Delete row and those below
173
+ - **Ctrl+X** - Delete row and those above
174
+ - **delete** - ❌ Clear current cell (set to NULL)
150
175
  - **-** - ❌ Delete current column
151
176
  - **d** - 📋 Duplicate current column
152
- - **h** - 👁️ Hide current column
153
- - **H** - 👀 Show all hidden columns
177
+ - **D** - 📋 Duplicate current row
154
178
 
155
179
  ## 🎯 Reorder
156
180
  - **Shift+↑↓** - ⬆️⬇️ Move row up/down
@@ -166,37 +190,48 @@ class DataFrameTable(DataTable):
166
190
  - **@** - 🔗 Make URLs in current column clickable with Ctrl/Cmd
167
191
 
168
192
  ## 💾 Data Management
169
- - **z** - 📌 Freeze rows and columns
170
- - **,** - 🔢 Toggle thousand separator for numeric display
171
193
  - **c** - 📋 Copy cell to clipboard
172
194
  - **Ctrl+c** - 📊 Copy column to clipboard
173
195
  - **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
174
196
  - **Ctrl+s** - 💾 Save current tab to file
175
197
  - **u** - ↩️ Undo last action
176
- - **U** - 🔄 Reset to original data
198
+ - **U** - 🔄 Redo last undone action
199
+ - **Ctrl+U** - 🔁 Reset to initial state
177
200
  """).strip()
178
201
 
179
202
  # fmt: off
180
203
  BINDINGS = [
204
+ # Navigation
181
205
  ("g", "jump_top", "Jump to top"),
182
206
  ("G", "jump_bottom", "Jump to bottom"),
207
+ ("ctrl+f", "forward_page", "Page down"),
208
+ ("ctrl+b", "backward_page", "Page up"),
209
+ # Display
183
210
  ("h", "hide_column", "Hide column"),
184
- ("H", "show_column", "Show columns"),
211
+ ("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
212
+ ("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
213
+ ("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
214
+ ("z", "freeze_row_column", "Freeze rows/columns"),
215
+ ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
216
+ ("underscore", "expand_column", "Expand column to full width"), # `_`
217
+ # Copy
185
218
  ("c", "copy_cell", "Copy cell to clipboard"),
186
219
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
187
220
  ("ctrl+r", "copy_row", "Copy row to clipboard"),
221
+ # Save
188
222
  ("ctrl+s", "save_to_file", "Save to file"),
223
+ # Detail, Frequency, and Statistics
189
224
  ("enter", "view_row_detail", "View row details"),
190
- # Frequency & Statistics
191
225
  ("F", "show_frequency", "Show frequency"),
192
226
  ("s", "show_statistics", "Show statistics for column"),
193
227
  ("S", "show_statistics('dataframe')", "Show statistics for dataframe"),
194
- # Sorting
228
+ # Sort
195
229
  ("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
196
230
  ("right_square_bracket", "sort_descending", "Sort descending"), # `]`
197
- # View
231
+ # View & Filter
198
232
  ("v", "view_rows", "View rows"),
199
233
  ("V", "view_rows_expr", "View rows by expression"),
234
+ ("quotation_mark", "filter_rows", "Filter selected"), # `"`
200
235
  # Search
201
236
  ("backslash", "search_cursor_value", "Search column with cursor value"), # `\`
202
237
  ("vertical_line", "search_expr", "Search column with expression"), # `|`
@@ -205,26 +240,30 @@ class DataFrameTable(DataTable):
205
240
  # Find
206
241
  ("slash", "find_cursor_value", "Find in column with cursor value"), # `/`
207
242
  ("question_mark", "find_expr", "Find in column with expression"), # `?`
208
- ("f", "find_cursor_value('global')", "Global find with cursor value"), # `f`
209
- ("ctrl+f", "find_expr('global')", "Global find with expression"), # `Ctrl+F`
243
+ ("semicolon", "find_cursor_value('global')", "Global find with cursor value"), # `;`
244
+ ("colon", "find_expr('global')", "Global find with expression"), # `:`
210
245
  ("n", "next_match", "Go to next match"), # `n`
211
246
  ("N", "previous_match", "Go to previous match"), # `Shift+n`
212
247
  # Replace
213
248
  ("r", "replace", "Replace in column"), # `r`
214
249
  ("R", "replace_global", "Replace global"), # `Shift+R`
215
250
  # Selection
216
- ("apostrophe", "make_selections", "Toggle row selection"), # `'`
251
+ ("apostrophe", "toggle_row_selection", "Toggle row selection"), # `'`
217
252
  ("t", "toggle_selections", "Toggle all row selections"),
218
- ("T", "clear_selections", "Clear selections"),
219
- ("quotation_mark", "filter_selected_rows", "Filter selected"), # `"`
220
- # Edit
253
+ ("T", "clear_selections_and_matches", "Clear selections"),
254
+ # Delete
255
+ ("delete", "clear_cell", "Clear cell"),
221
256
  ("minus", "delete_column", "Delete column"), # `-`
222
257
  ("x", "delete_row", "Delete row"),
223
- ("X", "clear_cell", "Clear cell"),
258
+ ("X", "delete_row_and_below", "Delete row and those below"),
259
+ ("ctrl+x", "delete_row_and_up", "Delete row and those up"),
260
+ # Duplicate
224
261
  ("d", "duplicate_column", "Duplicate column"),
225
262
  ("D", "duplicate_row", "Duplicate row"),
263
+ # Edit
226
264
  ("e", "edit_cell", "Edit cell"),
227
265
  ("E", "edit_column", "Edit column"),
266
+ # Add
228
267
  ("a", "add_column", "Add column"),
229
268
  ("A", "add_column_expr", "Add column with expression"),
230
269
  # Reorder
@@ -233,30 +272,29 @@ class DataFrameTable(DataTable):
233
272
  ("shift+up", "move_row_up", "Move row up"),
234
273
  ("shift+down", "move_row_down", "Move row down"),
235
274
  # 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"), # `$`
275
+ ("number_sign", "cast_column_dtype('pl.Int64')", "Cast column dtype to integer"), # `#`
276
+ ("percent_sign", "cast_column_dtype('pl.Float64')", "Cast column dtype to float"), # `%`
277
+ ("exclamation_mark", "cast_column_dtype('pl.Boolean')", "Cast column dtype to bool"), # `!`
278
+ ("dollar_sign", "cast_column_dtype('pl.String')", "Cast column dtype to string"), # `$`
240
279
  ("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"), # `,`
280
+ # Sql
281
+ ("l", "simple_sql", "Simple SQL interface"),
282
+ ("L", "advanced_sql", "Advanced SQL interface"),
246
283
  # Undo/Redo
247
284
  ("u", "undo", "Undo"),
248
- ("U", "reset", "Reset to original"),
285
+ ("U", "redo", "Redo"),
286
+ ("ctrl+u", "reset", "Reset to initial state"),
249
287
  ]
250
288
  # fmt: on
251
289
 
252
- def __init__(self, df: pl.DataFrame | pl.LazyFrame, filename: str = "", name: str = "", **kwargs) -> None:
290
+ def __init__(self, df: pl.DataFrame, filename: str = "", name: str = "", **kwargs) -> None:
253
291
  """Initialize the DataFrameTable with a dataframe and manage all state.
254
292
 
255
293
  Sets up the table widget with display configuration, loads the dataframe, and
256
294
  initializes all state tracking variables for row/column operations.
257
295
 
258
296
  Args:
259
- df: The Polars DataFrame or LazyFrame to display and edit.
297
+ df: The Polars DataFrame to display and edit.
260
298
  filename: Optional source filename for the data (used in save operations). Defaults to "".
261
299
  name: Optional display name for the table tab. Defaults to "" (uses filename stem).
262
300
  **kwargs: Additional keyword arguments passed to the parent DataTable widget.
@@ -267,8 +305,8 @@ class DataFrameTable(DataTable):
267
305
  super().__init__(name=(name or Path(filename).stem), **kwargs)
268
306
 
269
307
  # DataFrame state
270
- self.lazyframe = df.lazy() # Original dataframe
271
- self.df = self.lazyframe.collect() # Internal/working dataframe
308
+ self.dataframe = df # Original dataframe
309
+ self.df = df # Internal/working dataframe
272
310
  self.filename = filename # Current filename
273
311
 
274
312
  # Pagination & Loading
@@ -287,8 +325,10 @@ class DataFrameTable(DataTable):
287
325
  self.fixed_rows = 0 # Number of fixed rows
288
326
  self.fixed_columns = 0 # Number of fixed columns
289
327
 
290
- # History stack for undo/redo
328
+ # History stack for undo
291
329
  self.histories: deque[History] = deque()
330
+ # Current history state for redo
331
+ self.history: History = None
292
332
 
293
333
  # Pending filename for save operations
294
334
  self._pending_filename = ""
@@ -391,17 +431,27 @@ class DataFrameTable(DataTable):
391
431
  matches.append((ridx, cidx))
392
432
  return matches
393
433
 
394
- def on_mount(self) -> None:
395
- """Initialize table display when the widget is mounted.
434
+ def get_row_key(self, row_idx: int) -> RowKey:
435
+ """Get the row key for a given table row index.
396
436
 
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.
437
+ Args:
438
+ row_idx: Row index in the table display.
399
439
 
400
440
  Returns:
401
- None
441
+ Corresponding row key as string.
402
442
  """
403
- # self._setup_table()
404
- pass
443
+ return self._row_locations.get_key(row_idx)
444
+
445
+ def get_column_key(self, col_idx: int) -> ColumnKey:
446
+ """Get the column key for a given table column index.
447
+
448
+ Args:
449
+ col_idx: Column index in the table display.
450
+
451
+ Returns:
452
+ Corresponding column key as string.
453
+ """
454
+ return self._column_locations.get_key(col_idx)
405
455
 
406
456
  def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
407
457
  """Determine if the given cell should be highlighted because of the cursor.
@@ -475,10 +525,10 @@ class DataFrameTable(DataTable):
475
525
  self.refresh_row(new_row)
476
526
  elif self.cursor_type == "row":
477
527
  self.refresh_row(old_coordinate.row)
478
- self.refresh_row(new_coordinate.row)
528
+ self._highlight_row(new_coordinate.row)
479
529
  elif self.cursor_type == "column":
480
530
  self.refresh_column(old_coordinate.column)
481
- self.refresh_column(new_coordinate.column)
531
+ self._highlight_column(new_coordinate.column)
482
532
 
483
533
  # Handle scrolling if needed
484
534
  if self._require_update_dimensions:
@@ -486,6 +536,34 @@ class DataFrameTable(DataTable):
486
536
  else:
487
537
  self._scroll_cursor_into_view()
488
538
 
539
+ def move_cursor_to(self, ridx: int, cidx: int) -> None:
540
+ """Move cursor based on the dataframe indices.
541
+
542
+ Args:
543
+ ridx: Row index (0-based) in the dataframe.
544
+ cidx: Column index (0-based) in the dataframe.
545
+ """
546
+ # Ensure the target row is loaded
547
+ if ridx >= self.loaded_rows:
548
+ self._load_rows(stop=ridx + self.BATCH_SIZE)
549
+
550
+ row_key = str(ridx)
551
+ col_key = self.df.columns[cidx]
552
+ row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
553
+ self.move_cursor(row=row_idx, column=col_idx)
554
+
555
+ def on_mount(self) -> None:
556
+ """Initialize table display when the widget is mounted.
557
+
558
+ Called by Textual when the widget is first added to the display tree.
559
+ Currently a placeholder as table setup is deferred until first use.
560
+
561
+ Returns:
562
+ None
563
+ """
564
+ # self._setup_table()
565
+ pass
566
+
489
567
  def on_key(self, event) -> None:
490
568
  """Handle key press events for pagination.
491
569
 
@@ -533,8 +611,16 @@ class DataFrameTable(DataTable):
533
611
 
534
612
  def action_jump_bottom(self) -> None:
535
613
  """Jump to the bottom of the table."""
536
- self._load_rows()
537
- self.move_cursor(row=self.row_count - 1)
614
+ self._load_rows(move_to_end=True)
615
+
616
+ def action_forward_page(self) -> None:
617
+ """Scroll down one page."""
618
+ super().action_page_down()
619
+ self._check_and_load_more()
620
+
621
+ def action_backward_page(self) -> None:
622
+ """Scroll up one page."""
623
+ super().action_page_up()
538
624
 
539
625
  def action_view_row_detail(self) -> None:
540
626
  """View details of the current row."""
@@ -548,9 +634,13 @@ class DataFrameTable(DataTable):
548
634
  """Hide the current column."""
549
635
  self._hide_column()
550
636
 
551
- def action_show_column(self) -> None:
552
- """Show all hidden columns."""
553
- self._show_column()
637
+ def action_expand_column(self) -> None:
638
+ """Expand the current column to its full width."""
639
+ self._expand_column()
640
+
641
+ def action_show_hidden_rows_columns(self) -> None:
642
+ """Show all hidden rows/columns."""
643
+ self._show_hidden_rows_columns()
554
644
 
555
645
  def action_sort_ascending(self) -> None:
556
646
  """Sort by current column in ascending order."""
@@ -640,22 +730,30 @@ class DataFrameTable(DataTable):
640
730
  """Replace values across all columns."""
641
731
  self._replace_global()
642
732
 
643
- def action_make_selections(self) -> None:
733
+ def action_toggle_row_selection(self) -> None:
644
734
  """Toggle selection for the current row."""
645
- self._make_selections()
735
+ self._toggle_row_selection()
646
736
 
647
737
  def action_toggle_selections(self) -> None:
648
738
  """Toggle all row selections."""
649
739
  self._toggle_selections()
650
740
 
651
- def action_filter_selected_rows(self) -> None:
741
+ def action_filter_rows(self) -> None:
652
742
  """Filter to show only selected rows."""
653
- self._filter_selected_rows()
743
+ self._filter_rows()
654
744
 
655
745
  def action_delete_row(self) -> None:
656
746
  """Delete the current row."""
657
747
  self._delete_row()
658
748
 
749
+ def action_delete_row_and_below(self) -> None:
750
+ """Delete the current row and those below."""
751
+ self._delete_row(more="below")
752
+
753
+ def action_delete_row_and_up(self) -> None:
754
+ """Delete the current row and those above."""
755
+ self._delete_row(more="above")
756
+
659
757
  def action_duplicate_column(self) -> None:
660
758
  """Duplicate the current column."""
661
759
  self._duplicate_column()
@@ -668,10 +766,14 @@ class DataFrameTable(DataTable):
668
766
  """Undo the last action."""
669
767
  self._undo()
670
768
 
769
+ def action_redo(self) -> None:
770
+ """Redo the last undone action."""
771
+ self._redo()
772
+
671
773
  def action_reset(self) -> None:
672
- """Reset to the original data."""
774
+ """Reset to the initial state."""
673
775
  self._setup_table(reset=True)
674
- self.notify("Restored original display", title="Reset")
776
+ self.notify("Restored initial state", title="Reset")
675
777
 
676
778
  def action_move_column_left(self) -> None:
677
779
  """Move the current column to the left."""
@@ -689,9 +791,9 @@ class DataFrameTable(DataTable):
689
791
  """Move the current row down."""
690
792
  self._move_row("down")
691
793
 
692
- def action_clear_selections(self) -> None:
693
- """Clear all row selections."""
694
- self._clear_selections()
794
+ def action_clear_selections_and_matches(self) -> None:
795
+ """Clear all row selections and matches."""
796
+ self._clear_selections_and_matches()
695
797
 
696
798
  def action_cycle_cursor_type(self) -> None:
697
799
  """Cycle through cursor types."""
@@ -781,40 +883,13 @@ class DataFrameTable(DataTable):
781
883
  """Go to the previous selected row."""
782
884
  self._previous_selected_row()
783
885
 
784
- def _make_cell_clickable(self) -> None:
785
- """Make cells with URLs in the current column clickable.
886
+ def action_simple_sql(self) -> None:
887
+ """Open the SQL interface screen."""
888
+ self._simple_sql()
786
889
 
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
- )
890
+ def action_advanced_sql(self) -> None:
891
+ """Open the advanced SQL interface screen."""
892
+ self._advanced_sql()
818
893
 
819
894
  def on_mouse_scroll_down(self, event) -> None:
820
895
  """Load more rows when scrolling down with mouse."""
@@ -827,9 +902,12 @@ class DataFrameTable(DataTable):
827
902
  Row keys are 0-based indices, which map directly to dataframe row indices.
828
903
  Column keys are header names from the dataframe.
829
904
  """
905
+ self.loaded_rows = 0
906
+ self.show_row_labels = True
907
+
830
908
  # Reset to original dataframe
831
909
  if reset:
832
- self.df = self.lazyframe.collect()
910
+ self.df = self.dataframe
833
911
  self.loaded_rows = 0
834
912
  self.sorted_columns = {}
835
913
  self.hidden_columns = set()
@@ -840,35 +918,109 @@ class DataFrameTable(DataTable):
840
918
  self.matches = defaultdict(set)
841
919
 
842
920
  # Lazy load up to INITIAL_BATCH_SIZE visible rows
843
- stop, visible_count = len(self.df), 0
921
+ stop, visible_count = self.INITIAL_BATCH_SIZE, 0
844
922
  for row_idx, visible in enumerate(self.visible_rows):
845
923
  if not visible:
846
924
  continue
847
925
  visible_count += 1
848
- if visible_count >= self.INITIAL_BATCH_SIZE:
849
- stop = row_idx + 1
926
+ if visible_count > self.INITIAL_BATCH_SIZE:
927
+ stop = row_idx + self.BATCH_SIZE
850
928
  break
929
+ else:
930
+ stop = row_idx + self.BATCH_SIZE
931
+
932
+ # # Ensure all selected rows or matches are loaded
933
+ # stop = max(stop, rindex(self.selected_rows, True) + 1)
934
+ # stop = max(stop, max(self.matches.keys(), default=0) + 1)
851
935
 
852
936
  # Save current cursor position before clearing
853
937
  row_idx, col_idx = self.cursor_coordinate
854
938
 
855
939
  self._setup_columns()
856
940
  self._load_rows(stop)
857
- self._do_highlight()
858
941
 
859
942
  # Restore cursor position
860
943
  if row_idx < len(self.rows) and col_idx < len(self.columns):
861
944
  self.move_cursor(row=row_idx, column=col_idx)
862
945
 
946
+ def _determine_column_widths(self) -> dict[str, int]:
947
+ """Determine optimal width for each column based on data type and content.
948
+
949
+ For String columns:
950
+ - Minimum width: length of column label
951
+ - Ideal width: maximum width of all cells in the column
952
+ - If space constrained: find appropriate width smaller than maximum
953
+
954
+ For non-String columns:
955
+ - Return None to let Textual auto-determine width
956
+
957
+ Returns:
958
+ dict[str, int]: Mapping of column name to width (None for auto-sizing columns).
959
+ """
960
+ column_widths = {}
961
+
962
+ # Get available width for the table (with some padding for borders/scrollbar)
963
+ available_width = self.size.width - 4 # Account for borders and scrollbar
964
+
965
+ # Calculate how much width we need for string columns first
966
+ string_cols = [col for col, dtype in zip(self.df.columns, self.df.dtypes) if dtype == pl.String]
967
+
968
+ # No string columns, let TextualDataTable auto-size all columns
969
+ if not string_cols:
970
+ return column_widths
971
+
972
+ # Sample a reasonable number of rows to calculate widths (don't scan entire dataframe)
973
+ sample_size = min(self.INITIAL_BATCH_SIZE, len(self.df))
974
+ sample_lf = self.df.lazy().slice(0, sample_size)
975
+
976
+ # Determine widths for each column
977
+ for col, dtype in zip(self.df.columns, self.df.dtypes):
978
+ if col in self.hidden_columns:
979
+ continue
980
+
981
+ # Get column label width
982
+ # Add padding for sort indicators if any
983
+ label_width = measure(self.app.console, col, 1) + 2
984
+
985
+ try:
986
+ # Get sample values from the column
987
+ sample_values = sample_lf.select(col).collect().get_column(col).to_list()
988
+
989
+ # Find maximum width in sample
990
+ max_cell_width = max(
991
+ (measure(self.app.console, str(val), 1) for val in sample_values if val),
992
+ default=label_width,
993
+ )
994
+
995
+ # Set column width to max of label and sampled data (capped at reasonable max)
996
+ max_width = max(label_width, max_cell_width)
997
+ except Exception:
998
+ # If any error, let Textual auto-size
999
+ max_width = label_width
1000
+
1001
+ if dtype == pl.String:
1002
+ column_widths[col] = max_width
1003
+
1004
+ available_width -= max_width
1005
+
1006
+ # If there's no more available width, auto-size remaining columns
1007
+ if available_width < 0:
1008
+ for col in column_widths:
1009
+ if column_widths[col] > STRING_WIDTH_CAP:
1010
+ column_widths[col] = STRING_WIDTH_CAP # Cap string columns
1011
+
1012
+ return column_widths
1013
+
863
1014
  def _setup_columns(self) -> None:
864
1015
  """Clear table and setup columns.
865
1016
 
866
1017
  Column keys are header names from the dataframe.
867
1018
  Column labels contain column names from the dataframe, with sort indicators if applicable.
868
1019
  """
869
- self.loaded_rows = 0
870
1020
  self.clear(columns=True)
871
- self.show_row_labels = True
1021
+
1022
+ # Get optimal column widths
1023
+ column_widths = self._determine_column_widths()
872
1024
 
873
1025
  # Add columns with justified headers
874
1026
  for col, dtype in zip(self.df.columns, self.df.dtypes):
@@ -886,43 +1038,120 @@ class DataFrameTable(DataTable):
886
1038
  else: # No break occurred, so column is not sorted
887
1039
  cell_value = col
888
1040
 
889
- self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col)
1041
+ # Get the width for this column (None means auto-size)
1042
+ width = column_widths.get(col)
890
1043
 
891
- def _load_rows(self, stop: int | None = None) -> None:
892
- """Load a batch of rows into the table.
1044
+ self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
893
1045
 
894
- Row keys are 0-based indices as strings, which map directly to dataframe row indices.
895
- Row labels are 1-based indices as strings.
1046
+ def _load_rows(self, stop: int | None = None, move_to_end: bool = False) -> None:
1047
+ """Load a batch of rows into the table (synchronous wrapper).
896
1048
 
897
1049
  Args:
898
- stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
1050
+ stop: Stop loading rows when this index is reached.
1051
+ If None, load until the end of the dataframe.
899
1052
  """
900
1053
  if stop is None or stop > len(self.df):
901
1054
  stop = len(self.df)
902
1055
 
1056
+ # If already loaded enough rows, just move cursor if needed
903
1057
  if stop <= self.loaded_rows:
1058
+ if move_to_end:
1059
+ self.move_cursor(row=self.row_count - 1)
1060
+
904
1061
  return
905
1062
 
906
- start = self.loaded_rows
907
- df_slice = self.df.slice(start, stop - start)
1063
+ # Warn user if loading a large number of rows
1064
+ elif (nrows := stop - self.loaded_rows) >= WARN_ROWS_THRESHOLD:
908
1065
 
909
- for row_idx, row in enumerate(df_slice.rows(), start):
910
- if not self.visible_rows[row_idx]:
911
- continue # Skip hidden rows
912
- vals, dtypes = [], []
913
- for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
914
- if col in self.hidden_columns:
915
- continue # Skip hidden columns
916
- vals.append(val)
917
- dtypes.append(dtype)
918
- formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
919
- # 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))
1066
+ def _continue(result: bool) -> None:
1067
+ if result:
1068
+ self._load_rows_async(stop, move_to_end=move_to_end)
921
1069
 
922
- # Update loaded rows count
923
- self.loaded_rows = stop
1070
+ self.app.push_screen(
1071
+ ConfirmScreen(
1072
+ f"Load {nrows} Rows",
1073
+ label="Loading a large number of rows may cause the application to become unresponsive. Do you want to continue?",
1074
+ ),
1075
+ callback=_continue,
1076
+ )
1077
+
1078
+ return
1079
+
1080
+ # Load rows asynchronously
1081
+ self._load_rows_async(stop, move_to_end=move_to_end)
924
1082
 
925
- # self.notify(f"Loaded [$accent]{stop}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
1083
+ @work(exclusive=True, description="Loading rows...")
1084
+ async def _load_rows_async(self, stop: int, move_to_end: bool = False) -> None:
1085
+ """Perform loading with async to avoid blocking.
1086
+
1087
+ Args:
1088
+ stop: Stop loading rows when this index is reached.
1089
+ move_to_end: If True, move cursor to the last loaded row after loading completes.
1090
+ """
1091
+ # Load rows in smaller chunks to avoid blocking
1092
+ if stop > self.loaded_rows:
1093
+ self.log(f"Async loading up to row {self.loaded_rows = }, {stop = }")
1094
+ # Load incrementally to avoid one big block
1095
+ # Load max BATCH_SIZE rows at a time
1096
+ chunk_size = min(self.BATCH_SIZE, stop - self.loaded_rows)
1097
+ next_stop = min(self.loaded_rows + chunk_size, stop)
1098
+ self._load_rows_batch(next_stop)
1099
+
1100
+ # If there's more to load, yield to event loop with delay
1101
+ if next_stop < stop:
1102
+ await sleep_async(0.05) # 50ms delay to allow UI updates
1103
+ self._load_rows_async(stop, move_to_end=move_to_end)
1104
+ return
1105
+
1106
+ # After loading completes, move cursor to end if requested
1107
+ if move_to_end:
1108
+ self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
1109
+
1110
+ def _load_rows_batch(self, stop: int) -> None:
1111
+ """Load a batch of rows into the table.
1112
+
1113
+ Row keys are 0-based indices as strings, which map directly to dataframe row indices.
1114
+ Row labels are 1-based indices as strings.
1115
+
1116
+ Args:
1117
+ stop: Stop loading rows when this index is reached.
1118
+ """
1119
+ try:
1120
+ start = self.loaded_rows
1121
+ df_slice = self.df.slice(start, stop - start)
1122
+
1123
+ for ridx, row in enumerate(df_slice.rows(), start):
1124
+ if not self.visible_rows[ridx]:
1125
+ continue # Skip hidden rows
1126
+
1127
+ is_selected = self.selected_rows[ridx]
1128
+ match_cols = self.matches.get(ridx, set())
1129
+
1130
+ vals, dtypes, styles = [], [], []
1131
+ for cidx, (val, col, dtype) in enumerate(zip(row, self.df.columns, self.df.dtypes)):
1132
+ if col in self.hidden_columns:
1133
+ continue # Skip hidden columns
1134
+
1135
+ vals.append(val)
1136
+ dtypes.append(dtype)
1137
+
1138
+ # Highlight entire row with selection or cells with matches
1139
+ styles.append(HIGHLIGHT_COLOR if is_selected or cidx in match_cols else None)
1140
+
1141
+ formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
1142
+
1143
+ # Always add labels so they can be shown/hidden via CSS
1144
+ self.add_row(*formatted_row, key=str(ridx), label=str(ridx + 1))
1145
+
1146
+ # Update loaded rows count
1147
+ self.loaded_rows = stop
1148
+
1149
+ # self.notify(f"Loaded [$accent]{self.loaded_rows}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
1150
+ self.log(f"Loaded {self.loaded_rows}/{len(self.df)} rows from `{self.filename or self.name}`")
1151
+
1152
+ except Exception as e:
1153
+ self.notify("Error loading rows", title="Load", severity="error")
1154
+ self.log(f"Error loading rows: {str(e)}")
926
1155
 
927
1156
  def _check_and_load_more(self) -> None:
928
1157
  """Check if we need to load more rows and load them."""
@@ -937,51 +1166,60 @@ class DataFrameTable(DataTable):
937
1166
  if bottom_visible_row >= self.loaded_rows - 10:
938
1167
  self._load_rows(self.loaded_rows + self.BATCH_SIZE)
939
1168
 
940
- def _do_highlight(self, clear: bool = False) -> None:
1169
+ # Highlighting
1170
+ def _do_highlight(self, force: bool = False) -> None:
941
1171
  """Update all rows, highlighting selected ones and restoring others to default.
942
1172
 
943
1173
  Args:
944
- clear: If True, clear all highlights.
1174
+ force: If True, clear all highlights and restore default styles.
945
1175
  """
946
- if clear:
947
- self.selected_rows = [False] * len(self.df)
948
- self.matches = defaultdict(set)
949
-
950
1176
  # Ensure all selected rows or matches are loaded
951
1177
  stop = rindex(self.selected_rows, True) + 1
952
1178
  stop = max(stop, max(self.matches.keys(), default=0) + 1)
953
1179
 
954
1180
  self._load_rows(stop)
955
- self._highlight_table()
1181
+ self._highlight_table(force)
956
1182
 
957
- def _highlight_table(self) -> None:
1183
+ def _highlight_table(self, force: bool = False) -> None:
958
1184
  """Highlight selected rows/cells in red."""
1185
+ if not force and not any(self.selected_rows) and not self.matches:
1186
+ return # Nothing to highlight
1187
+
959
1188
  # Update all rows based on selected state
960
1189
  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())
1190
+ ridx = int(row.key.value) # 0-based index
1191
+ is_selected = self.selected_rows[ridx]
1192
+ match_cols = self.matches.get(ridx, set())
1193
+
1194
+ if not force and not is_selected and not match_cols:
1195
+ continue # No highlight needed for this row
964
1196
 
965
1197
  # Update all cells in this row
966
1198
  for col_idx, col in enumerate(self.ordered_columns):
967
- cell_text: Text = self.get_cell(row.key, col.key)
1199
+ if not force and not is_selected and col_idx not in match_cols:
1200
+ continue # No highlight needed for this cell
968
1201
 
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
1202
+ cell_text: Text = self.get_cell(row.key, col.key)
1203
+ need_update = False
1204
+
1205
+ if is_selected or col_idx in match_cols:
1206
+ cell_text.style = HIGHLIGHT_COLOR
1207
+ need_update = True
1208
+ elif force:
1209
+ # Restore original style based on dtype
1210
+ dtype = self.df.schema[col.key.value]
1211
+ dc = DtypeConfig(dtype)
1212
+ cell_text.style = dc.style
1213
+ need_update = True
973
1214
 
974
1215
  # Update the cell in the table
975
- self.update_cell(row.key, col.key, cell_text)
1216
+ if need_update:
1217
+ self.update_cell(row.key, col.key, cell_text)
976
1218
 
977
1219
  # 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(
1220
+ def _create_history(self, description: str) -> None:
1221
+ """Create the initial history state."""
1222
+ return History(
985
1223
  description=description,
986
1224
  df=self.df,
987
1225
  filename=self.filename,
@@ -995,16 +1233,12 @@ class DataFrameTable(DataTable):
995
1233
  cursor_coordinate=self.cursor_coordinate,
996
1234
  matches={k: v.copy() for k, v in self.matches.items()},
997
1235
  )
998
- self.histories.append(history)
999
1236
 
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")
1237
+ def _apply_history(self, history: History) -> None:
1238
+ """Apply the current history state to the table."""
1239
+ if history is None:
1004
1240
  return
1005
1241
 
1006
- history = self.histories.pop()
1007
-
1008
1242
  # Restore state
1009
1243
  self.df = history.df
1010
1244
  self.filename = history.filename
@@ -1016,14 +1250,64 @@ class DataFrameTable(DataTable):
1016
1250
  self.fixed_rows = history.fixed_rows
1017
1251
  self.fixed_columns = history.fixed_columns
1018
1252
  self.cursor_coordinate = history.cursor_coordinate
1019
- self.matches = {k: v.copy() for k, v in history.matches.items()}
1253
+ self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
1020
1254
 
1021
- # Recreate the table for display
1255
+ # Recreate table for display
1022
1256
  self._setup_table()
1023
1257
 
1024
- # self.notify(f"Reverted: {history.description}", title="Undo")
1258
+ def _add_history(self, description: str) -> None:
1259
+ """Add the current state to the history stack.
1260
+
1261
+ Args:
1262
+ description: Description of the action for this history entry.
1263
+ """
1264
+ history = self._create_history(description)
1265
+ self.histories.append(history)
1266
+
1267
+ def _undo(self) -> None:
1268
+ """Undo the last action."""
1269
+ if not self.histories:
1270
+ self.notify("No actions to undo", title="Undo", severity="warning")
1271
+ return
1272
+
1273
+ # Pop the last history state for undo
1274
+ history = self.histories.pop()
1275
+
1276
+ # Save current state for redo
1277
+ self.history = self._create_history(history.description)
1278
+
1279
+ # Restore state
1280
+ self._apply_history(history)
1281
+
1282
+ self.notify(f"Reverted: {history.description}", title="Undo")
1283
+
1284
+ def _redo(self) -> None:
1285
+ """Redo the last undone action."""
1286
+ if self.history is None:
1287
+ self.notify("No actions to redo", title="Redo", severity="warning")
1288
+ return
1289
+
1290
+ description = self.history.description
1291
+
1292
+ # Save current state for undo
1293
+ self._add_history(description)
1294
+
1295
+ # Restore state
1296
+ self._apply_history(self.history)
1297
+
1298
+ # Clear redo state
1299
+ self.history = None
1300
+
1301
+ self.notify(f"Reapplied: {description}", title="Redo")
1302
+
1303
+ # Display
1304
+ def _cycle_cursor_type(self) -> None:
1305
+ """Cycle through cursor types: cell -> row -> column -> cell."""
1306
+ next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
1307
+ self.cursor_type = next_type
1308
+
1309
+ # self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
1025
1310
 
1026
- # View
1027
1311
  def _view_row_detail(self) -> None:
1028
1312
  """Open a modal screen to view the selected row's details."""
1029
1313
  ridx = self.cursor_row_idx
@@ -1071,49 +1355,12 @@ class DataFrameTable(DataTable):
1071
1355
  self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
1072
1356
 
1073
1357
  # Apply the pin settings to the table
1074
- if fixed_rows > 0:
1358
+ if fixed_rows >= 0:
1075
1359
  self.fixed_rows = fixed_rows
1076
- if fixed_columns > 0:
1360
+ if fixed_columns >= 0:
1077
1361
  self.fixed_columns = fixed_columns
1078
1362
 
1079
- self.notify(
1080
- f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
1081
- title="Pin",
1082
- )
1083
-
1084
- # Delete & Move
1085
- def _delete_column(self) -> None:
1086
- """Remove the currently selected column from the table."""
1087
- # Get the column to remove
1088
- col_idx = self.cursor_column
1089
- col_name = self.cursor_col_name
1090
- col_key = self.cursor_col_key
1091
-
1092
- # Add to history
1093
- self._add_history(f"Removed column [$success]{col_name}[/]")
1094
-
1095
- # Remove the column from the table display using the column name as key
1096
- self.remove_column(col_key)
1097
-
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)
1101
-
1102
- # Remove from sorted columns if present
1103
- if col_name in self.sorted_columns:
1104
- del self.sorted_columns[col_name]
1105
-
1106
- # Remove from matches
1107
- for row_idx in list(self.matches.keys()):
1108
- self.matches[row_idx].discard(col_idx)
1109
- # Remove empty entries
1110
- if not self.matches[row_idx]:
1111
- del self.matches[row_idx]
1112
-
1113
- # Remove from dataframe
1114
- self.df = self.df.drop(col_name)
1115
-
1116
- self.notify(f"Removed column [$success]{col_name}[/]", title="Delete")
1363
+ # self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
1117
1364
 
1118
1365
  def _hide_column(self) -> None:
1119
1366
  """Hide the currently selected column from the table display."""
@@ -1136,28 +1383,168 @@ class DataFrameTable(DataTable):
1136
1383
 
1137
1384
  # self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
1138
1385
 
1139
- def _show_column(self) -> None:
1140
- """Show all hidden columns by recreating the table with all dataframe columns."""
1386
+ def _expand_column(self) -> None:
1387
+ """Expand the current column to show the widest cell in the loaded data."""
1388
+ col_idx = self.cursor_col_idx
1389
+ col_key = self.cursor_col_key
1390
+ col_name = col_key.value
1391
+ dtype = self.df.dtypes[col_idx]
1392
+
1393
+ # Only expand string columns
1394
+ if dtype != pl.String:
1395
+ return
1396
+
1397
+ # Calculate the maximum width across all loaded rows
1398
+ max_width = len(col_name) + 2 # Start with column name width + padding
1399
+
1400
+ try:
1401
+ # Scan through all loaded rows that are visible to find max width
1402
+ for row_idx in range(self.loaded_rows):
1403
+ if not self.visible_rows[row_idx]:
1404
+ continue # Skip hidden rows
1405
+ cell_value = str(self.df.item(row_idx, col_idx))
1406
+ cell_width = measure(self.app.console, cell_value, 1)
1407
+ max_width = max(max_width, cell_width)
1408
+
1409
+ # Update the column width
1410
+ col = self.columns[col_key]
1411
+ col.width = max_width
1412
+
1413
+ # Force a refresh
1414
+ self._update_count += 1
1415
+ self._require_update_dimensions = True
1416
+ self.refresh(layout=True)
1417
+
1418
+ # self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
1419
+ except Exception as e:
1420
+ self.notify("Error expanding column", title="Expand", severity="error")
1421
+ self.log(f"Error expanding column `{col_name}`: {str(e)}")
1422
+
1423
+ def _show_hidden_rows_columns(self) -> None:
1424
+ """Show all hidden rows/columns by recreating the table."""
1141
1425
  # Get currently visible columns
1142
1426
  visible_cols = set(col.key for col in self.ordered_columns)
1143
1427
 
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]
1428
+ hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
1429
+ hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
1146
1430
 
1147
- if not hidden_cols:
1148
- self.notify("No hidden columns to show", title="Column", severity="warning")
1431
+ if not hidden_row_count and not hidden_col_count:
1432
+ self.notify("No hidden columns or rows to show", title="Show", severity="warning")
1149
1433
  return
1150
1434
 
1151
1435
  # Add to history
1152
- self._add_history(f"Showed {len(hidden_cols)} hidden column(s)")
1436
+ self._add_history("Showed hidden rows/columns")
1153
1437
 
1154
- # Clear hidden columns tracking
1438
+ # Clear hidden rows/columns tracking
1439
+ self.visible_rows = [True] * len(self.df)
1155
1440
  self.hidden_columns.clear()
1156
1441
 
1157
- # Recreate table with all columns
1442
+ # Recreate table for display
1158
1443
  self._setup_table()
1159
1444
 
1160
- self.notify(f"Showed [$accent]{len(hidden_cols)}[/] hidden column(s)", title="Column")
1445
+ self.notify(
1446
+ f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
1447
+ title="Show",
1448
+ )
1449
+
1450
+ def _make_cell_clickable(self) -> None:
1451
+ """Make cells with URLs in the current column clickable.
1452
+
1453
+ Scans all loaded rows in the current column for cells containing URLs
1454
+ (starting with 'http://' or 'https://') and applies Textual link styling
1455
+ to make them clickable. Does not modify the dataframe.
1456
+
1457
+ Returns:
1458
+ None
1459
+ """
1460
+ cidx = self.cursor_col_idx
1461
+ col_key = self.cursor_col_key
1462
+ dtype = self.df.dtypes[cidx]
1463
+
1464
+ # Only process string columns
1465
+ if dtype != pl.String:
1466
+ return
1467
+
1468
+ # Count how many URLs were made clickable
1469
+ url_count = 0
1470
+
1471
+ # Iterate through all loaded rows and make URLs clickable
1472
+ for row in self.ordered_rows:
1473
+ cell_text: Text = self.get_cell(row.key, col_key)
1474
+ if cell_text.plain.startswith(("http://", "https://")):
1475
+ cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
1476
+ self.update_cell(row.key, col_key, cell_text)
1477
+ url_count += 1
1478
+
1479
+ if url_count:
1480
+ self.notify(
1481
+ f"Use Ctrl/Cmd click to open the links in column [$success]{col_key.value}[/]", title="Hyperlink"
1482
+ )
1483
+
1484
+ # Delete & Move
1485
+ def _delete_column(self, more: str = None) -> None:
1486
+ """Remove the currently selected column from the table."""
1487
+ # Get the column to remove
1488
+ col_idx = self.cursor_column
1489
+ col_name = self.cursor_col_name
1490
+ col_key = self.cursor_col_key
1491
+
1492
+ col_names_to_remove = []
1493
+ col_keys_to_remove = []
1494
+
1495
+ # Remove all columns before the current column
1496
+ if more == "before":
1497
+ for i in range(col_idx + 1):
1498
+ col_key = self.get_column_key(i)
1499
+ col_names_to_remove.append(col_key.value)
1500
+ col_keys_to_remove.append(col_key)
1501
+
1502
+ message = f"Removed column [$success]{col_name}[/] and all columns before"
1503
+
1504
+ # Remove all columns after the current column
1505
+ elif more == "after":
1506
+ for i in range(col_idx, len(self.columns)):
1507
+ col_key = self.get_column_key(i)
1508
+ col_names_to_remove.append(col_key.value)
1509
+ col_keys_to_remove.append(col_key)
1510
+
1511
+ message = f"Removed column [$success]{col_name}[/] and all columns after"
1512
+
1513
+ # Remove only the current column
1514
+ else:
1515
+ col_names_to_remove.append(col_name)
1516
+ col_keys_to_remove.append(col_key)
1517
+ message = f"Removed column [$success]{col_name}[/]"
1518
+
1519
+ # Add to history
1520
+ self._add_history(message)
1521
+
1522
+ # Remove the columns from the table display using the column names as keys
1523
+ for ck in col_keys_to_remove:
1524
+ self.remove_column(ck)
1525
+
1526
+ # Move cursor left if we deleted the last column(s)
1527
+ last_col_idx = len(self.columns) - 1
1528
+ if col_idx > last_col_idx:
1529
+ self.move_cursor(column=last_col_idx)
1530
+
1531
+ # Remove from sorted columns if present
1532
+ for col_name in col_names_to_remove:
1533
+ if col_name in self.sorted_columns:
1534
+ del self.sorted_columns[col_name]
1535
+
1536
+ # Remove from matches
1537
+ col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
1538
+ for row_idx in list(self.matches.keys()):
1539
+ self.matches[row_idx].difference_update(col_indices_to_remove)
1540
+ # Remove empty entries
1541
+ if not self.matches[row_idx]:
1542
+ del self.matches[row_idx]
1543
+
1544
+ # Remove from dataframe
1545
+ self.df = self.df.drop(col_names_to_remove)
1546
+
1547
+ self.notify(message, title="Delete")
1161
1548
 
1162
1549
  def _duplicate_column(self) -> None:
1163
1550
  """Duplicate the currently selected column, inserting it right after the current column."""
@@ -1179,18 +1566,27 @@ class DataFrameTable(DataTable):
1179
1566
  list(cols_before) + [new_col_name] + list(cols_after)
1180
1567
  )
1181
1568
 
1182
- # Recreate the table for display
1569
+ # Update matches to account for new column
1570
+ new_matches = defaultdict(set)
1571
+ for row_idx, cols in self.matches.items():
1572
+ new_cols = set()
1573
+ for col_idx_in_set in cols:
1574
+ if col_idx_in_set <= cidx:
1575
+ new_cols.add(col_idx_in_set)
1576
+ else:
1577
+ new_cols.add(col_idx_in_set + 1)
1578
+ new_matches[row_idx] = new_cols
1579
+ self.matches = new_matches
1580
+
1581
+ # Recreate table for display
1183
1582
  self._setup_table()
1184
1583
 
1185
1584
  # Move cursor to the new duplicated column
1186
1585
  self.move_cursor(column=col_idx + 1)
1187
1586
 
1188
- self.notify(
1189
- f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
1190
- title="Duplicate",
1191
- )
1587
+ # self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
1192
1588
 
1193
- def _delete_row(self) -> None:
1589
+ def _delete_row(self, more: str = None) -> None:
1194
1590
  """Delete rows from the table and dataframe.
1195
1591
 
1196
1592
  Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
@@ -1206,11 +1602,27 @@ class DataFrameTable(DataTable):
1206
1602
  if selected:
1207
1603
  predicates[ridx] = False
1208
1604
 
1605
+ # Delete current row and those above
1606
+ elif more == "above":
1607
+ ridx = self.cursor_row_idx
1608
+ history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
1609
+ for i in range(ridx + 1):
1610
+ predicates[i] = False
1611
+
1612
+ # Delete current row and those below
1613
+ elif more == "below":
1614
+ ridx = self.cursor_row_idx
1615
+ history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
1616
+ for i in range(ridx, len(self.df)):
1617
+ if self.visible_rows[i]:
1618
+ predicates[i] = False
1619
+
1209
1620
  # Delete the row at the cursor
1210
1621
  else:
1211
1622
  ridx = self.cursor_row_idx
1212
1623
  history_desc = f"Deleted row [$success]{ridx + 1}[/]"
1213
- predicates[ridx] = False
1624
+ if self.visible_rows[ridx]:
1625
+ predicates[ridx] = False
1214
1626
 
1215
1627
  # Add to history
1216
1628
  self._add_history(history_desc)
@@ -1233,12 +1645,12 @@ class DataFrameTable(DataTable):
1233
1645
  # Clear all matches since row indices have changed
1234
1646
  self.matches = defaultdict(set)
1235
1647
 
1236
- # Recreate the table display
1648
+ # Recreate table for display
1237
1649
  self._setup_table()
1238
1650
 
1239
1651
  deleted_count = old_count - len(self.df)
1240
- if deleted_count > 1:
1241
- self.notify(f"Deleted {deleted_count} row(s)", title="Delete")
1652
+ if deleted_count > 0:
1653
+ self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
1242
1654
 
1243
1655
  def _duplicate_row(self) -> None:
1244
1656
  """Duplicate the currently selected row, inserting it right after the current row."""
@@ -1263,10 +1675,16 @@ class DataFrameTable(DataTable):
1263
1675
  self.selected_rows = new_selected_rows
1264
1676
  self.visible_rows = new_visible_rows
1265
1677
 
1266
- # Clear all matches since row indices have changed
1267
- self.matches = defaultdict(set)
1678
+ # Update matches to account for new row
1679
+ new_matches = defaultdict(set)
1680
+ for row_idx, cols in self.matches.items():
1681
+ if row_idx <= ridx:
1682
+ new_matches[row_idx] = cols
1683
+ else:
1684
+ new_matches[row_idx + 1] = cols
1685
+ self.matches = new_matches
1268
1686
 
1269
- # Recreate the table display
1687
+ # Recreate table for display
1270
1688
  self._setup_table()
1271
1689
 
1272
1690
  # Move cursor to the new duplicated row
@@ -1349,7 +1767,7 @@ class DataFrameTable(DataTable):
1349
1767
  return
1350
1768
  swap_idx = row_idx + 1
1351
1769
  else:
1352
- self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
1770
+ # Invalid direction
1353
1771
  return
1354
1772
 
1355
1773
  row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
@@ -1440,7 +1858,7 @@ class DataFrameTable(DataTable):
1440
1858
  # Update the dataframe
1441
1859
  self.df = df_sorted.drop(RIDX)
1442
1860
 
1443
- # Recreate the table for display
1861
+ # Recreate table for display
1444
1862
  self._setup_table()
1445
1863
 
1446
1864
  # Restore cursor position on the sorted column
@@ -1453,7 +1871,7 @@ class DataFrameTable(DataTable):
1453
1871
  cidx = self.cursor_col_idx if cidx is None else cidx
1454
1872
  col_name = self.df.columns[cidx]
1455
1873
 
1456
- # Save current state to history
1874
+ # Add to history
1457
1875
  self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
1458
1876
 
1459
1877
  # Push the edit modal screen
@@ -1499,9 +1917,10 @@ class DataFrameTable(DataTable):
1499
1917
  col_key = col_name
1500
1918
  self.update_cell(row_key, col_key, formatted_value, update_width=True)
1501
1919
 
1502
- self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1920
+ # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1503
1921
  except Exception as e:
1504
- self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
1922
+ self.notify("Error updating cell", title="Edit", severity="error")
1923
+ self.log(f"Error updating cell: {str(e)}")
1505
1924
 
1506
1925
  def _edit_column(self) -> None:
1507
1926
  """Open modal to edit the entire column with an expression."""
@@ -1528,9 +1947,10 @@ class DataFrameTable(DataTable):
1528
1947
  # Check if term is a valid expression
1529
1948
  elif tentative_expr(term):
1530
1949
  try:
1531
- expr = validate_expr(term, self.df, cidx)
1950
+ expr = validate_expr(term, self.df.columns, cidx)
1532
1951
  except Exception as e:
1533
- self.notify(f"Error validating expression [$error]{term}[/]: {str(e)}", title="Edit", severity="error")
1952
+ self.notify(f"Error validating expression [$error]{term}[/]", title="Edit", severity="error")
1953
+ self.log(f"Error validating expression `{term}`: {str(e)}")
1534
1954
  return
1535
1955
 
1536
1956
  # Otherwise, treat term as a literal value
@@ -1541,7 +1961,7 @@ class DataFrameTable(DataTable):
1541
1961
  expr = pl.lit(value)
1542
1962
  except Exception:
1543
1963
  self.notify(
1544
- f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1964
+ f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1545
1965
  title="Edit",
1546
1966
  severity="error",
1547
1967
  )
@@ -1554,16 +1974,18 @@ class DataFrameTable(DataTable):
1554
1974
  # Apply the expression to the column
1555
1975
  self.df = self.df.with_columns(expr.alias(col_name))
1556
1976
  except Exception as e:
1557
- self.notify(f"Failed to apply expression: [$error]{str(e)}[/]", title="Edit", severity="error")
1977
+ self.notify(
1978
+ f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
1979
+ title="Edit",
1980
+ severity="error",
1981
+ )
1982
+ self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
1558
1983
  return
1559
1984
 
1560
- # Recreate the table for display
1985
+ # Recreate table for display
1561
1986
  self._setup_table()
1562
1987
 
1563
- self.notify(
1564
- f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
1565
- title="Edit",
1566
- )
1988
+ # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
1567
1989
 
1568
1990
  def _rename_column(self) -> None:
1569
1991
  """Open modal to rename the selected column."""
@@ -1604,16 +2026,13 @@ class DataFrameTable(DataTable):
1604
2026
  self.hidden_columns.remove(col_name)
1605
2027
  self.hidden_columns.add(new_name)
1606
2028
 
1607
- # Recreate the table for display
2029
+ # Recreate table for display
1608
2030
  self._setup_table()
1609
2031
 
1610
2032
  # Move cursor to the renamed column
1611
2033
  self.move_cursor(column=col_idx)
1612
2034
 
1613
- self.notify(
1614
- f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
1615
- title="Column",
1616
- )
2035
+ # self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
1617
2036
 
1618
2037
  def _clear_cell(self) -> None:
1619
2038
  """Clear the current cell by setting its value to None."""
@@ -1641,9 +2060,10 @@ class DataFrameTable(DataTable):
1641
2060
 
1642
2061
  self.update_cell(row_key, col_key, formatted_value)
1643
2062
 
1644
- self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
2063
+ # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
1645
2064
  except Exception as e:
1646
- self.notify(f"Failed to clear cell: {str(e)}", title="Clear", severity="error")
2065
+ self.notify("Error clearing cell", title="Clear", severity="error")
2066
+ self.log(f"Error clearing cell: {str(e)}")
1647
2067
  raise e
1648
2068
 
1649
2069
  def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
@@ -1680,15 +2100,16 @@ class DataFrameTable(DataTable):
1680
2100
  select_cols = cols_before + [new_name] + cols_after
1681
2101
  self.df = self.df.with_columns(new_col).select(select_cols)
1682
2102
 
1683
- # Recreate the table display
2103
+ # Recreate table for display
1684
2104
  self._setup_table()
1685
2105
 
1686
2106
  # Move cursor to the new column
1687
2107
  self.move_cursor(column=cidx + 1)
1688
2108
 
1689
- self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
2109
+ # self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1690
2110
  except Exception as e:
1691
- self.notify(f"Failed to add column: {str(e)}", title="Add Column", severity="error")
2111
+ self.notify("Error adding column", title="Add Column", severity="error")
2112
+ self.log(f"Error adding column: {str(e)}")
1692
2113
  raise e
1693
2114
 
1694
2115
  def _add_column_expr(self) -> None:
@@ -1722,7 +2143,7 @@ class DataFrameTable(DataTable):
1722
2143
  select_cols = cols_before + [col_name] + cols_after
1723
2144
  self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
1724
2145
 
1725
- # Recreate the table display
2146
+ # Recreate table for display
1726
2147
  self._setup_table()
1727
2148
 
1728
2149
  # Move cursor to the new column
@@ -1730,53 +2151,33 @@ class DataFrameTable(DataTable):
1730
2151
 
1731
2152
  # self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
1732
2153
  except Exception as e:
1733
- self.notify(f"Failed to add column: [$error]{str(e)}[/]", title="Add Column", severity="error")
1734
- raise e
1735
-
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)
2154
+ self.notify("Error adding column", title="Add Column", severity="error")
2155
+ self.log(f"Error adding column `{col_name}`: {str(e)}")
1757
2156
 
1758
- def _cast_column_dtype(self, dtype: str | pl.DataType = pl.String) -> None:
2157
+ # Type Casting
2158
+ def _cast_column_dtype(self, dtype: str) -> None:
1759
2159
  """Cast the current column to a different data type.
1760
2160
 
1761
2161
  Args:
1762
- dtype: Target data type (string like "int", "float", "bool", "string" or Polars DataType)
2162
+ dtype: Target data type (string representation, e.g., "pl.String", "pl.Int64")
1763
2163
  """
1764
2164
  cidx = self.cursor_col_idx
1765
2165
  col_name = self.cursor_col_name
1766
2166
  current_dtype = self.df.dtypes[cidx]
1767
2167
 
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
2168
+ try:
2169
+ target_dtype = eval(dtype)
2170
+ except Exception:
2171
+ self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
2172
+ return
2173
+
2174
+ if current_dtype == target_dtype:
2175
+ self.notify(
2176
+ f"Column [$accent]{col_name}[/] is already of type [$success]{target_dtype}[/]",
2177
+ title="Cast",
2178
+ severity="warning",
2179
+ )
2180
+ return # No change needed
1780
2181
 
1781
2182
  # Add to history
1782
2183
  self._add_history(
@@ -1787,17 +2188,19 @@ class DataFrameTable(DataTable):
1787
2188
  # Cast the column using Polars
1788
2189
  self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
1789
2190
 
1790
- # Recreate the table display
2191
+ # Recreate table for display
1791
2192
  self._setup_table()
1792
2193
 
2194
+ self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
2195
+ except Exception as e:
1793
2196
  self.notify(
1794
- f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]",
2197
+ f"Error casting column [$accent]{col_name}[/] to [$error]{target_dtype}[/]",
1795
2198
  title="Cast",
2199
+ severity="error",
1796
2200
  )
1797
- except Exception as e:
1798
- self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
1799
- raise e
2201
+ self.log(f"Error casting column `{col_name}`: {str(e)}")
1800
2202
 
2203
+ # Search
1801
2204
  def _search_cursor_value(self) -> None:
1802
2205
  """Search with cursor value in current column."""
1803
2206
  cidx = self.cursor_col_idx
@@ -1805,7 +2208,7 @@ class DataFrameTable(DataTable):
1805
2208
  # Get the value of the currently selected cell
1806
2209
  term = NULL if self.cursor_value is None else str(self.cursor_value)
1807
2210
 
1808
- self._do_search((term, cidx, False, False))
2211
+ self._do_search((term, cidx, False, True))
1809
2212
 
1810
2213
  def _search_expr(self) -> None:
1811
2214
  """Search by expression."""
@@ -1824,6 +2227,7 @@ class DataFrameTable(DataTable):
1824
2227
  """Search for a term."""
1825
2228
  if result is None:
1826
2229
  return
2230
+
1827
2231
  term, cidx, match_nocase, match_whole = result
1828
2232
  col_name = self.df.columns[cidx]
1829
2233
 
@@ -1833,13 +2237,10 @@ class DataFrameTable(DataTable):
1833
2237
  # Support for polars expressions
1834
2238
  elif tentative_expr(term):
1835
2239
  try:
1836
- expr = validate_expr(term, self.df, cidx)
2240
+ expr = validate_expr(term, self.df.columns, cidx)
1837
2241
  except Exception as e:
1838
- self.notify(
1839
- f"Failed to validate Polars expression [$error]{term}[/]: {str(e)}",
1840
- title="Search",
1841
- severity="error",
1842
- )
2242
+ self.notify(f"Error validating expression [$error]{term}[/]", title="Search", severity="error")
2243
+ self.log(f"Error validating expression `{term}`: {str(e)}")
1843
2244
  return
1844
2245
 
1845
2246
  # Perform type-aware search based on column dtype
@@ -1862,7 +2263,7 @@ class DataFrameTable(DataTable):
1862
2263
  term = f"(?i){term}"
1863
2264
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
1864
2265
  self.notify(
1865
- f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2266
+ f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1866
2267
  title="Search",
1867
2268
  severity="warning",
1868
2269
  )
@@ -1876,17 +2277,14 @@ class DataFrameTable(DataTable):
1876
2277
  try:
1877
2278
  matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
1878
2279
  except Exception as e:
1879
- self.notify(
1880
- f"Error applying search filter: [$error]{str(e)}[/]",
1881
- title="Search",
1882
- severity="error",
1883
- )
2280
+ self.notify(f"Error applying search filter [$error]{term}[/]", title="Search", severity="error")
2281
+ self.log(f"Error applying search filter `{term}`: {str(e)}")
1884
2282
  return
1885
2283
 
1886
2284
  match_count = len(matches)
1887
2285
  if match_count == 0:
1888
2286
  self.notify(
1889
- f"No matches found for [$warning]{term}[/]. Try [$accent](?i)abc[/] for case-insensitive search.",
2287
+ f"No matches found for [$accent]{term}[/]. Try [$warning](?i)abc[/] for case-insensitive search.",
1890
2288
  title="Search",
1891
2289
  severity="warning",
1892
2290
  )
@@ -1899,11 +2297,13 @@ class DataFrameTable(DataTable):
1899
2297
  for m in matches:
1900
2298
  self.selected_rows[m] = True
1901
2299
 
1902
- # Highlight matches
1903
- self._do_highlight()
1904
-
2300
+ # Show notification immediately, then start highlighting
1905
2301
  self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
1906
2302
 
2303
+ # Recreate table for display
2304
+ self._setup_table()
2305
+
2306
+ # Find
1907
2307
  def _find_matches(
1908
2308
  self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
1909
2309
  ) -> dict[int, set[int]]:
@@ -1941,9 +2341,11 @@ class DataFrameTable(DataTable):
1941
2341
  expr = pl.col(col_name).is_null()
1942
2342
  elif tentative_expr(term):
1943
2343
  try:
1944
- expr = validate_expr(term, self.df, col_idx)
2344
+ expr = validate_expr(term, self.df.columns, col_idx)
1945
2345
  except Exception as e:
1946
- raise Exception(f"Error validating Polars expression: {str(e)}")
2346
+ self.notify(f"Error validating expression [$error]{term}[/]", title="Find", severity="error")
2347
+ self.log(f"Error validating expression `{term}`: {str(e)}")
2348
+ return matches
1947
2349
  else:
1948
2350
  if match_whole:
1949
2351
  term = f"^{term}$"
@@ -1955,7 +2357,9 @@ class DataFrameTable(DataTable):
1955
2357
  try:
1956
2358
  matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
1957
2359
  except Exception as e:
1958
- raise Exception(f"Error applying filter: {str(e)}")
2360
+ self.notify(f"Error applying filter: {expr}", title="Find", severity="error")
2361
+ self.log(f"Error applying filter: {str(e)}")
2362
+ return matches
1959
2363
 
1960
2364
  for ridx in matched_ridxs:
1961
2365
  matches[ridx].add(col_idx)
@@ -1973,9 +2377,9 @@ class DataFrameTable(DataTable):
1973
2377
 
1974
2378
  if scope == "column":
1975
2379
  cidx = self.cursor_col_idx
1976
- self._do_find((term, cidx, False, False))
2380
+ self._do_find((term, cidx, False, True))
1977
2381
  else:
1978
- self._do_find_global((term, None, False, False))
2382
+ self._do_find_global((term, None, False, True))
1979
2383
 
1980
2384
  def _find_expr(self, scope="column") -> None:
1981
2385
  """Open screen to find by expression.
@@ -2004,16 +2408,13 @@ class DataFrameTable(DataTable):
2004
2408
  try:
2005
2409
  matches = self._find_matches(term, cidx, match_nocase, match_whole)
2006
2410
  except Exception as e:
2007
- self.notify(
2008
- f"Error finding matches: [$error]{str(e)}[/]",
2009
- title="Find",
2010
- severity="error",
2011
- )
2411
+ self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
2412
+ self.log(f"Error finding matches for `{term}`: {str(e)}")
2012
2413
  return
2013
2414
 
2014
2415
  if not matches:
2015
2416
  self.notify(
2016
- f"No matches found for [$warning]{term}[/] in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
2417
+ f"No matches found for [$accent]{term}[/] in current column. Try [$warning](?i)abc[/] for case-insensitive search.",
2017
2418
  title="Find",
2018
2419
  severity="warning",
2019
2420
  )
@@ -2027,11 +2428,11 @@ class DataFrameTable(DataTable):
2027
2428
  for ridx, col_idxs in matches.items():
2028
2429
  self.matches[ridx].update(col_idxs)
2029
2430
 
2030
- # Highlight matches
2031
- self._do_highlight()
2032
-
2033
2431
  self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
2034
2432
 
2433
+ # Recreate table for display
2434
+ self._setup_table()
2435
+
2035
2436
  def _do_find_global(self, result) -> None:
2036
2437
  """Global find a term across all columns."""
2037
2438
  if result is None:
@@ -2041,16 +2442,13 @@ class DataFrameTable(DataTable):
2041
2442
  try:
2042
2443
  matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
2043
2444
  except Exception as e:
2044
- self.notify(
2045
- f"Error finding matches: [$error]{str(e)}[/]",
2046
- title="Find",
2047
- severity="error",
2048
- )
2445
+ self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
2446
+ self.log(f"Error finding matches for `{term}`: {str(e)}")
2049
2447
  return
2050
2448
 
2051
2449
  if not matches:
2052
2450
  self.notify(
2053
- f"No matches found for [$warning]{term}[/] in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
2451
+ f"No matches found for [$accent]{term}[/] in any column. Try [$warning](?i)abc[/] for case-insensitive search.",
2054
2452
  title="Global Find",
2055
2453
  severity="warning",
2056
2454
  )
@@ -2064,25 +2462,12 @@ class DataFrameTable(DataTable):
2064
2462
  for ridx, col_idxs in matches.items():
2065
2463
  self.matches[ridx].update(col_idxs)
2066
2464
 
2067
- # Highlight matches
2068
- self._do_highlight()
2069
-
2070
2465
  self.notify(
2071
- f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
2072
- title="Global Find",
2466
+ f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
2073
2467
  )
2074
2468
 
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)
2469
+ # Recreate table for display
2470
+ self._setup_table()
2086
2471
 
2087
2472
  def _next_match(self) -> None:
2088
2473
  """Move cursor to the next match."""
@@ -2099,12 +2484,12 @@ class DataFrameTable(DataTable):
2099
2484
  # Find the next match after current position
2100
2485
  for ridx, cidx in ordered_matches:
2101
2486
  if (ridx, cidx) > current_pos:
2102
- self._move_cursor(ridx, cidx)
2487
+ self.move_cursor_to(ridx, cidx)
2103
2488
  return
2104
2489
 
2105
2490
  # If no next match, wrap around to the first match
2106
2491
  first_ridx, first_cidx = ordered_matches[0]
2107
- self._move_cursor(first_ridx, first_cidx)
2492
+ self.move_cursor_to(first_ridx, first_cidx)
2108
2493
 
2109
2494
  def _previous_match(self) -> None:
2110
2495
  """Move cursor to the previous match."""
@@ -2149,12 +2534,12 @@ class DataFrameTable(DataTable):
2149
2534
  # Find the next selected row after current position
2150
2535
  for ridx in selected_row_indices:
2151
2536
  if ridx > current_ridx:
2152
- self._move_cursor(ridx, self.cursor_col_idx)
2537
+ self.move_cursor_to(ridx, self.cursor_col_idx)
2153
2538
  return
2154
2539
 
2155
2540
  # If no next selected row, wrap around to the first selected row
2156
2541
  first_ridx = selected_row_indices[0]
2157
- self._move_cursor(first_ridx, self.cursor_col_idx)
2542
+ self.move_cursor_to(first_ridx, self.cursor_col_idx)
2158
2543
 
2159
2544
  def _previous_selected_row(self) -> None:
2160
2545
  """Move cursor to the previous selected row."""
@@ -2171,18 +2556,19 @@ class DataFrameTable(DataTable):
2171
2556
  # Find the previous selected row before current position
2172
2557
  for ridx in reversed(selected_row_indices):
2173
2558
  if ridx < current_ridx:
2174
- self._move_cursor(ridx, self.cursor_col_idx)
2559
+ self.move_cursor_to(ridx, self.cursor_col_idx)
2175
2560
  return
2176
2561
 
2177
2562
  # If no previous selected row, wrap around to the last selected row
2178
2563
  last_ridx = selected_row_indices[-1]
2179
- self._move_cursor(last_ridx, self.cursor_col_idx)
2564
+ self.move_cursor_to(last_ridx, self.cursor_col_idx)
2180
2565
 
2566
+ # Replace
2181
2567
  def _replace(self) -> None:
2182
2568
  """Open replace screen for current column."""
2183
2569
  # Push the replace modal screen
2184
2570
  self.app.push_screen(
2185
- FindReplaceScreen(self),
2571
+ FindReplaceScreen(self, title="Find and Replace in Current Column"),
2186
2572
  callback=self._do_replace,
2187
2573
  )
2188
2574
 
@@ -2194,7 +2580,7 @@ class DataFrameTable(DataTable):
2194
2580
  """Open replace screen for all columns."""
2195
2581
  # Push the replace modal screen
2196
2582
  self.app.push_screen(
2197
- FindReplaceScreen(self),
2583
+ FindReplaceScreen(self, title="Global Find and Replace"),
2198
2584
  callback=self._do_replace_global,
2199
2585
  )
2200
2586
 
@@ -2222,23 +2608,19 @@ class DataFrameTable(DataTable):
2222
2608
  matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
2223
2609
 
2224
2610
  if not matches:
2225
- self.notify(
2226
- f"No matches found for [$warning]{term_find}[/]",
2227
- title="Replace",
2228
- severity="warning",
2229
- )
2611
+ self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
2230
2612
  return
2231
2613
 
2232
2614
  # Add to history
2233
2615
  self._add_history(
2234
- f"Replacing [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
2616
+ f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
2235
2617
  )
2236
2618
 
2237
2619
  # Update matches
2238
- self.matches = {ridx: set(col_idxs) for ridx, col_idxs in matches.items()}
2620
+ self.matches = {ridx: col_idxs.copy() for ridx, col_idxs in matches.items()}
2239
2621
 
2240
- # Highlight matches
2241
- self._do_highlight()
2622
+ # Recreate table for display
2623
+ self._setup_table()
2242
2624
 
2243
2625
  # Store state for interactive replacement using dataclass
2244
2626
  self._replace_state = ReplaceState(
@@ -2268,10 +2650,11 @@ class DataFrameTable(DataTable):
2268
2650
 
2269
2651
  except Exception as e:
2270
2652
  self.notify(
2271
- f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]: {str(e)}",
2653
+ f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
2272
2654
  title="Replace",
2273
2655
  severity="error",
2274
2656
  )
2657
+ self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
2275
2658
 
2276
2659
  def _do_replace_all(self, term_find: str, term_replace: str) -> None:
2277
2660
  """Replace all occurrences."""
@@ -2324,7 +2707,7 @@ class DataFrameTable(DataTable):
2324
2707
 
2325
2708
  state.replaced_occurrence += 1
2326
2709
 
2327
- # Recreate the table display
2710
+ # Recreate table for display
2328
2711
  self._setup_table()
2329
2712
 
2330
2713
  col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
@@ -2340,10 +2723,11 @@ class DataFrameTable(DataTable):
2340
2723
  self._show_next_replace_confirmation()
2341
2724
  except Exception as e:
2342
2725
  self.notify(
2343
- f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]: {str(e)}",
2726
+ f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
2344
2727
  title="Replace",
2345
2728
  severity="error",
2346
2729
  )
2730
+ self.log(f"Error in interactive replace: {str(e)}")
2347
2731
 
2348
2732
  def _show_next_replace_confirmation(self) -> None:
2349
2733
  """Show confirmation for next replacement."""
@@ -2360,12 +2744,12 @@ class DataFrameTable(DataTable):
2360
2744
  # Move cursor to next match
2361
2745
  ridx = state.rows[state.current_rpos]
2362
2746
  cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
2363
- self.move_cursor(row=ridx, column=cidx)
2747
+ self.move_cursor_to(ridx, cidx)
2364
2748
 
2365
2749
  state.current_occurrence += 1
2366
2750
 
2367
2751
  # Show confirmation
2368
- label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] (Occurrence {state.current_occurrence} of {state.total_occurrence})?"
2752
+ label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] ({state.current_occurrence} of {state.total_occurrence})?"
2369
2753
 
2370
2754
  self.app.push_screen(
2371
2755
  ConfirmScreen("Replace", label=label, maybe="Skip"),
@@ -2430,15 +2814,16 @@ class DataFrameTable(DataTable):
2430
2814
  if state.current_rpos >= len(state.rows):
2431
2815
  state.done = True
2432
2816
 
2433
- # Recreate the table display
2817
+ # Recreate table for display
2434
2818
  self._setup_table()
2435
2819
 
2436
2820
  # Show next confirmation
2437
2821
  self._show_next_replace_confirmation()
2438
2822
 
2823
+ # Selection & Match
2439
2824
  def _toggle_selections(self) -> None:
2440
2825
  """Toggle selected rows highlighting on/off."""
2441
- # Save current state to history
2826
+ # Add to history
2442
2827
  self._add_history("Toggled row selection")
2443
2828
 
2444
2829
  if False in self.visible_rows:
@@ -2454,37 +2839,37 @@ class DataFrameTable(DataTable):
2454
2839
 
2455
2840
  # Check if we're highlighting or un-highlighting
2456
2841
  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
- )
2842
+ self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
2461
2843
 
2462
- # Refresh the highlighting (also restores default styles for unselected rows)
2463
- self._do_highlight()
2844
+ # Recreate table for display
2845
+ self._setup_table()
2464
2846
 
2465
- def _make_selections(self) -> None:
2466
- """Make selections based on current matches or toggle current row selection."""
2467
- # Save current state to history
2847
+ def _toggle_row_selection(self) -> None:
2848
+ """Select/deselect current row."""
2849
+ # Add to history
2468
2850
  self._add_history("Toggled row selection")
2469
2851
 
2470
- if self.matches:
2471
- # There are matched cells - select rows with matches
2472
- for ridx in self.matches.keys():
2473
- self.selected_rows[ridx] = True
2474
- else:
2475
- # No matched cells - select/deselect the current row
2476
- ridx = self.cursor_row_idx
2477
- self.selected_rows[ridx] = not self.selected_rows[ridx]
2852
+ ridx = self.cursor_row_idx
2853
+ self.selected_rows[ridx] = not self.selected_rows[ridx]
2478
2854
 
2479
- # Check if we're highlighting or un-highlighting
2480
- if new_selected_count := self.selected_rows.count(True):
2481
- self.notify(f"Selected [$accent]{new_selected_count}[/] rows", title="Toggle")
2855
+ row_key = str(ridx)
2856
+ match_cols = self.matches.get(ridx, set())
2857
+ for col_idx, col in enumerate(self.ordered_columns):
2858
+ col_key = col.key
2859
+ cell_text: Text = self.get_cell(row_key, col_key)
2482
2860
 
2483
- # Refresh the highlighting (also restores default styles for unselected rows)
2484
- self._do_highlight()
2861
+ if self.selected_rows[ridx] or (col_idx in match_cols):
2862
+ cell_text.style = HIGHLIGHT_COLOR
2863
+ else:
2864
+ # Reset to default style based on dtype
2865
+ dtype = self.df.dtypes[col_idx]
2866
+ dc = DtypeConfig(dtype)
2867
+ cell_text.style = dc.style
2485
2868
 
2486
- def _clear_selections(self) -> None:
2487
- """Clear all selected rows without removing them from the dataframe."""
2869
+ self.update_cell(row_key, col_key, cell_text)
2870
+
2871
+ def _clear_selections_and_matches(self) -> None:
2872
+ """Clear all selected rows and matches without removing them from the dataframe."""
2488
2873
  # Check if any selected rows or matches
2489
2874
  if not any(self.selected_rows) and not self.matches:
2490
2875
  self.notify("No selections to clear", title="Clear", severity="warning")
@@ -2494,46 +2879,61 @@ class DataFrameTable(DataTable):
2494
2879
  1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
2495
2880
  )
2496
2881
 
2497
- # Save current state to history
2882
+ # Add to history
2498
2883
  self._add_history("Cleared all selected rows")
2499
2884
 
2500
- # Clear all selections and refresh highlighting
2501
- self._do_highlight(clear=True)
2885
+ # Clear all selections
2886
+ self.selected_rows = [False] * len(self.df)
2887
+ self.matches = defaultdict(set)
2888
+
2889
+ # Recreate table for display
2890
+ self._setup_table()
2502
2891
 
2503
2892
  self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
2504
2893
 
2505
- def _filter_selected_rows(self) -> None:
2506
- """Keep only the selected rows and remove unselected ones."""
2507
- selected_count = self.selected_rows.count(True)
2508
- if selected_count == 0:
2509
- self.notify("No rows selected to filter", title="Filter", severity="warning")
2894
+ # Filter & View
2895
+ def _filter_rows(self) -> None:
2896
+ """Keep only the rows with selections and matches, and remove others."""
2897
+ if not any(self.selected_rows) and not self.matches:
2898
+ self.notify("No rows to filter", title="Filter", severity="warning")
2510
2899
  return
2511
2900
 
2512
- # Save current state to history
2513
- self._add_history("Filtered to selected rows")
2901
+ filter_expr = [
2902
+ True if (selected or ridx in self.matches) else False for ridx, selected in enumerate(self.selected_rows)
2903
+ ]
2904
+
2905
+ # Add to history
2906
+ self._add_history("Filtered to selections and matches")
2907
+
2908
+ # Apply filter to dataframe with row indices
2909
+ df_filtered = self.df.with_row_index(RIDX).filter(filter_expr)
2910
+
2911
+ # Update selections and matches
2912
+ self.selected_rows = [self.selected_rows[ridx] for ridx in df_filtered[RIDX]]
2913
+ self.matches = {
2914
+ idx: self.matches[ridx].copy() for idx, ridx in enumerate(df_filtered[RIDX]) if ridx in self.matches
2915
+ }
2514
2916
 
2515
- # Update dataframe to only include selected rows
2516
- self.df = self.df.filter(self.selected_rows)
2517
- self.selected_rows = [True] * len(self.df)
2917
+ # Update dataframe
2918
+ self.df = df_filtered.drop(RIDX)
2518
2919
 
2519
- # Recreate the table for display
2920
+ # Recreate table for display
2520
2921
  self._setup_table()
2521
2922
 
2522
2923
  self.notify(
2523
- f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
2524
- title="Filter",
2924
+ f"Removed rows without selections or matches. Now showing [$accent]{len(self.df)}[/] rows", title="Filter"
2525
2925
  )
2526
2926
 
2527
2927
  def _view_rows(self) -> None:
2528
2928
  """View rows.
2529
2929
 
2530
- If there are selected rows, view those rows.
2930
+ If there are selected rows or matches, view those rows.
2531
2931
  Otherwise, view based on the value of the currently selected cell.
2532
2932
  """
2533
2933
 
2534
2934
  cidx = self.cursor_col_idx
2535
2935
 
2536
- # If there are selected rows or matches, use those
2936
+ # If there are rows with selections or matches, use those
2537
2937
  if any(self.selected_rows) or self.matches:
2538
2938
  term = [
2539
2939
  True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
@@ -2543,7 +2943,7 @@ class DataFrameTable(DataTable):
2543
2943
  ridx = self.cursor_row_idx
2544
2944
  term = str(self.df.item(ridx, cidx))
2545
2945
 
2546
- self._do_view_rows((term, cidx, False, False))
2946
+ self._do_view_rows((term, cidx, False, True))
2547
2947
 
2548
2948
  def _view_rows_expr(self) -> None:
2549
2949
  """Open the filter screen to enter an expression."""
@@ -2557,7 +2957,7 @@ class DataFrameTable(DataTable):
2557
2957
  )
2558
2958
 
2559
2959
  def _do_view_rows(self, result) -> None:
2560
- """Show only those matching rows and hide others. Do not modify the dataframe."""
2960
+ """Show only rows with selections or matches, and do hide others. Do not modify the dataframe."""
2561
2961
  if result is None:
2562
2962
  return
2563
2963
  term, cidx, match_nocase, match_whole = result
@@ -2572,11 +2972,10 @@ class DataFrameTable(DataTable):
2572
2972
  elif tentative_expr(term):
2573
2973
  # Support for polars expressions
2574
2974
  try:
2575
- expr = validate_expr(term, self.df, cidx)
2975
+ expr = validate_expr(term, self.df.columns, cidx)
2576
2976
  except Exception as e:
2577
- self.notify(
2578
- f"Error validating Polars expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
2579
- )
2977
+ self.notify(f"Error validating expression [$error]{term}[/]", title="Filter", severity="error")
2978
+ self.log(f"Error validating expression `{term}`: {str(e)}")
2580
2979
  return
2581
2980
  else:
2582
2981
  dtype = self.df.dtypes[cidx]
@@ -2597,9 +2996,7 @@ class DataFrameTable(DataTable):
2597
2996
  term = f"(?i){term}"
2598
2997
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2599
2998
  self.notify(
2600
- f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
2601
- title="Filter",
2602
- severity="warning",
2999
+ f"Unknown column type [$warning]{dtype}[/]. Cast to string.", title="Filter", severity="warning"
2603
3000
  )
2604
3001
 
2605
3002
  # Lazyframe with row indices
@@ -2613,17 +3010,14 @@ class DataFrameTable(DataTable):
2613
3010
  try:
2614
3011
  df_filtered = lf.filter(expr).collect()
2615
3012
  except Exception as e:
2616
- self.notify(f"Failed to apply filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
2617
3013
  self.histories.pop() # Remove last history entry
3014
+ self.notify(f"Error applying filter [$error]{expr}[/]", title="Filter", severity="error")
3015
+ self.log(f"Error applying filter `{expr}`: {str(e)}")
2618
3016
  return
2619
3017
 
2620
3018
  matched_count = len(df_filtered)
2621
3019
  if not matched_count:
2622
- self.notify(
2623
- f"No rows match the expression: [$success]{expr}[/]",
2624
- title="Filter",
2625
- severity="warning",
2626
- )
3020
+ self.notify(f"No rows match the expression: [$success]{expr}[/]", title="Filter", severity="warning")
2627
3021
  return
2628
3022
 
2629
3023
  # Add to history
@@ -2636,21 +3030,12 @@ class DataFrameTable(DataTable):
2636
3030
  if ridx not in filtered_row_indices:
2637
3031
  self.visible_rows[ridx] = False
2638
3032
 
2639
- # Recreate the table for display
3033
+ # Recreate table for display
2640
3034
  self._setup_table()
2641
3035
 
2642
- self.notify(
2643
- f"Filtered to [$accent]{matched_count}[/] matching rows",
2644
- title="Filter",
2645
- )
2646
-
2647
- def _cycle_cursor_type(self) -> None:
2648
- """Cycle through cursor types: cell -> row -> column -> cell."""
2649
- next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
2650
- self.cursor_type = next_type
2651
-
2652
- # self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
3036
+ self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
2653
3037
 
3038
+ # Copy & Save
2654
3039
  def _copy_to_clipboard(self, content: str, message: str) -> None:
2655
3040
  """Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
2656
3041
 
@@ -2716,6 +3101,9 @@ class DataFrameTable(DataTable):
2716
3101
  filepath = Path(filename)
2717
3102
  ext = filepath.suffix.lower()
2718
3103
 
3104
+ # Add to history
3105
+ self._add_history(f"Saved dataframe to [$success]{filename}[/]")
3106
+
2719
3107
  try:
2720
3108
  if ext in (".xlsx", ".xls"):
2721
3109
  self._do_save_excel(filename)
@@ -2728,17 +3116,14 @@ class DataFrameTable(DataTable):
2728
3116
  else:
2729
3117
  self.df.write_csv(filename)
2730
3118
 
2731
- self.lazyframe = self.df.lazy() # Update original dataframe
3119
+ self.dataframe = self.df # Update original dataframe
2732
3120
  self.filename = filename # Update current filename
2733
3121
  if not self._all_tabs:
2734
3122
  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
- )
3123
+ self.notify(f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save")
2739
3124
  except Exception as e:
2740
- self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
2741
- raise e
3125
+ self.notify(f"Error saving [$error]{filename}[/]", title="Save", severity="error")
3126
+ self.log(f"Error saving file `{filename}`: {str(e)}")
2742
3127
 
2743
3128
  def _do_save_excel(self, filename: str) -> None:
2744
3129
  """Save to an Excel file."""
@@ -2757,12 +3142,71 @@ class DataFrameTable(DataTable):
2757
3142
 
2758
3143
  # From ConfirmScreen callback, so notify accordingly
2759
3144
  if self._all_tabs is True:
2760
- self.notify(
2761
- f"Saved all tabs to [$success]{filename}[/]",
2762
- title="Save",
2763
- )
3145
+ self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save")
2764
3146
  else:
2765
3147
  self.notify(
2766
- f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2767
- title="Save",
3148
+ f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save"
2768
3149
  )
3150
+
3151
+ # SQL Interface
3152
+ def _simple_sql(self) -> None:
3153
+ """Open the SQL interface screen."""
3154
+ self.app.push_screen(
3155
+ SimpleSqlScreen(self),
3156
+ callback=self._do_simple_sql,
3157
+ )
3158
+
3159
+ def _do_simple_sql(self, result) -> None:
3160
+ """Handle SQL result result from SimpleSqlScreen."""
3161
+ if result is None:
3162
+ return
3163
+ columns, where = result
3164
+
3165
+ sql = f"SELECT {columns} FROM self"
3166
+ if where:
3167
+ sql += f" WHERE {where}"
3168
+
3169
+ self._do_sql(sql)
3170
+
3171
+ def _advanced_sql(self) -> None:
3172
+ """Open the advanced SQL interface screen."""
3173
+ self.app.push_screen(
3174
+ AdvancedSqlScreen(self),
3175
+ callback=self._do_advanced_sql,
3176
+ )
3177
+
3178
+ def _do_advanced_sql(self, result) -> None:
3179
+ """Handle SQL result result from AdvancedSqlScreen."""
3180
+ if result is None:
3181
+ return
3182
+
3183
+ self._do_sql(result)
3184
+
3185
+ def _do_sql(self, sql: str) -> None:
3186
+ """Execute a SQL query directly.
3187
+
3188
+ Args:
3189
+ sql: The SQL query string to execute.
3190
+ """
3191
+ # Add to history
3192
+ self._add_history(f"SQL Query:\n[$accent]{sql}[/]")
3193
+
3194
+ # Execute the SQL query
3195
+ try:
3196
+ self.df = self.df.sql(sql)
3197
+ except Exception as e:
3198
+ self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error")
3199
+ self.log(f"Error executing SQL query `{sql}`: {str(e)}")
3200
+ return
3201
+
3202
+ if not len(self.df):
3203
+ self.notify(f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning")
3204
+ return
3205
+
3206
+ # Recreate table for display
3207
+ self._setup_table()
3208
+
3209
+ self.notify(
3210
+ f"SQL query executed successfully. Now showing [$accent]{len(self.df)}[/] rows and [$accent]{len(self.df.columns)}[/] columns.",
3211
+ title="SQL Query",
3212
+ )