dataframe-textual 1.2.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
@@ -106,6 +121,7 @@ class DataFrameTable(DataTable):
106
121
  - **S** - 📊 Show statistics for entire dataframe
107
122
  - **h** - 👁️ Hide current column
108
123
  - **H** - 👀 Show all hidden rows/columns
124
+ - **_** - 📏 Expand column to full width
109
125
  - **z** - 📌 Freeze rows and columns
110
126
  - **~** - 🏷️ Toggle row labels
111
127
  - **,** - 🔢 Toggle thousand separator for numeric display
@@ -121,8 +137,8 @@ class DataFrameTable(DataTable):
121
137
  - **\\\\** - 🔎 Search in current column using cursor value
122
138
  - **/** - 🔎 Find in current column with cursor value
123
139
  - **?** - 🔎 Find in current column with expression
124
- - **f** - 🌐 Global find using cursor value
125
- - **Ctrl+f** - 🌐 Global find with expression
140
+ - **;** - 🌐 Global find using cursor value
141
+ - **:** - 🌐 Global find with expression
126
142
  - **n** - ⬇️ Go to next match
127
143
  - **N** - ⬆️ Go to previous match
128
144
  - **v** - 👁️ View/filter rows by cell or selected rows
@@ -142,6 +158,10 @@ class DataFrameTable(DataTable):
142
158
  - **"** - 📍 Filter to show only selected rows
143
159
  - **T** - 🧹 Clear all selections and matches
144
160
 
161
+ ## 🔍 SQL Interface
162
+ - **l** - 💬 Open simple SQL interface (select columns & WHERE clause)
163
+ - **L** - 🔎 Open advanced SQL interface (full SQL queries)
164
+
145
165
  ## ✏️ Edit & Modify
146
166
  - **Double-click** - ✍️ Edit cell or rename column header
147
167
  - **e** - ✍️ Edit current cell
@@ -153,12 +173,9 @@ class DataFrameTable(DataTable):
153
173
  - **Ctrl+X** - ❌ Delete row and those above
154
174
  - **delete** - ❌ Clear current cell (set to NULL)
155
175
  - **-** - ❌ Delete current column
156
- - **_** - ❌ Delete column and those after
157
- - **Ctrl+-** - ❌ Delete column and those before
158
176
  - **d** - 📋 Duplicate current column
159
177
  - **D** - 📋 Duplicate current row
160
178
 
161
-
162
179
  ## 🎯 Reorder
163
180
  - **Shift+↑↓** - ⬆️⬇️ Move row up/down
164
181
  - **Shift+←→** - ⬅️➡️ Move column left/right
@@ -187,6 +204,8 @@ class DataFrameTable(DataTable):
187
204
  # Navigation
188
205
  ("g", "jump_top", "Jump to top"),
189
206
  ("G", "jump_bottom", "Jump to bottom"),
207
+ ("ctrl+f", "forward_page", "Page down"),
208
+ ("ctrl+b", "backward_page", "Page up"),
190
209
  # Display
191
210
  ("h", "hide_column", "Hide column"),
192
211
  ("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
@@ -194,6 +213,7 @@ class DataFrameTable(DataTable):
194
213
  ("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
195
214
  ("z", "freeze_row_column", "Freeze rows/columns"),
196
215
  ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
216
+ ("underscore", "expand_column", "Expand column to full width"), # `_`
197
217
  # Copy
198
218
  ("c", "copy_cell", "Copy cell to clipboard"),
199
219
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
@@ -208,9 +228,10 @@ class DataFrameTable(DataTable):
208
228
  # Sort
209
229
  ("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
210
230
  ("right_square_bracket", "sort_descending", "Sort descending"), # `]`
211
- # View
231
+ # View & Filter
212
232
  ("v", "view_rows", "View rows"),
213
233
  ("V", "view_rows_expr", "View rows by expression"),
234
+ ("quotation_mark", "filter_rows", "Filter selected"), # `"`
214
235
  # Search
215
236
  ("backslash", "search_cursor_value", "Search column with cursor value"), # `\`
216
237
  ("vertical_line", "search_expr", "Search column with expression"), # `|`
@@ -219,23 +240,20 @@ class DataFrameTable(DataTable):
219
240
  # Find
220
241
  ("slash", "find_cursor_value", "Find in column with cursor value"), # `/`
221
242
  ("question_mark", "find_expr", "Find in column with expression"), # `?`
222
- ("f", "find_cursor_value('global')", "Global find with cursor value"), # `f`
223
- ("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"), # `:`
224
245
  ("n", "next_match", "Go to next match"), # `n`
225
246
  ("N", "previous_match", "Go to previous match"), # `Shift+n`
226
247
  # Replace
227
248
  ("r", "replace", "Replace in column"), # `r`
228
249
  ("R", "replace_global", "Replace global"), # `Shift+R`
229
250
  # Selection
230
- ("apostrophe", "make_selections", "Toggle row selection"), # `'`
251
+ ("apostrophe", "toggle_row_selection", "Toggle row selection"), # `'`
231
252
  ("t", "toggle_selections", "Toggle all row selections"),
232
253
  ("T", "clear_selections_and_matches", "Clear selections"),
233
- ("quotation_mark", "filter_selected_rows", "Filter selected"), # `"`
234
254
  # Delete
235
255
  ("delete", "clear_cell", "Clear cell"),
236
256
  ("minus", "delete_column", "Delete column"), # `-`
237
- ("underscore", "delete_column_and_after", "Delete column and those after"), # `_`
238
- ("ctrl+minus", "delete_column_and_before", "Delete column and those before"), # `Ctrl+-`
239
257
  ("x", "delete_row", "Delete row"),
240
258
  ("X", "delete_row_and_below", "Delete row and those below"),
241
259
  ("ctrl+x", "delete_row_and_up", "Delete row and those up"),
@@ -254,11 +272,14 @@ class DataFrameTable(DataTable):
254
272
  ("shift+up", "move_row_up", "Move row up"),
255
273
  ("shift+down", "move_row_down", "Move row down"),
256
274
  # Type Conversion
257
- ("number_sign", "cast_column_dtype('int')", "Cast column dtype to int"), # `#`
258
- ("percent_sign", "cast_column_dtype('float')", "Cast column dtype to float"), # `%`
259
- ("exclamation_mark", "cast_column_dtype('bool')", "Cast column dtype to bool"), # `!`
260
- ("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"), # `$`
261
279
  ("at", "make_cell_clickable", "Make cell clickable"), # `@`
280
+ # Sql
281
+ ("l", "simple_sql", "Simple SQL interface"),
282
+ ("L", "advanced_sql", "Advanced SQL interface"),
262
283
  # Undo/Redo
263
284
  ("u", "undo", "Undo"),
264
285
  ("U", "redo", "Redo"),
@@ -266,14 +287,14 @@ class DataFrameTable(DataTable):
266
287
  ]
267
288
  # fmt: on
268
289
 
269
- 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:
270
291
  """Initialize the DataFrameTable with a dataframe and manage all state.
271
292
 
272
293
  Sets up the table widget with display configuration, loads the dataframe, and
273
294
  initializes all state tracking variables for row/column operations.
274
295
 
275
296
  Args:
276
- df: The Polars DataFrame or LazyFrame to display and edit.
297
+ df: The Polars DataFrame to display and edit.
277
298
  filename: Optional source filename for the data (used in save operations). Defaults to "".
278
299
  name: Optional display name for the table tab. Defaults to "" (uses filename stem).
279
300
  **kwargs: Additional keyword arguments passed to the parent DataTable widget.
@@ -284,8 +305,8 @@ class DataFrameTable(DataTable):
284
305
  super().__init__(name=(name or Path(filename).stem), **kwargs)
285
306
 
286
307
  # DataFrame state
287
- self.lazyframe = df.lazy() # Original dataframe
288
- self.df = self.lazyframe.collect() # Internal/working dataframe
308
+ self.dataframe = df # Original dataframe
309
+ self.df = df # Internal/working dataframe
289
310
  self.filename = filename # Current filename
290
311
 
291
312
  # Pagination & Loading
@@ -522,6 +543,10 @@ class DataFrameTable(DataTable):
522
543
  ridx: Row index (0-based) in the dataframe.
523
544
  cidx: Column index (0-based) in the dataframe.
524
545
  """
546
+ # Ensure the target row is loaded
547
+ if ridx >= self.loaded_rows:
548
+ self._load_rows(stop=ridx + self.BATCH_SIZE)
549
+
525
550
  row_key = str(ridx)
526
551
  col_key = self.df.columns[cidx]
527
552
  row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
@@ -586,8 +611,16 @@ class DataFrameTable(DataTable):
586
611
 
587
612
  def action_jump_bottom(self) -> None:
588
613
  """Jump to the bottom of the table."""
589
- self._load_rows()
590
- 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()
591
624
 
592
625
  def action_view_row_detail(self) -> None:
593
626
  """View details of the current row."""
@@ -597,18 +630,14 @@ class DataFrameTable(DataTable):
597
630
  """Delete the current column."""
598
631
  self._delete_column()
599
632
 
600
- def action_delete_column_and_after(self) -> None:
601
- """Delete the current column and those after."""
602
- self._delete_column(more="after")
603
-
604
- def action_delete_column_and_before(self) -> None:
605
- """Delete the current column and those before."""
606
- self._delete_column(more="before")
607
-
608
633
  def action_hide_column(self) -> None:
609
634
  """Hide the current column."""
610
635
  self._hide_column()
611
636
 
637
+ def action_expand_column(self) -> None:
638
+ """Expand the current column to its full width."""
639
+ self._expand_column()
640
+
612
641
  def action_show_hidden_rows_columns(self) -> None:
613
642
  """Show all hidden rows/columns."""
614
643
  self._show_hidden_rows_columns()
@@ -701,17 +730,17 @@ class DataFrameTable(DataTable):
701
730
  """Replace values across all columns."""
702
731
  self._replace_global()
703
732
 
704
- def action_make_selections(self) -> None:
733
+ def action_toggle_row_selection(self) -> None:
705
734
  """Toggle selection for the current row."""
706
- self._make_selections()
735
+ self._toggle_row_selection()
707
736
 
708
737
  def action_toggle_selections(self) -> None:
709
738
  """Toggle all row selections."""
710
739
  self._toggle_selections()
711
740
 
712
- def action_filter_selected_rows(self) -> None:
741
+ def action_filter_rows(self) -> None:
713
742
  """Filter to show only selected rows."""
714
- self._filter_selected_rows()
743
+ self._filter_rows()
715
744
 
716
745
  def action_delete_row(self) -> None:
717
746
  """Delete the current row."""
@@ -854,6 +883,14 @@ class DataFrameTable(DataTable):
854
883
  """Go to the previous selected row."""
855
884
  self._previous_selected_row()
856
885
 
886
+ def action_simple_sql(self) -> None:
887
+ """Open the SQL interface screen."""
888
+ self._simple_sql()
889
+
890
+ def action_advanced_sql(self) -> None:
891
+ """Open the advanced SQL interface screen."""
892
+ self._advanced_sql()
893
+
857
894
  def on_mouse_scroll_down(self, event) -> None:
858
895
  """Load more rows when scrolling down with mouse."""
859
896
  self._check_and_load_more()
@@ -865,9 +902,12 @@ class DataFrameTable(DataTable):
865
902
  Row keys are 0-based indices, which map directly to dataframe row indices.
866
903
  Column keys are header names from the dataframe.
867
904
  """
905
+ self.loaded_rows = 0
906
+ self.show_row_labels = True
907
+
868
908
  # Reset to original dataframe
869
909
  if reset:
870
- self.df = self.lazyframe.collect()
910
+ self.df = self.dataframe
871
911
  self.loaded_rows = 0
872
912
  self.sorted_columns = {}
873
913
  self.hidden_columns = set()
@@ -878,35 +918,109 @@ class DataFrameTable(DataTable):
878
918
  self.matches = defaultdict(set)
879
919
 
880
920
  # Lazy load up to INITIAL_BATCH_SIZE visible rows
881
- stop, visible_count = len(self.df), 0
921
+ stop, visible_count = self.INITIAL_BATCH_SIZE, 0
882
922
  for row_idx, visible in enumerate(self.visible_rows):
883
923
  if not visible:
884
924
  continue
885
925
  visible_count += 1
886
- if visible_count >= self.INITIAL_BATCH_SIZE:
887
- stop = row_idx + 1
926
+ if visible_count > self.INITIAL_BATCH_SIZE:
927
+ stop = row_idx + self.BATCH_SIZE
888
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)
889
935
 
890
936
  # Save current cursor position before clearing
891
937
  row_idx, col_idx = self.cursor_coordinate
892
938
 
893
939
  self._setup_columns()
894
940
  self._load_rows(stop)
895
- self._do_highlight()
896
941
 
897
942
  # Restore cursor position
898
943
  if row_idx < len(self.rows) and col_idx < len(self.columns):
899
944
  self.move_cursor(row=row_idx, column=col_idx)
900
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
+
901
1014
  def _setup_columns(self) -> None:
902
1015
  """Clear table and setup columns.
903
1016
 
904
1017
  Column keys are header names from the dataframe.
905
1018
  Column labels contain column names from the dataframe, with sort indicators if applicable.
906
1019
  """
907
- self.loaded_rows = 0
908
1020
  self.clear(columns=True)
909
- self.show_row_labels = True
1021
+
1022
+ # Get optimal column widths
1023
+ column_widths = self._determine_column_widths()
910
1024
 
911
1025
  # Add columns with justified headers
912
1026
  for col, dtype in zip(self.df.columns, self.df.dtypes):
@@ -924,45 +1038,120 @@ class DataFrameTable(DataTable):
924
1038
  else: # No break occurred, so column is not sorted
925
1039
  cell_value = col
926
1040
 
927
- 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)
928
1043
 
929
- def _load_rows(self, stop: int | None = None) -> None:
930
- """Load a batch of rows into the table.
1044
+ self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
931
1045
 
932
- Row keys are 0-based indices as strings, which map directly to dataframe row indices.
933
- 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).
934
1048
 
935
1049
  Args:
936
- 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.
937
1052
  """
938
1053
  if stop is None or stop > len(self.df):
939
1054
  stop = len(self.df)
940
1055
 
1056
+ # If already loaded enough rows, just move cursor if needed
941
1057
  if stop <= self.loaded_rows:
1058
+ if move_to_end:
1059
+ self.move_cursor(row=self.row_count - 1)
1060
+
942
1061
  return
943
1062
 
944
- start = self.loaded_rows
945
- 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:
1065
+
1066
+ def _continue(result: bool) -> None:
1067
+ if result:
1068
+ self._load_rows_async(stop, move_to_end=move_to_end)
1069
+
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)
1082
+
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
946
1126
 
947
- for row_idx, row in enumerate(df_slice.rows(), start):
948
- if not self.visible_rows[row_idx]:
949
- continue # Skip hidden rows
1127
+ is_selected = self.selected_rows[ridx]
1128
+ match_cols = self.matches.get(ridx, set())
950
1129
 
951
- vals, dtypes = [], []
952
- for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
953
- if col in self.hidden_columns:
954
- continue # Skip hidden columns
955
- vals.append(val)
956
- dtypes.append(dtype)
957
- formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
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
958
1134
 
959
- # Always add labels so they can be shown/hidden via CSS
960
- self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
1135
+ vals.append(val)
1136
+ dtypes.append(dtype)
961
1137
 
962
- # Update loaded rows count
963
- self.loaded_rows = stop
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)
964
1140
 
965
- # self.notify(f"Loaded [$accent]{stop}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
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)}")
966
1155
 
967
1156
  def _check_and_load_more(self) -> None:
968
1157
  """Check if we need to load more rows and load them."""
@@ -977,6 +1166,7 @@ class DataFrameTable(DataTable):
977
1166
  if bottom_visible_row >= self.loaded_rows - 10:
978
1167
  self._load_rows(self.loaded_rows + self.BATCH_SIZE)
979
1168
 
1169
+ # Highlighting
980
1170
  def _do_highlight(self, force: bool = False) -> None:
981
1171
  """Update all rows, highlighting selected ones and restoring others to default.
982
1172
 
@@ -1013,7 +1203,7 @@ class DataFrameTable(DataTable):
1013
1203
  need_update = False
1014
1204
 
1015
1205
  if is_selected or col_idx in match_cols:
1016
- cell_text.style = "red"
1206
+ cell_text.style = HIGHLIGHT_COLOR
1017
1207
  need_update = True
1018
1208
  elif force:
1019
1209
  # Restore original style based on dtype
@@ -1062,7 +1252,7 @@ class DataFrameTable(DataTable):
1062
1252
  self.cursor_coordinate = history.cursor_coordinate
1063
1253
  self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
1064
1254
 
1065
- # Recreate the table for display
1255
+ # Recreate table for display
1066
1256
  self._setup_table()
1067
1257
 
1068
1258
  def _add_history(self, description: str) -> None:
@@ -1080,16 +1270,16 @@ class DataFrameTable(DataTable):
1080
1270
  self.notify("No actions to undo", title="Undo", severity="warning")
1081
1271
  return
1082
1272
 
1083
- # Save current state for redo
1084
- self.history = self._create_history("Redo state")
1085
-
1086
1273
  # Pop the last history state for undo
1087
1274
  history = self.histories.pop()
1088
1275
 
1276
+ # Save current state for redo
1277
+ self.history = self._create_history(history.description)
1278
+
1089
1279
  # Restore state
1090
1280
  self._apply_history(history)
1091
1281
 
1092
- # self.notify(f"Reverted: {history.description}", title="Undo")
1282
+ self.notify(f"Reverted: {history.description}", title="Undo")
1093
1283
 
1094
1284
  def _redo(self) -> None:
1095
1285
  """Redo the last undone action."""
@@ -1097,8 +1287,10 @@ class DataFrameTable(DataTable):
1097
1287
  self.notify("No actions to redo", title="Redo", severity="warning")
1098
1288
  return
1099
1289
 
1290
+ description = self.history.description
1291
+
1100
1292
  # Save current state for undo
1101
- self._add_history("Undo state")
1293
+ self._add_history(description)
1102
1294
 
1103
1295
  # Restore state
1104
1296
  self._apply_history(self.history)
@@ -1106,9 +1298,16 @@ class DataFrameTable(DataTable):
1106
1298
  # Clear redo state
1107
1299
  self.history = None
1108
1300
 
1109
- # self.notify(f"Reapplied: {history.description}", title="Redo")
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")
1110
1310
 
1111
- # View
1112
1311
  def _view_row_detail(self) -> None:
1113
1312
  """Open a modal screen to view the selected row's details."""
1114
1313
  ridx = self.cursor_row_idx
@@ -1161,11 +1360,127 @@ class DataFrameTable(DataTable):
1161
1360
  if fixed_columns >= 0:
1162
1361
  self.fixed_columns = fixed_columns
1163
1362
 
1363
+ # self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
1364
+
1365
+ def _hide_column(self) -> None:
1366
+ """Hide the currently selected column from the table display."""
1367
+ col_key = self.cursor_col_key
1368
+ col_name = col_key.value
1369
+ col_idx = self.cursor_column
1370
+
1371
+ # Add to history
1372
+ self._add_history(f"Hid column [$success]{col_name}[/]")
1373
+
1374
+ # Remove the column from the table display (but keep in dataframe)
1375
+ self.remove_column(col_key)
1376
+
1377
+ # Track hidden columns
1378
+ self.hidden_columns.add(col_name)
1379
+
1380
+ # Move cursor left if we hid the last column
1381
+ if col_idx >= len(self.columns):
1382
+ self.move_cursor(column=len(self.columns) - 1)
1383
+
1384
+ # self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
1385
+
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."""
1425
+ # Get currently visible columns
1426
+ visible_cols = set(col.key for col in self.ordered_columns)
1427
+
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)
1430
+
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")
1433
+ return
1434
+
1435
+ # Add to history
1436
+ self._add_history("Showed hidden rows/columns")
1437
+
1438
+ # Clear hidden rows/columns tracking
1439
+ self.visible_rows = [True] * len(self.df)
1440
+ self.hidden_columns.clear()
1441
+
1442
+ # Recreate table for display
1443
+ self._setup_table()
1444
+
1164
1445
  self.notify(
1165
- f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
1166
- title="Pin",
1446
+ f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
1447
+ title="Show",
1167
1448
  )
1168
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
+
1169
1484
  # Delete & Move
1170
1485
  def _delete_column(self, more: str = None) -> None:
1171
1486
  """Remove the currently selected column from the table."""
@@ -1184,7 +1499,7 @@ class DataFrameTable(DataTable):
1184
1499
  col_names_to_remove.append(col_key.value)
1185
1500
  col_keys_to_remove.append(col_key)
1186
1501
 
1187
- descr = f"Removed column [$success]{col_name}[/] and all columns before"
1502
+ message = f"Removed column [$success]{col_name}[/] and all columns before"
1188
1503
 
1189
1504
  # Remove all columns after the current column
1190
1505
  elif more == "after":
@@ -1193,16 +1508,16 @@ class DataFrameTable(DataTable):
1193
1508
  col_names_to_remove.append(col_key.value)
1194
1509
  col_keys_to_remove.append(col_key)
1195
1510
 
1196
- descr = f"Removed column [$success]{col_name}[/] and all columns after"
1511
+ message = f"Removed column [$success]{col_name}[/] and all columns after"
1197
1512
 
1198
1513
  # Remove only the current column
1199
1514
  else:
1200
1515
  col_names_to_remove.append(col_name)
1201
1516
  col_keys_to_remove.append(col_key)
1202
- descr = f"Removed column [$success]{col_name}[/]"
1517
+ message = f"Removed column [$success]{col_name}[/]"
1203
1518
 
1204
1519
  # Add to history
1205
- self._add_history(descr)
1520
+ self._add_history(message)
1206
1521
 
1207
1522
  # Remove the columns from the table display using the column names as keys
1208
1523
  for ck in col_keys_to_remove:
@@ -1229,55 +1544,7 @@ class DataFrameTable(DataTable):
1229
1544
  # Remove from dataframe
1230
1545
  self.df = self.df.drop(col_names_to_remove)
1231
1546
 
1232
- # self.notify(descr, title="Delete")
1233
-
1234
- def _hide_column(self) -> None:
1235
- """Hide the currently selected column from the table display."""
1236
- col_key = self.cursor_col_key
1237
- col_name = col_key.value
1238
- col_idx = self.cursor_column
1239
-
1240
- # Add to history
1241
- self._add_history(f"Hid column [$success]{col_name}[/]")
1242
-
1243
- # Remove the column from the table display (but keep in dataframe)
1244
- self.remove_column(col_key)
1245
-
1246
- # Track hidden columns
1247
- self.hidden_columns.add(col_name)
1248
-
1249
- # Move cursor left if we hid the last column
1250
- if col_idx >= len(self.columns):
1251
- self.move_cursor(column=len(self.columns) - 1)
1252
-
1253
- # self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
1254
-
1255
- def _show_hidden_rows_columns(self) -> None:
1256
- """Show all hidden rows/columns by recreating the table."""
1257
- # Get currently visible columns
1258
- visible_cols = set(col.key for col in self.ordered_columns)
1259
-
1260
- hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
1261
- hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
1262
-
1263
- if not hidden_row_count and not hidden_col_count:
1264
- self.notify("No hidden columns or rows to show", title="Show", severity="warning")
1265
- return
1266
-
1267
- # Add to history
1268
- self._add_history("Showed hidden rows/columns")
1269
-
1270
- # Clear hidden rows/columns tracking
1271
- self.visible_rows = [True] * len(self.df)
1272
- self.hidden_columns.clear()
1273
-
1274
- # Recreate table for display
1275
- self._setup_table()
1276
-
1277
- self.notify(
1278
- f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] hidden column(s)",
1279
- title="Show",
1280
- )
1547
+ self.notify(message, title="Delete")
1281
1548
 
1282
1549
  def _duplicate_column(self) -> None:
1283
1550
  """Duplicate the currently selected column, inserting it right after the current column."""
@@ -1311,16 +1578,13 @@ class DataFrameTable(DataTable):
1311
1578
  new_matches[row_idx] = new_cols
1312
1579
  self.matches = new_matches
1313
1580
 
1314
- # Recreate the table for display
1581
+ # Recreate table for display
1315
1582
  self._setup_table()
1316
1583
 
1317
1584
  # Move cursor to the new duplicated column
1318
1585
  self.move_cursor(column=col_idx + 1)
1319
1586
 
1320
- self.notify(
1321
- f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
1322
- title="Duplicate",
1323
- )
1587
+ # self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
1324
1588
 
1325
1589
  def _delete_row(self, more: str = None) -> None:
1326
1590
  """Delete rows from the table and dataframe.
@@ -1381,11 +1645,11 @@ class DataFrameTable(DataTable):
1381
1645
  # Clear all matches since row indices have changed
1382
1646
  self.matches = defaultdict(set)
1383
1647
 
1384
- # Recreate the table display
1648
+ # Recreate table for display
1385
1649
  self._setup_table()
1386
1650
 
1387
1651
  deleted_count = old_count - len(self.df)
1388
- if deleted_count > 1:
1652
+ if deleted_count > 0:
1389
1653
  self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
1390
1654
 
1391
1655
  def _duplicate_row(self) -> None:
@@ -1420,7 +1684,7 @@ class DataFrameTable(DataTable):
1420
1684
  new_matches[row_idx + 1] = cols
1421
1685
  self.matches = new_matches
1422
1686
 
1423
- # Recreate the table display
1687
+ # Recreate table for display
1424
1688
  self._setup_table()
1425
1689
 
1426
1690
  # Move cursor to the new duplicated row
@@ -1503,7 +1767,7 @@ class DataFrameTable(DataTable):
1503
1767
  return
1504
1768
  swap_idx = row_idx + 1
1505
1769
  else:
1506
- self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
1770
+ # Invalid direction
1507
1771
  return
1508
1772
 
1509
1773
  row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
@@ -1594,7 +1858,7 @@ class DataFrameTable(DataTable):
1594
1858
  # Update the dataframe
1595
1859
  self.df = df_sorted.drop(RIDX)
1596
1860
 
1597
- # Recreate the table for display
1861
+ # Recreate table for display
1598
1862
  self._setup_table()
1599
1863
 
1600
1864
  # Restore cursor position on the sorted column
@@ -1607,7 +1871,7 @@ class DataFrameTable(DataTable):
1607
1871
  cidx = self.cursor_col_idx if cidx is None else cidx
1608
1872
  col_name = self.df.columns[cidx]
1609
1873
 
1610
- # Save current state to history
1874
+ # Add to history
1611
1875
  self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
1612
1876
 
1613
1877
  # Push the edit modal screen
@@ -1653,9 +1917,10 @@ class DataFrameTable(DataTable):
1653
1917
  col_key = col_name
1654
1918
  self.update_cell(row_key, col_key, formatted_value, update_width=True)
1655
1919
 
1656
- self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1920
+ # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1657
1921
  except Exception as e:
1658
- 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)}")
1659
1924
 
1660
1925
  def _edit_column(self) -> None:
1661
1926
  """Open modal to edit the entire column with an expression."""
@@ -1682,9 +1947,10 @@ class DataFrameTable(DataTable):
1682
1947
  # Check if term is a valid expression
1683
1948
  elif tentative_expr(term):
1684
1949
  try:
1685
- expr = validate_expr(term, self.df, cidx)
1950
+ expr = validate_expr(term, self.df.columns, cidx)
1686
1951
  except Exception as e:
1687
- 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)}")
1688
1954
  return
1689
1955
 
1690
1956
  # Otherwise, treat term as a literal value
@@ -1695,7 +1961,7 @@ class DataFrameTable(DataTable):
1695
1961
  expr = pl.lit(value)
1696
1962
  except Exception:
1697
1963
  self.notify(
1698
- f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1964
+ f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1699
1965
  title="Edit",
1700
1966
  severity="error",
1701
1967
  )
@@ -1708,16 +1974,18 @@ class DataFrameTable(DataTable):
1708
1974
  # Apply the expression to the column
1709
1975
  self.df = self.df.with_columns(expr.alias(col_name))
1710
1976
  except Exception as e:
1711
- 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)}")
1712
1983
  return
1713
1984
 
1714
- # Recreate the table for display
1985
+ # Recreate table for display
1715
1986
  self._setup_table()
1716
1987
 
1717
- self.notify(
1718
- f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
1719
- title="Edit",
1720
- )
1988
+ # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
1721
1989
 
1722
1990
  def _rename_column(self) -> None:
1723
1991
  """Open modal to rename the selected column."""
@@ -1758,16 +2026,13 @@ class DataFrameTable(DataTable):
1758
2026
  self.hidden_columns.remove(col_name)
1759
2027
  self.hidden_columns.add(new_name)
1760
2028
 
1761
- # Recreate the table for display
2029
+ # Recreate table for display
1762
2030
  self._setup_table()
1763
2031
 
1764
2032
  # Move cursor to the renamed column
1765
2033
  self.move_cursor(column=col_idx)
1766
2034
 
1767
- self.notify(
1768
- f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
1769
- title="Column",
1770
- )
2035
+ # self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
1771
2036
 
1772
2037
  def _clear_cell(self) -> None:
1773
2038
  """Clear the current cell by setting its value to None."""
@@ -1795,9 +2060,10 @@ class DataFrameTable(DataTable):
1795
2060
 
1796
2061
  self.update_cell(row_key, col_key, formatted_value)
1797
2062
 
1798
- self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
2063
+ # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
1799
2064
  except Exception as e:
1800
- 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)}")
1801
2067
  raise e
1802
2068
 
1803
2069
  def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
@@ -1834,15 +2100,16 @@ class DataFrameTable(DataTable):
1834
2100
  select_cols = cols_before + [new_name] + cols_after
1835
2101
  self.df = self.df.with_columns(new_col).select(select_cols)
1836
2102
 
1837
- # Recreate the table display
2103
+ # Recreate table for display
1838
2104
  self._setup_table()
1839
2105
 
1840
2106
  # Move cursor to the new column
1841
2107
  self.move_cursor(column=cidx + 1)
1842
2108
 
1843
- self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
2109
+ # self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1844
2110
  except Exception as e:
1845
- 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)}")
1846
2113
  raise e
1847
2114
 
1848
2115
  def _add_column_expr(self) -> None:
@@ -1876,7 +2143,7 @@ class DataFrameTable(DataTable):
1876
2143
  select_cols = cols_before + [col_name] + cols_after
1877
2144
  self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
1878
2145
 
1879
- # Recreate the table display
2146
+ # Recreate table for display
1880
2147
  self._setup_table()
1881
2148
 
1882
2149
  # Move cursor to the new column
@@ -1884,53 +2151,33 @@ class DataFrameTable(DataTable):
1884
2151
 
1885
2152
  # self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
1886
2153
  except Exception as e:
1887
- self.notify(f"Failed to add column: [$error]{str(e)}[/]", title="Add Column", severity="error")
1888
- raise e
1889
-
1890
- def _string_to_polars_dtype(self, dtype_str: str) -> pl.DataType:
1891
- """Convert string type name to Polars DataType.
1892
-
1893
- Args:
1894
- dtype_str: String representation of the type ("string", "int", "float", "bool")
1895
-
1896
- Returns:
1897
- Corresponding Polars DataType
2154
+ self.notify("Error adding column", title="Add Column", severity="error")
2155
+ self.log(f"Error adding column `{col_name}`: {str(e)}")
1898
2156
 
1899
- Raises:
1900
- ValueError: If the type string is not recognized
1901
- """
1902
- dtype_map = {
1903
- "string": pl.String,
1904
- "int": pl.Int64,
1905
- "float": pl.Float64,
1906
- "bool": pl.Boolean,
1907
- }
1908
-
1909
- dtype_lower = dtype_str.lower().strip()
1910
- return dtype_map.get(dtype_lower)
1911
-
1912
- def _cast_column_dtype(self, dtype: str | pl.DataType = pl.String) -> None:
2157
+ # Type Casting
2158
+ def _cast_column_dtype(self, dtype: str) -> None:
1913
2159
  """Cast the current column to a different data type.
1914
2160
 
1915
2161
  Args:
1916
- 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")
1917
2163
  """
1918
2164
  cidx = self.cursor_col_idx
1919
2165
  col_name = self.cursor_col_name
1920
2166
  current_dtype = self.df.dtypes[cidx]
1921
2167
 
1922
- # Convert string dtype to Polars DataType if needed
1923
- if isinstance(dtype, str):
1924
- target_dtype = self._string_to_polars_dtype(dtype)
1925
- if target_dtype is None:
1926
- self.notify(
1927
- f"Use string for unknown data type: {dtype}. Supported types: {', '.join(self._string_to_polars_dtype.keys())}",
1928
- title="Cast",
1929
- severity="warning",
1930
- )
1931
- target_dtype = pl.String
1932
- else:
1933
- 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
1934
2181
 
1935
2182
  # Add to history
1936
2183
  self._add_history(
@@ -1941,17 +2188,19 @@ class DataFrameTable(DataTable):
1941
2188
  # Cast the column using Polars
1942
2189
  self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
1943
2190
 
1944
- # Recreate the table display
2191
+ # Recreate table for display
1945
2192
  self._setup_table()
1946
2193
 
2194
+ self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
2195
+ except Exception as e:
1947
2196
  self.notify(
1948
- f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]",
2197
+ f"Error casting column [$accent]{col_name}[/] to [$error]{target_dtype}[/]",
1949
2198
  title="Cast",
2199
+ severity="error",
1950
2200
  )
1951
- except Exception as e:
1952
- self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
1953
- raise e
2201
+ self.log(f"Error casting column `{col_name}`: {str(e)}")
1954
2202
 
2203
+ # Search
1955
2204
  def _search_cursor_value(self) -> None:
1956
2205
  """Search with cursor value in current column."""
1957
2206
  cidx = self.cursor_col_idx
@@ -1959,7 +2208,7 @@ class DataFrameTable(DataTable):
1959
2208
  # Get the value of the currently selected cell
1960
2209
  term = NULL if self.cursor_value is None else str(self.cursor_value)
1961
2210
 
1962
- self._do_search((term, cidx, False, False))
2211
+ self._do_search((term, cidx, False, True))
1963
2212
 
1964
2213
  def _search_expr(self) -> None:
1965
2214
  """Search by expression."""
@@ -1978,6 +2227,7 @@ class DataFrameTable(DataTable):
1978
2227
  """Search for a term."""
1979
2228
  if result is None:
1980
2229
  return
2230
+
1981
2231
  term, cidx, match_nocase, match_whole = result
1982
2232
  col_name = self.df.columns[cidx]
1983
2233
 
@@ -1987,13 +2237,10 @@ class DataFrameTable(DataTable):
1987
2237
  # Support for polars expressions
1988
2238
  elif tentative_expr(term):
1989
2239
  try:
1990
- expr = validate_expr(term, self.df, cidx)
2240
+ expr = validate_expr(term, self.df.columns, cidx)
1991
2241
  except Exception as e:
1992
- self.notify(
1993
- f"Failed to validate Polars expression [$error]{term}[/]: {str(e)}",
1994
- title="Search",
1995
- severity="error",
1996
- )
2242
+ self.notify(f"Error validating expression [$error]{term}[/]", title="Search", severity="error")
2243
+ self.log(f"Error validating expression `{term}`: {str(e)}")
1997
2244
  return
1998
2245
 
1999
2246
  # Perform type-aware search based on column dtype
@@ -2016,7 +2263,7 @@ class DataFrameTable(DataTable):
2016
2263
  term = f"(?i){term}"
2017
2264
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2018
2265
  self.notify(
2019
- f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2266
+ f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2020
2267
  title="Search",
2021
2268
  severity="warning",
2022
2269
  )
@@ -2030,17 +2277,14 @@ class DataFrameTable(DataTable):
2030
2277
  try:
2031
2278
  matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
2032
2279
  except Exception as e:
2033
- self.notify(
2034
- f"Error applying search filter: [$error]{str(e)}[/]",
2035
- title="Search",
2036
- severity="error",
2037
- )
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)}")
2038
2282
  return
2039
2283
 
2040
2284
  match_count = len(matches)
2041
2285
  if match_count == 0:
2042
2286
  self.notify(
2043
- 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.",
2044
2288
  title="Search",
2045
2289
  severity="warning",
2046
2290
  )
@@ -2053,11 +2297,13 @@ class DataFrameTable(DataTable):
2053
2297
  for m in matches:
2054
2298
  self.selected_rows[m] = True
2055
2299
 
2056
- # Highlight matches
2057
- self._do_highlight()
2058
-
2300
+ # Show notification immediately, then start highlighting
2059
2301
  self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
2060
2302
 
2303
+ # Recreate table for display
2304
+ self._setup_table()
2305
+
2306
+ # Find
2061
2307
  def _find_matches(
2062
2308
  self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
2063
2309
  ) -> dict[int, set[int]]:
@@ -2095,9 +2341,11 @@ class DataFrameTable(DataTable):
2095
2341
  expr = pl.col(col_name).is_null()
2096
2342
  elif tentative_expr(term):
2097
2343
  try:
2098
- expr = validate_expr(term, self.df, col_idx)
2344
+ expr = validate_expr(term, self.df.columns, col_idx)
2099
2345
  except Exception as e:
2100
- 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
2101
2349
  else:
2102
2350
  if match_whole:
2103
2351
  term = f"^{term}$"
@@ -2109,7 +2357,9 @@ class DataFrameTable(DataTable):
2109
2357
  try:
2110
2358
  matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
2111
2359
  except Exception as e:
2112
- 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
2113
2363
 
2114
2364
  for ridx in matched_ridxs:
2115
2365
  matches[ridx].add(col_idx)
@@ -2127,9 +2377,9 @@ class DataFrameTable(DataTable):
2127
2377
 
2128
2378
  if scope == "column":
2129
2379
  cidx = self.cursor_col_idx
2130
- self._do_find((term, cidx, False, False))
2380
+ self._do_find((term, cidx, False, True))
2131
2381
  else:
2132
- self._do_find_global((term, None, False, False))
2382
+ self._do_find_global((term, None, False, True))
2133
2383
 
2134
2384
  def _find_expr(self, scope="column") -> None:
2135
2385
  """Open screen to find by expression.
@@ -2158,16 +2408,13 @@ class DataFrameTable(DataTable):
2158
2408
  try:
2159
2409
  matches = self._find_matches(term, cidx, match_nocase, match_whole)
2160
2410
  except Exception as e:
2161
- self.notify(
2162
- f"Error finding matches: [$error]{str(e)}[/]",
2163
- title="Find",
2164
- severity="error",
2165
- )
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)}")
2166
2413
  return
2167
2414
 
2168
2415
  if not matches:
2169
2416
  self.notify(
2170
- 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.",
2171
2418
  title="Find",
2172
2419
  severity="warning",
2173
2420
  )
@@ -2181,11 +2428,11 @@ class DataFrameTable(DataTable):
2181
2428
  for ridx, col_idxs in matches.items():
2182
2429
  self.matches[ridx].update(col_idxs)
2183
2430
 
2184
- # Highlight matches
2185
- self._do_highlight()
2186
-
2187
2431
  self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
2188
2432
 
2433
+ # Recreate table for display
2434
+ self._setup_table()
2435
+
2189
2436
  def _do_find_global(self, result) -> None:
2190
2437
  """Global find a term across all columns."""
2191
2438
  if result is None:
@@ -2195,16 +2442,13 @@ class DataFrameTable(DataTable):
2195
2442
  try:
2196
2443
  matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
2197
2444
  except Exception as e:
2198
- self.notify(
2199
- f"Error finding matches: [$error]{str(e)}[/]",
2200
- title="Find",
2201
- severity="error",
2202
- )
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)}")
2203
2447
  return
2204
2448
 
2205
2449
  if not matches:
2206
2450
  self.notify(
2207
- 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.",
2208
2452
  title="Global Find",
2209
2453
  severity="warning",
2210
2454
  )
@@ -2218,14 +2462,13 @@ class DataFrameTable(DataTable):
2218
2462
  for ridx, col_idxs in matches.items():
2219
2463
  self.matches[ridx].update(col_idxs)
2220
2464
 
2221
- # Highlight matches
2222
- self._do_highlight()
2223
-
2224
2465
  self.notify(
2225
- f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
2226
- title="Global Find",
2466
+ f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
2227
2467
  )
2228
2468
 
2469
+ # Recreate table for display
2470
+ self._setup_table()
2471
+
2229
2472
  def _next_match(self) -> None:
2230
2473
  """Move cursor to the next match."""
2231
2474
  if not self.matches:
@@ -2320,11 +2563,12 @@ class DataFrameTable(DataTable):
2320
2563
  last_ridx = selected_row_indices[-1]
2321
2564
  self.move_cursor_to(last_ridx, self.cursor_col_idx)
2322
2565
 
2566
+ # Replace
2323
2567
  def _replace(self) -> None:
2324
2568
  """Open replace screen for current column."""
2325
2569
  # Push the replace modal screen
2326
2570
  self.app.push_screen(
2327
- FindReplaceScreen(self),
2571
+ FindReplaceScreen(self, title="Find and Replace in Current Column"),
2328
2572
  callback=self._do_replace,
2329
2573
  )
2330
2574
 
@@ -2336,7 +2580,7 @@ class DataFrameTable(DataTable):
2336
2580
  """Open replace screen for all columns."""
2337
2581
  # Push the replace modal screen
2338
2582
  self.app.push_screen(
2339
- FindReplaceScreen(self),
2583
+ FindReplaceScreen(self, title="Global Find and Replace"),
2340
2584
  callback=self._do_replace_global,
2341
2585
  )
2342
2586
 
@@ -2364,23 +2608,19 @@ class DataFrameTable(DataTable):
2364
2608
  matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
2365
2609
 
2366
2610
  if not matches:
2367
- self.notify(
2368
- f"No matches found for [$warning]{term_find}[/]",
2369
- title="Replace",
2370
- severity="warning",
2371
- )
2611
+ self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
2372
2612
  return
2373
2613
 
2374
2614
  # Add to history
2375
2615
  self._add_history(
2376
- 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}[/]"
2377
2617
  )
2378
2618
 
2379
2619
  # Update matches
2380
- 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()}
2381
2621
 
2382
- # Highlight matches
2383
- self._do_highlight()
2622
+ # Recreate table for display
2623
+ self._setup_table()
2384
2624
 
2385
2625
  # Store state for interactive replacement using dataclass
2386
2626
  self._replace_state = ReplaceState(
@@ -2410,10 +2650,11 @@ class DataFrameTable(DataTable):
2410
2650
 
2411
2651
  except Exception as e:
2412
2652
  self.notify(
2413
- f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]: {str(e)}",
2653
+ f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
2414
2654
  title="Replace",
2415
2655
  severity="error",
2416
2656
  )
2657
+ self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
2417
2658
 
2418
2659
  def _do_replace_all(self, term_find: str, term_replace: str) -> None:
2419
2660
  """Replace all occurrences."""
@@ -2466,7 +2707,7 @@ class DataFrameTable(DataTable):
2466
2707
 
2467
2708
  state.replaced_occurrence += 1
2468
2709
 
2469
- # Recreate the table display
2710
+ # Recreate table for display
2470
2711
  self._setup_table()
2471
2712
 
2472
2713
  col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
@@ -2482,10 +2723,11 @@ class DataFrameTable(DataTable):
2482
2723
  self._show_next_replace_confirmation()
2483
2724
  except Exception as e:
2484
2725
  self.notify(
2485
- f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]: {str(e)}",
2726
+ f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
2486
2727
  title="Replace",
2487
2728
  severity="error",
2488
2729
  )
2730
+ self.log(f"Error in interactive replace: {str(e)}")
2489
2731
 
2490
2732
  def _show_next_replace_confirmation(self) -> None:
2491
2733
  """Show confirmation for next replacement."""
@@ -2502,12 +2744,12 @@ class DataFrameTable(DataTable):
2502
2744
  # Move cursor to next match
2503
2745
  ridx = state.rows[state.current_rpos]
2504
2746
  cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
2505
- self.move_cursor(row=ridx, column=cidx)
2747
+ self.move_cursor_to(ridx, cidx)
2506
2748
 
2507
2749
  state.current_occurrence += 1
2508
2750
 
2509
2751
  # Show confirmation
2510
- 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})?"
2511
2753
 
2512
2754
  self.app.push_screen(
2513
2755
  ConfirmScreen("Replace", label=label, maybe="Skip"),
@@ -2572,15 +2814,16 @@ class DataFrameTable(DataTable):
2572
2814
  if state.current_rpos >= len(state.rows):
2573
2815
  state.done = True
2574
2816
 
2575
- # Recreate the table display
2817
+ # Recreate table for display
2576
2818
  self._setup_table()
2577
2819
 
2578
2820
  # Show next confirmation
2579
2821
  self._show_next_replace_confirmation()
2580
2822
 
2823
+ # Selection & Match
2581
2824
  def _toggle_selections(self) -> None:
2582
2825
  """Toggle selected rows highlighting on/off."""
2583
- # Save current state to history
2826
+ # Add to history
2584
2827
  self._add_history("Toggled row selection")
2585
2828
 
2586
2829
  if False in self.visible_rows:
@@ -2596,34 +2839,34 @@ class DataFrameTable(DataTable):
2596
2839
 
2597
2840
  # Check if we're highlighting or un-highlighting
2598
2841
  if new_selected_count := self.selected_rows.count(True):
2599
- self.notify(
2600
- f"Toggled selection for [$accent]{new_selected_count}[/] rows",
2601
- title="Toggle",
2602
- )
2842
+ self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
2603
2843
 
2604
- # Refresh the highlighting
2605
- self._do_highlight(force=True)
2844
+ # Recreate table for display
2845
+ self._setup_table()
2606
2846
 
2607
- def _make_selections(self) -> None:
2608
- """Make selections based on current matches or toggle current row selection."""
2609
- # Save current state to history
2847
+ def _toggle_row_selection(self) -> None:
2848
+ """Select/deselect current row."""
2849
+ # Add to history
2610
2850
  self._add_history("Toggled row selection")
2611
2851
 
2612
- if self.matches:
2613
- # There are matched cells - select rows with matches
2614
- for ridx in self.matches.keys():
2615
- self.selected_rows[ridx] = True
2616
- else:
2617
- # No matched cells - select/deselect the current row
2618
- ridx = self.cursor_row_idx
2619
- 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]
2620
2854
 
2621
- # Check if we're highlighting or un-highlighting
2622
- if new_selected_count := self.selected_rows.count(True):
2623
- 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)
2860
+
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
2624
2868
 
2625
- # Refresh the highlighting (also restores default styles for unselected rows)
2626
- self._do_highlight(force=True)
2869
+ self.update_cell(row_key, col_key, cell_text)
2627
2870
 
2628
2871
  def _clear_selections_and_matches(self) -> None:
2629
2872
  """Clear all selected rows and matches without removing them from the dataframe."""
@@ -2636,50 +2879,61 @@ class DataFrameTable(DataTable):
2636
2879
  1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
2637
2880
  )
2638
2881
 
2639
- # Save current state to history
2882
+ # Add to history
2640
2883
  self._add_history("Cleared all selected rows")
2641
2884
 
2642
2885
  # Clear all selections
2643
2886
  self.selected_rows = [False] * len(self.df)
2644
2887
  self.matches = defaultdict(set)
2645
2888
 
2646
- # Refresh the highlighting to remove all highlights
2647
- self._do_highlight(force=True)
2889
+ # Recreate table for display
2890
+ self._setup_table()
2648
2891
 
2649
2892
  self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
2650
2893
 
2651
- def _filter_selected_rows(self) -> None:
2652
- """Keep only the selected rows and remove unselected ones."""
2653
- selected_count = self.selected_rows.count(True)
2654
- if selected_count == 0:
2655
- 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")
2656
2899
  return
2657
2900
 
2658
- # Save current state to history
2659
- 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
+ }
2660
2916
 
2661
- # Update dataframe to only include selected rows
2662
- self.df = self.df.filter(self.selected_rows)
2663
- self.selected_rows = [True] * len(self.df)
2917
+ # Update dataframe
2918
+ self.df = df_filtered.drop(RIDX)
2664
2919
 
2665
- # Recreate the table for display
2920
+ # Recreate table for display
2666
2921
  self._setup_table()
2667
2922
 
2668
2923
  self.notify(
2669
- f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
2670
- title="Filter",
2924
+ f"Removed rows without selections or matches. Now showing [$accent]{len(self.df)}[/] rows", title="Filter"
2671
2925
  )
2672
2926
 
2673
2927
  def _view_rows(self) -> None:
2674
2928
  """View rows.
2675
2929
 
2676
- If there are selected rows, view those rows.
2930
+ If there are selected rows or matches, view those rows.
2677
2931
  Otherwise, view based on the value of the currently selected cell.
2678
2932
  """
2679
2933
 
2680
2934
  cidx = self.cursor_col_idx
2681
2935
 
2682
- # If there are selected rows or matches, use those
2936
+ # If there are rows with selections or matches, use those
2683
2937
  if any(self.selected_rows) or self.matches:
2684
2938
  term = [
2685
2939
  True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
@@ -2689,7 +2943,7 @@ class DataFrameTable(DataTable):
2689
2943
  ridx = self.cursor_row_idx
2690
2944
  term = str(self.df.item(ridx, cidx))
2691
2945
 
2692
- self._do_view_rows((term, cidx, False, False))
2946
+ self._do_view_rows((term, cidx, False, True))
2693
2947
 
2694
2948
  def _view_rows_expr(self) -> None:
2695
2949
  """Open the filter screen to enter an expression."""
@@ -2703,7 +2957,7 @@ class DataFrameTable(DataTable):
2703
2957
  )
2704
2958
 
2705
2959
  def _do_view_rows(self, result) -> None:
2706
- """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."""
2707
2961
  if result is None:
2708
2962
  return
2709
2963
  term, cidx, match_nocase, match_whole = result
@@ -2718,11 +2972,10 @@ class DataFrameTable(DataTable):
2718
2972
  elif tentative_expr(term):
2719
2973
  # Support for polars expressions
2720
2974
  try:
2721
- expr = validate_expr(term, self.df, cidx)
2975
+ expr = validate_expr(term, self.df.columns, cidx)
2722
2976
  except Exception as e:
2723
- self.notify(
2724
- f"Error validating Polars expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
2725
- )
2977
+ self.notify(f"Error validating expression [$error]{term}[/]", title="Filter", severity="error")
2978
+ self.log(f"Error validating expression `{term}`: {str(e)}")
2726
2979
  return
2727
2980
  else:
2728
2981
  dtype = self.df.dtypes[cidx]
@@ -2743,9 +2996,7 @@ class DataFrameTable(DataTable):
2743
2996
  term = f"(?i){term}"
2744
2997
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2745
2998
  self.notify(
2746
- f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
2747
- title="Filter",
2748
- severity="warning",
2999
+ f"Unknown column type [$warning]{dtype}[/]. Cast to string.", title="Filter", severity="warning"
2749
3000
  )
2750
3001
 
2751
3002
  # Lazyframe with row indices
@@ -2759,17 +3010,14 @@ class DataFrameTable(DataTable):
2759
3010
  try:
2760
3011
  df_filtered = lf.filter(expr).collect()
2761
3012
  except Exception as e:
2762
- self.notify(f"Failed to apply filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
2763
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)}")
2764
3016
  return
2765
3017
 
2766
3018
  matched_count = len(df_filtered)
2767
3019
  if not matched_count:
2768
- self.notify(
2769
- f"No rows match the expression: [$success]{expr}[/]",
2770
- title="Filter",
2771
- severity="warning",
2772
- )
3020
+ self.notify(f"No rows match the expression: [$success]{expr}[/]", title="Filter", severity="warning")
2773
3021
  return
2774
3022
 
2775
3023
  # Add to history
@@ -2782,21 +3030,12 @@ class DataFrameTable(DataTable):
2782
3030
  if ridx not in filtered_row_indices:
2783
3031
  self.visible_rows[ridx] = False
2784
3032
 
2785
- # Recreate the table for display
3033
+ # Recreate table for display
2786
3034
  self._setup_table()
2787
3035
 
2788
- self.notify(
2789
- f"Filtered to [$accent]{matched_count}[/] matching rows",
2790
- title="Filter",
2791
- )
2792
-
2793
- def _cycle_cursor_type(self) -> None:
2794
- """Cycle through cursor types: cell -> row -> column -> cell."""
2795
- next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
2796
- self.cursor_type = next_type
2797
-
2798
- # 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")
2799
3037
 
3038
+ # Copy & Save
2800
3039
  def _copy_to_clipboard(self, content: str, message: str) -> None:
2801
3040
  """Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
2802
3041
 
@@ -2862,6 +3101,9 @@ class DataFrameTable(DataTable):
2862
3101
  filepath = Path(filename)
2863
3102
  ext = filepath.suffix.lower()
2864
3103
 
3104
+ # Add to history
3105
+ self._add_history(f"Saved dataframe to [$success]{filename}[/]")
3106
+
2865
3107
  try:
2866
3108
  if ext in (".xlsx", ".xls"):
2867
3109
  self._do_save_excel(filename)
@@ -2874,17 +3116,14 @@ class DataFrameTable(DataTable):
2874
3116
  else:
2875
3117
  self.df.write_csv(filename)
2876
3118
 
2877
- self.lazyframe = self.df.lazy() # Update original dataframe
3119
+ self.dataframe = self.df # Update original dataframe
2878
3120
  self.filename = filename # Update current filename
2879
3121
  if not self._all_tabs:
2880
3122
  extra = "current tab with " if len(self.app.tabs) > 1 else ""
2881
- self.notify(
2882
- f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2883
- title="Save",
2884
- )
3123
+ self.notify(f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save")
2885
3124
  except Exception as e:
2886
- self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
2887
- raise e
3125
+ self.notify(f"Error saving [$error]{filename}[/]", title="Save", severity="error")
3126
+ self.log(f"Error saving file `{filename}`: {str(e)}")
2888
3127
 
2889
3128
  def _do_save_excel(self, filename: str) -> None:
2890
3129
  """Save to an Excel file."""
@@ -2903,47 +3142,71 @@ class DataFrameTable(DataTable):
2903
3142
 
2904
3143
  # From ConfirmScreen callback, so notify accordingly
2905
3144
  if self._all_tabs is True:
2906
- self.notify(
2907
- f"Saved all tabs to [$success]{filename}[/]",
2908
- title="Save",
2909
- )
3145
+ self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save")
2910
3146
  else:
2911
3147
  self.notify(
2912
- f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2913
- title="Save",
3148
+ f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save"
2914
3149
  )
2915
3150
 
2916
- def _make_cell_clickable(self) -> None:
2917
- """Make cells with URLs in the current column clickable.
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
+ )
2918
3158
 
2919
- Scans all loaded rows in the current column for cells containing URLs
2920
- (starting with 'http://' or 'https://') and applies Textual link styling
2921
- to make them clickable. Does not modify the dataframe.
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
2922
3164
 
2923
- Returns:
2924
- None
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.
2925
3190
  """
2926
- cidx = self.cursor_col_idx
2927
- col_key = self.cursor_col_key
2928
- dtype = self.df.dtypes[cidx]
3191
+ # Add to history
3192
+ self._add_history(f"SQL Query:\n[$accent]{sql}[/]")
2929
3193
 
2930
- # Only process string columns
2931
- if dtype != pl.String:
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)}")
2932
3200
  return
2933
3201
 
2934
- # Count how many URLs were made clickable
2935
- url_count = 0
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
2936
3205
 
2937
- # Iterate through all loaded rows and make URLs clickable
2938
- for row in self.ordered_rows:
2939
- cell_text: Text = self.get_cell(row.key, col_key)
2940
- if cell_text.plain.startswith(("http://", "https://")):
2941
- cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
2942
- self.update_cell(row.key, col_key, cell_text)
2943
- url_count += 1
3206
+ # Recreate table for display
3207
+ self._setup_table()
2944
3208
 
2945
- if url_count:
2946
- self.notify(
2947
- f"Made [$accent]{url_count}[/] cell(s) clickable in column [$success]{col_key.value}[/]",
2948
- title="Make Clickable",
2949
- )
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
+ )