dataframe-textual 1.16.2__py3-none-any.whl → 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,6 @@ from typing import Any
10
10
 
11
11
  import polars as pl
12
12
  from rich.text import Text, TextType
13
- from textual import work
14
13
  from textual._two_way_dict import TwoWayDict
15
14
  from textual.coordinate import Coordinate
16
15
  from textual.events import Click
@@ -32,7 +31,7 @@ from .common import (
32
31
  CURSOR_TYPES,
33
32
  NULL,
34
33
  NULL_DISPLAY,
35
- RIDX,
34
+ RID,
36
35
  SUBSCRIPT_DIGITS,
37
36
  SUPPORTED_FORMATS,
38
37
  DtypeConfig,
@@ -40,7 +39,6 @@ from .common import (
40
39
  get_next_item,
41
40
  parse_placeholders,
42
41
  round_to_nearest_hundreds,
43
- sleep_async,
44
42
  tentative_expr,
45
43
  validate_expr,
46
44
  )
@@ -79,16 +77,16 @@ class History:
79
77
 
80
78
  description: str
81
79
  df: pl.DataFrame
80
+ df_view: pl.DataFrame | None
82
81
  filename: str
83
82
  loaded_rows: int
84
- sorted_columns: dict[str, bool]
85
83
  hidden_columns: set[str]
86
- selected_rows: list[bool]
87
- visible_rows: list[bool]
84
+ selected_rows: set[int]
85
+ sorted_columns: dict[str, bool] # col_name -> descending
88
86
  fixed_rows: int
89
87
  fixed_columns: int
90
88
  cursor_coordinate: Coordinate
91
- matches: dict[int, set[int]]
89
+ matches: dict[int, set[str]] # RID -> set of col names
92
90
  dirty: bool = False # Whether this history state has unsaved changes
93
91
 
94
92
 
@@ -155,7 +153,7 @@ class DataFrameTable(DataTable):
155
153
  - *(Multi-column sort supported)*
156
154
 
157
155
  ## ✅ Row Selection
158
- - **\\\\** - ✅ Select rows in current column using cursor value
156
+ - **\\\\** - ✅ Select rows with cell matches or those matching cursor value in current column
159
157
  - **|** - ✅ Select rows with expression
160
158
  - **'** - ✅ Select/deselect current row
161
159
  - **t** - 💡 Toggle row selection (invert all)
@@ -177,8 +175,8 @@ class DataFrameTable(DataTable):
177
175
 
178
176
  ## 👁️ View & Filter
179
177
  - **"** - 📍 Filter selected rows (removes others)
180
- - **v** - 👁️ View rows that are selected or contain matching cells (hide others)
181
- - **V** - 🔧 View rows by expression (hides others)
178
+ - **v** - 👁️ View selected rows (hides others)
179
+ - **V** - 🔧 View selected rows matching expression (hides others)
182
180
 
183
181
  ## 🔍 SQL Interface
184
182
  - **l** - 💬 Open simple SQL interface (select columns & where clause)
@@ -221,10 +219,8 @@ class DataFrameTable(DataTable):
221
219
  # Navigation
222
220
  ("g", "jump_top", "Jump to top"),
223
221
  ("G", "jump_bottom", "Jump to bottom"),
224
- ("ctrl+f", "forward_page", "Page down"),
225
- ("ctrl+b", "backward_page", "Page up"),
226
- ("pageup", "page_up", "Page up"),
227
- ("pagedown", "page_down", "Page down"),
222
+ ("pageup,ctrl+b", "page_up", "Page up"),
223
+ ("pagedown,ctrl+f", "page_down", "Page down"),
228
224
  # Undo/Redo/Reset
229
225
  ("u", "undo", "Undo"),
230
226
  ("U", "redo", "Redo"),
@@ -237,6 +233,7 @@ class DataFrameTable(DataTable):
237
233
  ("z", "freeze_row_column", "Freeze rows/columns"),
238
234
  ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
239
235
  ("underscore", "expand_column", "Expand column to full width"), # `_`
236
+ ("circumflex_accent", "toggle_rid", "Toggle internal row index"), # `^`
240
237
  # Copy
241
238
  ("c", "copy_cell", "Copy cell to clipboard"),
242
239
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
@@ -254,11 +251,11 @@ class DataFrameTable(DataTable):
254
251
  ("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
255
252
  ("right_square_bracket", "sort_descending", "Sort descending"), # `]`
256
253
  # View & Filter
257
- ("v", "view_rows", "View rows"),
258
- ("V", "view_rows_expr", "View rows by expression"),
259
- ("quotation_mark", "filter_rows", "Filter selected"), # `"`
254
+ ("v", "view_rows", "View selected rows"),
255
+ ("V", "view_rows_expr", "View selected rows matching expression"),
256
+ ("quotation_mark", "filter_rows", "Filter selected rows"), # `"`
260
257
  # Row Selection
261
- ("backslash", "select_row_cursor_value", "Select rows with cursor value in current column"), # `\`
258
+ ("backslash", "select_row", "Select rows with cell matches or those matching cursor value in current column"), # `\`
262
259
  ("vertical_line", "select_row_expr", "Select rows with expression"), # `|`
263
260
  ("right_curly_bracket", "next_selected_row", "Go to next selected row"), # `}`
264
261
  ("left_curly_bracket", "previous_selected_row", "Go to previous selected row"), # `{`
@@ -324,34 +321,40 @@ class DataFrameTable(DataTable):
324
321
  super().__init__(**kwargs)
325
322
 
326
323
  # DataFrame state
327
- self.dataframe = df # Original dataframe
328
- self.df = df # Internal/working dataframe
324
+ self.dataframe = df.lazy().with_row_index(RID).select(pl.exclude(RID), RID).collect() # Original dataframe
325
+ self.df = self.dataframe # Internal/working dataframe
329
326
  self.filename = filename or "untitled.csv" # Current filename
330
327
  self.tabname = tabname or Path(filename).stem # Tab name
328
+
329
+ # In view mode, this is the copy of self.df
330
+ self.df_view = None
331
+
331
332
  # Pagination & Loading
332
333
  self.BATCH_SIZE = max((self.app.size.height // 100 + 1) * 100, 100)
333
334
  self.loaded_rows = 0 # Track how many rows are currently loaded
334
335
  self.loaded_ranges: list[tuple[int, int]] = [] # List of (start, end) row indices that are loaded
335
336
 
336
337
  # State tracking (all 0-based indexing)
337
- self.sorted_columns: dict[str, bool] = {} # col_name -> descending
338
338
  self.hidden_columns: set[str] = set() # Set of hidden column names
339
- self.selected_rows: list[bool] = [False] * len(self.df) # Track selected rows
340
- self.visible_rows: list[bool] = [True] * len(self.df) # Track visible rows (for filtering)
341
- self.matches: dict[int, set[int]] = defaultdict(set) # Track search matches: row_idx -> set of col_idx
339
+ self.selected_rows: set[int] = set() # Track selected rows by RID
340
+ self.sorted_columns: dict[str, bool] = {} # col_name -> descending
341
+ self.matches: dict[int, set[str]] = defaultdict(set) # Track search matches: RID -> set of col_names
342
342
 
343
343
  # Freezing
344
344
  self.fixed_rows = 0 # Number of fixed rows
345
345
  self.fixed_columns = 0 # Number of fixed columns
346
346
 
347
347
  # History stack for undo
348
- self.histories: deque[History] = deque()
349
- # Current history state for redo
350
- self.history: History = None
348
+ self.histories_undo: deque[History] = deque()
349
+ # History stack for redo
350
+ self.histories_redo: deque[History] = deque()
351
351
 
352
352
  # Whether to use thousand separator for numeric display
353
353
  self.thousand_separator = False
354
354
 
355
+ # Whether to show internal row index column
356
+ self.show_rid = False
357
+
355
358
  @property
356
359
  def cursor_key(self) -> CellKey:
357
360
  """Get the current cursor position as a CellKey.
@@ -418,22 +421,13 @@ class DataFrameTable(DataTable):
418
421
 
419
422
  @property
420
423
  def cursor_value(self) -> Any:
421
- """Get the current cursor cell value.
424
+ """Get the current cursor cell value in the dataframe.
422
425
 
423
426
  Returns:
424
427
  Any: The value of the cell at the cursor position.
425
428
  """
426
429
  return self.df.item(self.cursor_row_idx, self.cursor_col_idx)
427
430
 
428
- @property
429
- def has_hidden_rows(self) -> bool:
430
- """Check if there are any hidden rows.
431
-
432
- Returns:
433
- bool: True if there are hidden rows, False otherwise.
434
- """
435
- return any(1 for v in self.visible_rows if v is False)
436
-
437
431
  @property
438
432
  def ordered_selected_rows(self) -> list[int]:
439
433
  """Get the list of selected row indices in order.
@@ -441,7 +435,7 @@ class DataFrameTable(DataTable):
441
435
  Returns:
442
436
  list[int]: A list of 0-based row indices that are currently selected.
443
437
  """
444
- return [ridx for ridx, selected in enumerate(self.selected_rows) if selected]
438
+ return [ridx for ridx, rid in enumerate(self.df[RID]) if rid in self.selected_rows]
445
439
 
446
440
  @property
447
441
  def ordered_matches(self) -> list[tuple[int, int]]:
@@ -451,19 +445,22 @@ class DataFrameTable(DataTable):
451
445
  list[tuple[int, int]]: A list of (row_idx, col_idx) tuples for matched cells.
452
446
  """
453
447
  matches = []
454
- for ridx in sorted(self.matches.keys()):
455
- for cidx in sorted(self.matches[ridx]):
456
- matches.append((ridx, cidx))
457
- return matches
458
448
 
459
- @property
460
- def last_history(self) -> History:
461
- """Get the last history state.
449
+ # Uniq columns
450
+ cols_to_check = set()
451
+ for cols in self.matches.values():
452
+ cols_to_check.update(cols)
462
453
 
463
- Returns:
464
- History: The most recent History object from the histories deque.
465
- """
466
- return self.histories[-1] if self.histories else None
454
+ # Ordered columns
455
+ cidx2col = {cidx: col for cidx, col in enumerate(self.df.columns) if col in cols_to_check}
456
+
457
+ for ridx, rid in enumerate(self.df[RID]):
458
+ if cols := self.matches.get(rid):
459
+ for cidx, col in cidx2col.items():
460
+ if col in cols:
461
+ matches.append((ridx, cidx))
462
+
463
+ return matches
467
464
 
468
465
  def _round_to_nearest_hundreds(self, num: int):
469
466
  """Round a number to the nearest hundreds.
@@ -677,42 +674,19 @@ class DataFrameTable(DataTable):
677
674
  # Action handlers for BINDINGS
678
675
  def action_jump_top(self) -> None:
679
676
  """Jump to the top of the table."""
680
- self.move_cursor(row=0)
677
+ self.do_jump_top()
681
678
 
682
679
  def action_jump_bottom(self) -> None:
683
680
  """Jump to the bottom of the table."""
684
- stop = len(self.df)
685
- start = max(0, ((stop - self.BATCH_SIZE) // self.BATCH_SIZE + 1) * self.BATCH_SIZE)
686
- self.load_rows_range(start, stop)
687
- self.move_cursor(row=self.row_count - 1)
681
+ self.do_jump_bottom()
688
682
 
689
683
  def action_page_up(self) -> None:
690
684
  """Move the cursor one page up."""
691
- self._set_hover_cursor(False)
692
- if self.show_cursor and self.cursor_type in ("cell", "row"):
693
- height = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
694
-
695
- col_idx = self.cursor_column
696
- ridx = self.cursor_row_idx
697
- next_ridx = max(0, ridx - height - BUFFER_SIZE)
698
- start, stop = self._round_to_nearest_hundreds(next_ridx)
699
- self.load_rows_range(start, stop)
700
-
701
- self.move_cursor(row=self.get_row_idx(str(next_ridx)), column=col_idx)
702
- else:
703
- super().action_page_up()
685
+ self.do_page_up()
704
686
 
705
687
  def action_page_down(self) -> None:
706
- super().action_page_down()
707
- self.load_rows_down()
708
-
709
- def action_backward_page(self) -> None:
710
- """Scroll up one page."""
711
- self.action_page_up()
712
-
713
- def action_forward_page(self) -> None:
714
- """Scroll down one page."""
715
- self.action_page_down()
688
+ """Move the cursor one page down."""
689
+ self.do_page_down()
716
690
 
717
691
  def action_view_row_detail(self) -> None:
718
692
  """View details of the current row."""
@@ -730,6 +704,10 @@ class DataFrameTable(DataTable):
730
704
  """Expand the current column to its full width."""
731
705
  self.do_expand_column()
732
706
 
707
+ def action_toggle_rid(self) -> None:
708
+ """Toggle the internal row index column visibility."""
709
+ self.do_toggle_rid()
710
+
733
711
  def action_show_hidden_rows_columns(self) -> None:
734
712
  """Show all hidden rows/columns."""
735
713
  self.do_show_hidden_rows_columns()
@@ -802,9 +780,9 @@ class DataFrameTable(DataTable):
802
780
  """Clear the current cell (set to None)."""
803
781
  self.do_clear_cell()
804
782
 
805
- def action_select_row_cursor_value(self) -> None:
783
+ def action_select_row(self) -> None:
806
784
  """Select rows with cursor value in the current column."""
807
- self.do_select_row_cursor_value()
785
+ self.do_select_row()
808
786
 
809
787
  def action_select_row_expr(self) -> None:
810
788
  """Select rows by expression."""
@@ -1014,51 +992,31 @@ class DataFrameTable(DataTable):
1014
992
  # Set new dataframe and reset table
1015
993
  self.df = new_df
1016
994
  self.loaded_rows = 0
1017
- self.sorted_columns = {}
1018
995
  self.hidden_columns = set()
1019
- self.selected_rows = [False] * len(self.df)
1020
- self.visible_rows = [True] * len(self.df)
996
+ self.selected_rows = set()
997
+ self.sorted_columns = {}
1021
998
  self.fixed_rows = 0
1022
999
  self.fixed_columns = 0
1023
1000
  self.matches = defaultdict(set)
1024
1001
  # self.histories.clear()
1025
- # self.history = None
1002
+ # self.histories2.clear()
1026
1003
  self.dirty = dirty # Mark as dirty since data changed
1027
1004
 
1028
- def setup_table(self, reset: bool = False) -> None:
1005
+ def setup_table(self) -> None:
1029
1006
  """Setup the table for display.
1030
1007
 
1031
1008
  Row keys are 0-based indices, which map directly to dataframe row indices.
1032
1009
  Column keys are header names from the dataframe.
1033
1010
  """
1034
1011
  self.loaded_rows = 0
1012
+ self.loaded_ranges.clear()
1035
1013
  self.show_row_labels = True
1036
1014
 
1037
- # Reset to original dataframe
1038
- if reset:
1039
- self.reset_df(self.dataframe, dirty=False)
1040
-
1041
- # Lazy load up to BATCH_SIZE visible rows
1042
- stop, visible_count, row_idx = self.BATCH_SIZE, 0, 0
1043
- for row_idx, visible in enumerate(self.visible_rows):
1044
- if not visible:
1045
- continue
1046
- visible_count += 1
1047
- if visible_count > self.BATCH_SIZE:
1048
- stop = row_idx
1049
- break
1050
- else:
1051
- stop = row_idx
1052
-
1053
- # Round up to next hundreds
1054
- if stop % self.BATCH_SIZE != 0:
1055
- stop = (stop // self.BATCH_SIZE + 1) * self.BATCH_SIZE
1056
-
1057
1015
  # Save current cursor position before clearing
1058
1016
  row_idx, col_idx = self.cursor_coordinate
1059
1017
 
1060
1018
  self.setup_columns()
1061
- self.load_rows_range(0, stop)
1019
+ self.load_rows_range(0, self.BATCH_SIZE) # Load initial rows
1062
1020
 
1063
1021
  # Restore cursor position
1064
1022
  if row_idx < len(self.rows) and col_idx < len(self.columns):
@@ -1102,28 +1060,30 @@ class DataFrameTable(DataTable):
1102
1060
  # Get column label width
1103
1061
  # Add padding for sort indicators if any
1104
1062
  label_width = measure(self.app.console, col, 1) + 2
1063
+ if dtype != pl.String:
1064
+ available_width -= label_width
1065
+ continue
1105
1066
 
1106
1067
  try:
1107
1068
  # Get sample values from the column
1108
- sample_values = sample_lf.select(col).collect().get_column(col).to_list()
1069
+ sample_values = sample_lf.select(col).collect().get_column(col).drop_nulls().to_list()
1109
1070
  if any(val.startswith(("https://", "http://")) for val in sample_values):
1110
1071
  continue # Skip link columns so they can auto-size and be clickable
1111
1072
 
1112
1073
  # Find maximum width in sample
1113
1074
  max_cell_width = max(
1114
- (measure(self.app.console, str(val), 1) for val in sample_values if val),
1075
+ (measure(self.app.console, val, 1) for val in sample_values),
1115
1076
  default=label_width,
1116
1077
  )
1117
1078
 
1118
1079
  # Set column width to max of label and sampled data (capped at reasonable max)
1119
1080
  max_width = max(label_width, max_cell_width)
1120
- except Exception:
1081
+ except Exception as e:
1121
1082
  # If any error, let Textual auto-size
1122
1083
  max_width = label_width
1084
+ self.log(f"Error determining width for column '{col}': {e}")
1123
1085
 
1124
- if dtype == pl.String:
1125
- column_widths[col] = max_width
1126
-
1086
+ column_widths[col] = max_width
1127
1087
  available_width -= max_width
1128
1088
 
1129
1089
  # If there's no more available width, auto-size remaining columns
@@ -1147,8 +1107,8 @@ class DataFrameTable(DataTable):
1147
1107
 
1148
1108
  # Add columns with justified headers
1149
1109
  for col, dtype in zip(self.df.columns, self.df.dtypes):
1150
- if col in self.hidden_columns:
1151
- continue # Skip hidden columns
1110
+ if col in self.hidden_columns or (col == RID and not self.show_rid):
1111
+ continue # Skip hidden columns and internal RID
1152
1112
  for idx, c in enumerate(self.sorted_columns, 1):
1153
1113
  if c == col:
1154
1114
  # Add sort indicator to column header
@@ -1166,71 +1126,6 @@ class DataFrameTable(DataTable):
1166
1126
 
1167
1127
  self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
1168
1128
 
1169
- def load_rows(self, stop: int | None = None, move_to_end: bool = False) -> None:
1170
- """Load a batch of rows into the table (synchronous wrapper).
1171
-
1172
- Args:
1173
- stop: Stop loading rows when this index is reached.
1174
- If None, load until the end of the dataframe.
1175
- """
1176
- if stop is None or stop > len(self.df):
1177
- stop = len(self.df)
1178
-
1179
- # If already loaded enough rows, just move cursor if needed
1180
- if stop <= self.loaded_rows:
1181
- if move_to_end:
1182
- self.move_cursor(row=self.row_count - 1)
1183
-
1184
- return
1185
-
1186
- # Warn user if loading a large number of rows
1187
- elif (nrows := stop - self.loaded_rows) >= WARN_ROWS_THRESHOLD:
1188
-
1189
- def _continue(result: bool) -> None:
1190
- if result:
1191
- self.load_rows_async(stop, move_to_end=move_to_end)
1192
-
1193
- self.app.push_screen(
1194
- ConfirmScreen(
1195
- f"Load {nrows} Rows",
1196
- label="Loading a large number of rows may cause the application to become unresponsive. Do you want to continue?",
1197
- ),
1198
- callback=_continue,
1199
- )
1200
-
1201
- return
1202
-
1203
- # Load rows asynchronously
1204
- self.load_rows_async(stop, move_to_end=move_to_end)
1205
-
1206
- @work(exclusive=True, description="Loading rows...")
1207
- async def load_rows_async(self, stop: int, move_to_end: bool = False) -> None:
1208
- """Perform loading with async to avoid blocking.
1209
-
1210
- Args:
1211
- stop: Stop loading rows when this index is reached.
1212
- move_to_end: If True, move cursor to the last loaded row after loading completes.
1213
- """
1214
- # Load rows in smaller chunks to avoid blocking
1215
- if stop > self.loaded_rows:
1216
- self.log(f"Async loading up to row {self.loaded_rows = }, {stop = }")
1217
- # Load incrementally to avoid one big block
1218
- # Load max BATCH_SIZE rows at a time
1219
- chunk_size = min(self.BATCH_SIZE, stop - self.loaded_rows)
1220
- next_stop = min(self.loaded_rows + chunk_size, stop)
1221
- self.load_rows_range(self.loaded_rows, next_stop)
1222
- self.loaded_rows = next_stop
1223
-
1224
- # If there's more to load, yield to event loop with delay
1225
- if next_stop < stop:
1226
- await sleep_async(0.05) # 50ms delay to allow UI updates
1227
- self.load_rows_async(stop, move_to_end=move_to_end)
1228
- return
1229
-
1230
- # After loading completes, move cursor to end if requested
1231
- if move_to_end:
1232
- self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
1233
-
1234
1129
  def _calculate_load_range(self, start: int, stop: int) -> list[tuple[int, int]]:
1235
1130
  """Calculate the actual ranges to load, accounting for already-loaded ranges.
1236
1131
 
@@ -1262,8 +1157,11 @@ class DataFrameTable(DataTable):
1262
1157
  # Merge overlapping/adjacent ranges
1263
1158
  merged = []
1264
1159
  for range_start, range_stop in sorted_ranges:
1265
- if merged and range_start <= merged[-1][1]:
1266
- # Overlapping or adjacent: merge
1160
+ # Fully covered, no need to load anything
1161
+ if range_start <= start and range_stop >= stop:
1162
+ return []
1163
+ # Overlapping or adjacent: merge
1164
+ elif merged and range_start <= merged[-1][1]:
1267
1165
  merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
1268
1166
  else:
1269
1167
  merged.append((range_start, range_stop))
@@ -1356,23 +1254,20 @@ class DataFrameTable(DataTable):
1356
1254
  df_slice = self.df.slice(segment_start, segment_stop - segment_start)
1357
1255
 
1358
1256
  # Load each row at the correct position
1359
- for ridx, row in enumerate(df_slice.rows(), segment_start):
1360
- if not self.visible_rows[ridx]:
1361
- continue # Skip hidden rows
1362
-
1363
- is_selected = self.selected_rows[ridx]
1364
- match_cols = self.matches.get(ridx, set())
1257
+ for (ridx, row), rid in zip(enumerate(df_slice.rows(), segment_start), df_slice[RID]):
1258
+ is_selected = rid in self.selected_rows
1259
+ match_cols = self.matches.get(rid, set())
1365
1260
 
1366
1261
  vals, dtypes, styles = [], [], []
1367
- for cidx, (val, col, dtype) in enumerate(zip(row, self.df.columns, self.df.dtypes)):
1368
- if col in self.hidden_columns:
1369
- continue # Skip hidden columns
1262
+ for val, col, dtype in zip(row, self.df.columns, self.df.dtypes, strict=True):
1263
+ if col in self.hidden_columns or (col == RID and not self.show_rid):
1264
+ continue # Skip hidden columns and internal RID
1370
1265
 
1371
1266
  vals.append(val)
1372
1267
  dtypes.append(dtype)
1373
1268
 
1374
1269
  # Highlight entire row with selection or cells with matches
1375
- styles.append(HIGHLIGHT_COLOR if is_selected or cidx in match_cols else None)
1270
+ styles.append(HIGHLIGHT_COLOR if is_selected or col in match_cols else None)
1376
1271
 
1377
1272
  formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
1378
1273
 
@@ -1413,8 +1308,7 @@ class DataFrameTable(DataTable):
1413
1308
 
1414
1309
  # If nothing needs loading, return early
1415
1310
  if not ranges_to_load:
1416
- self.log(f"Range {start}-{stop} already loaded, skipping")
1417
- return 0
1311
+ return 0 # Already loaded
1418
1312
 
1419
1313
  # Track the number of loaded rows in this range
1420
1314
  range_count = 0
@@ -1446,26 +1340,12 @@ class DataFrameTable(DataTable):
1446
1340
  if top_row_key:
1447
1341
  top_ridx = int(top_row_key.value)
1448
1342
  else:
1449
- top_ridx = 0
1450
- self.log(f"No top row key at index {top_row_index}, defaulting to 0")
1343
+ top_ridx = 0 # No top row key at index, default to 0
1451
1344
 
1452
1345
  # Load upward
1453
1346
  start, stop = self._round_to_nearest_hundreds(top_ridx - BUFFER_SIZE * 2)
1454
1347
  range_count = self.load_rows_range(start, stop)
1455
1348
 
1456
- # self.log(
1457
- # "========",
1458
- # f"{self.scrollable_content_region.height = },",
1459
- # f"{self.header_height = },",
1460
- # f"{self.scroll_y = },",
1461
- # f"{top_row_index = },",
1462
- # f"{top_ridx = },",
1463
- # f"{start = },",
1464
- # f"{stop = },",
1465
- # f"{range_count = },",
1466
- # f"{self.loaded_ranges = }",
1467
- # )
1468
-
1469
1349
  # Adjust scroll to maintain position if rows were loaded above
1470
1350
  if range_count > 0:
1471
1351
  self.move_cursor(row=top_row_index + range_count)
@@ -1477,33 +1357,19 @@ class DataFrameTable(DataTable):
1477
1357
  if self.loaded_rows >= len(self.df):
1478
1358
  return
1479
1359
 
1480
- visible_row_count = self.scrollable_content_region.height - self.header_height
1360
+ visible_row_count = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
1481
1361
  bottom_row_index = self.scroll_y + visible_row_count - BUFFER_SIZE
1482
1362
 
1483
1363
  bottom_row_key = self.get_row_key(bottom_row_index)
1484
1364
  if bottom_row_key:
1485
1365
  bottom_ridx = int(bottom_row_key.value)
1486
1366
  else:
1487
- bottom_ridx = 0
1488
- self.log(f"No bottom row key at index {bottom_row_index}, defaulting to 0")
1367
+ bottom_ridx = 0 # No bottom row key at index, default to 0
1489
1368
 
1490
1369
  # Load downward
1491
1370
  start, stop = self._round_to_nearest_hundreds(bottom_ridx + BUFFER_SIZE * 2)
1492
1371
  range_count = self.load_rows_range(start, stop)
1493
1372
 
1494
- # self.log(
1495
- # "========",
1496
- # f"{self.scrollable_content_region.height = },",
1497
- # f"{self.header_height = },",
1498
- # f"{self.scroll_y = },",
1499
- # f"{bottom_row_index = },",
1500
- # f"{bottom_ridx = },",
1501
- # f"{start = },",
1502
- # f"{stop = },",
1503
- # f"{range_count = },",
1504
- # f"{self.loaded_ranges = }",
1505
- # )
1506
-
1507
1373
  if range_count > 0:
1508
1374
  self.log(f"Loaded down: {range_count} rows in range {start}-{stop}/{len(self.df)}")
1509
1375
 
@@ -1608,18 +1474,58 @@ class DataFrameTable(DataTable):
1608
1474
  self.check_idle()
1609
1475
  return row_key
1610
1476
 
1477
+ # Navigation
1478
+ def do_jump_top(self) -> None:
1479
+ """Jump to the top of the table."""
1480
+ self.move_cursor(row=0)
1481
+
1482
+ def do_jump_bottom(self) -> None:
1483
+ """Jump to the bottom of the table."""
1484
+ stop = len(self.df)
1485
+ start = max(0, stop - self.BATCH_SIZE)
1486
+
1487
+ if start % self.BATCH_SIZE != 0:
1488
+ start = (start // self.BATCH_SIZE + 1) * self.BATCH_SIZE
1489
+
1490
+ if stop - start < self.BATCH_SIZE:
1491
+ start -= self.BATCH_SIZE
1492
+
1493
+ self.load_rows_range(start, stop)
1494
+ self.move_cursor(row=self.row_count - 1)
1495
+
1496
+ def do_page_up(self) -> None:
1497
+ """Move the cursor one page up."""
1498
+ self._set_hover_cursor(False)
1499
+ if self.show_cursor and self.cursor_type in ("cell", "row"):
1500
+ height = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
1501
+
1502
+ col_idx = self.cursor_column
1503
+ ridx = self.cursor_row_idx
1504
+ next_ridx = max(0, ridx - height - BUFFER_SIZE)
1505
+ start, stop = self._round_to_nearest_hundreds(next_ridx)
1506
+ self.load_rows_range(start, stop)
1507
+
1508
+ self.move_cursor(row=self.get_row_idx(str(next_ridx)), column=col_idx)
1509
+ else:
1510
+ super().action_page_up()
1511
+
1512
+ def do_page_down(self) -> None:
1513
+ """Move the cursor one page down."""
1514
+ super().action_page_down()
1515
+ self.load_rows_down()
1516
+
1611
1517
  # History & Undo
1612
1518
  def create_history(self, description: str) -> None:
1613
1519
  """Create the initial history state."""
1614
1520
  return History(
1615
1521
  description=description,
1616
1522
  df=self.df,
1523
+ df_view=self.df_view,
1617
1524
  filename=self.filename,
1618
1525
  loaded_rows=self.loaded_rows,
1619
- sorted_columns=self.sorted_columns.copy(),
1620
1526
  hidden_columns=self.hidden_columns.copy(),
1621
1527
  selected_rows=self.selected_rows.copy(),
1622
- visible_rows=self.visible_rows.copy(),
1528
+ sorted_columns=self.sorted_columns.copy(),
1623
1529
  fixed_rows=self.fixed_rows,
1624
1530
  fixed_columns=self.fixed_columns,
1625
1531
  cursor_coordinate=self.cursor_coordinate,
@@ -1634,12 +1540,12 @@ class DataFrameTable(DataTable):
1634
1540
 
1635
1541
  # Restore state
1636
1542
  self.df = history.df
1543
+ self.df_view = history.df_view
1637
1544
  self.filename = history.filename
1638
1545
  self.loaded_rows = history.loaded_rows
1639
- self.sorted_columns = history.sorted_columns.copy()
1640
1546
  self.hidden_columns = history.hidden_columns.copy()
1641
1547
  self.selected_rows = history.selected_rows.copy()
1642
- self.visible_rows = history.visible_rows.copy()
1548
+ self.sorted_columns = history.sorted_columns.copy()
1643
1549
  self.fixed_rows = history.fixed_rows
1644
1550
  self.fixed_columns = history.fixed_columns
1645
1551
  self.cursor_coordinate = history.cursor_coordinate
@@ -1649,15 +1555,18 @@ class DataFrameTable(DataTable):
1649
1555
  # Recreate table for display
1650
1556
  self.setup_table()
1651
1557
 
1652
- def add_history(self, description: str, dirty: bool = False) -> None:
1558
+ def add_history(self, description: str, dirty: bool = False, clear_redo: bool = True) -> None:
1653
1559
  """Add the current state to the history stack.
1654
1560
 
1655
1561
  Args:
1656
1562
  description: Description of the action for this history entry.
1657
1563
  dirty: Whether this operation modifies the data (True) or just display state (False).
1658
1564
  """
1659
- history = self.create_history(description)
1660
- self.histories.append(history)
1565
+ self.histories_undo.append(self.create_history(description))
1566
+
1567
+ # Clear redo stack when a new action is performed
1568
+ if clear_redo:
1569
+ self.histories_redo.clear()
1661
1570
 
1662
1571
  # Mark table as dirty if this operation modifies data
1663
1572
  if dirty:
@@ -1665,52 +1574,43 @@ class DataFrameTable(DataTable):
1665
1574
 
1666
1575
  def do_undo(self) -> None:
1667
1576
  """Undo the last action."""
1668
- if not self.histories:
1577
+ if not self.histories_undo:
1669
1578
  self.notify("No actions to undo", title="Undo", severity="warning")
1670
1579
  return
1671
1580
 
1672
- # Pop the last history state for undo
1673
- history = self.histories.pop()
1674
-
1675
- # Save current state for redo
1676
- self.history = self.create_history(history.description)
1581
+ # Pop the last history state for undo and save to redo stack
1582
+ history = self.histories_undo.pop()
1583
+ self.histories_redo.append(self.create_history(history.description))
1677
1584
 
1678
1585
  # Restore state
1679
1586
  self.apply_history(history)
1680
1587
 
1681
- self.notify(f"Reverted: [$success]{history.description}[/]", title="Undo")
1588
+ self.notify(f"Reverted: {history.description}", title="Undo")
1682
1589
 
1683
1590
  def do_redo(self) -> None:
1684
1591
  """Redo the last undone action."""
1685
- if self.history is None:
1592
+ if not self.histories_redo:
1686
1593
  self.notify("No actions to redo", title="Redo", severity="warning")
1687
1594
  return
1688
1595
 
1689
- description = self.history.description
1596
+ # Pop the last undone state from redo stack
1597
+ history = self.histories_redo.pop()
1598
+ description = history.description
1690
1599
 
1691
1600
  # Save current state for undo
1692
- self.add_history(description)
1601
+ self.add_history(description, clear_redo=False)
1693
1602
 
1694
1603
  # Restore state
1695
- self.apply_history(self.history)
1696
-
1697
- # Clear redo state
1698
- self.history = None
1604
+ self.apply_history(history)
1699
1605
 
1700
- self.notify(f"Reapplied: [$success]{description}[/]", title="Redo")
1606
+ self.notify(f"Reapplied: {description}", title="Redo")
1701
1607
 
1702
1608
  def do_reset(self) -> None:
1703
1609
  """Reset the table to the initial state."""
1704
- self.setup_table(reset=True)
1610
+ self.reset_df(self.dataframe, dirty=False)
1611
+ self.setup_table()
1705
1612
  self.notify("Restored initial state", title="Reset")
1706
1613
 
1707
- def restore_dirty(self, default: bool | None = None) -> None:
1708
- """Restore the dirty state from the last history entry."""
1709
- if self.last_history:
1710
- self.dirty = self.last_history.dirty
1711
- elif default is not None:
1712
- self.dirty = default
1713
-
1714
1614
  # Display
1715
1615
  def do_cycle_cursor_type(self) -> None:
1716
1616
  """Cycle through cursor types: cell -> row -> column -> cell."""
@@ -1817,14 +1717,20 @@ class DataFrameTable(DataTable):
1817
1717
  max_width = len(col_name) + 2 # Start with column name width + padding
1818
1718
 
1819
1719
  try:
1720
+ need_expand = False
1721
+
1820
1722
  # Scan through all loaded rows that are visible to find max width
1821
1723
  for row_idx in range(self.loaded_rows):
1822
- if not self.visible_rows[row_idx]:
1823
- continue # Skip hidden rows
1824
1724
  cell_value = str(self.df.item(row_idx, col_idx))
1825
1725
  cell_width = measure(self.app.console, cell_value, 1)
1726
+
1727
+ if cell_width > max_width:
1728
+ need_expand = True
1826
1729
  max_width = max(max_width, cell_width)
1827
1730
 
1731
+ if not need_expand:
1732
+ return
1733
+
1828
1734
  # Update the column width
1829
1735
  col = self.columns[col_key]
1830
1736
  col.width = max_width
@@ -1841,32 +1747,34 @@ class DataFrameTable(DataTable):
1841
1747
  )
1842
1748
  self.log(f"Error expanding column `{col_name}`: {str(e)}")
1843
1749
 
1844
- def do_show_hidden_rows_columns(self) -> None:
1845
- """Show all hidden rows/columns by recreating the table."""
1846
- # Get currently visible columns
1847
- visible_cols = set(col.key for col in self.ordered_columns)
1750
+ def do_toggle_rid(self) -> None:
1751
+ """Toggle display of the internal RID column."""
1752
+ self.show_rid = not self.show_rid
1848
1753
 
1849
- hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
1850
- hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
1754
+ # Recreate table for display
1755
+ self.setup_table()
1851
1756
 
1852
- if not hidden_row_count and not hidden_col_count:
1853
- self.notify("No hidden columns or rows to show", title="Show", severity="warning")
1757
+ def do_show_hidden_rows_columns(self) -> None:
1758
+ """Show all hidden rows/columns by recreating the table."""
1759
+ if not self.hidden_columns and self.df_view is None:
1760
+ self.notify("No hidden rows or columns to show", title="Show", severity="warning")
1854
1761
  return
1855
1762
 
1856
1763
  # Add to history
1857
1764
  self.add_history("Showed hidden rows/columns")
1858
1765
 
1766
+ # If in a filtered view, restore the full dataframe
1767
+ if self.df_view is not None:
1768
+ self.df = self.df_view
1769
+ self.df_view = None
1770
+
1859
1771
  # Clear hidden rows/columns tracking
1860
- self.visible_rows = [True] * len(self.df)
1861
1772
  self.hidden_columns.clear()
1862
1773
 
1863
1774
  # Recreate table for display
1864
1775
  self.setup_table()
1865
1776
 
1866
- self.notify(
1867
- f"Showed [$success]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
1868
- title="Show",
1869
- )
1777
+ self.notify("Showed hidden row(s) and/or hidden column(s)", title="Show")
1870
1778
 
1871
1779
  # Sort
1872
1780
  def do_sort_by_column(self, descending: bool = False) -> None:
@@ -1888,41 +1796,39 @@ class DataFrameTable(DataTable):
1888
1796
  # Add to history
1889
1797
  self.add_history(f"Sorted on column [$success]{col_name}[/]", dirty=True)
1890
1798
 
1799
+ # New column - add to sort
1891
1800
  if old_desc is None:
1892
- # Add new column to sort
1893
1801
  self.sorted_columns[col_name] = descending
1802
+
1803
+ # Old column, same direction - remove from sort
1894
1804
  elif old_desc == descending:
1895
- # Same direction - remove from sort
1896
1805
  del self.sorted_columns[col_name]
1806
+
1807
+ # Old column, different direction - add to sort at end
1897
1808
  else:
1898
- # Move to end of sort order
1899
1809
  del self.sorted_columns[col_name]
1900
1810
  self.sorted_columns[col_name] = descending
1901
1811
 
1902
- lf = self.df.lazy().with_row_index(RIDX)
1812
+ lf = self.df.lazy()
1813
+ sort_by = {}
1903
1814
 
1904
1815
  # Apply multi-column sort
1905
1816
  if sort_cols := list(self.sorted_columns.keys()):
1906
1817
  descending_flags = list(self.sorted_columns.values())
1907
- lf = lf.sort(sort_cols, descending=descending_flags, nulls_last=True)
1908
-
1909
- df_sorted = lf.collect()
1910
-
1911
- # Updated visible rows, selected rows, and cell matches to match new order
1912
- old_row_indices = df_sorted[RIDX].to_list()
1913
- if self.has_hidden_rows:
1914
- self.visible_rows = [self.visible_rows[old_ridx] for old_ridx in old_row_indices]
1915
- if any(self.selected_rows):
1916
- self.selected_rows = [self.selected_rows[old_ridx] for old_ridx in old_row_indices]
1917
- if any(self.matches):
1918
- self.matches = {
1919
- new_ridx: self.matches[old_ridx]
1920
- for new_ridx, old_ridx in enumerate(old_row_indices)
1921
- if old_ridx in self.matches
1922
- }
1818
+ sort_by = {"by": sort_cols, "descending": descending_flags, "nulls_last": True}
1819
+ else:
1820
+ # No sort - restore original order by adding a temporary index column
1821
+ sort_by = {"by": RID}
1822
+
1823
+ # Perform the sort
1824
+ df_sorted = lf.sort(**sort_by).collect()
1825
+
1826
+ # Also update df_view if applicable
1827
+ if self.df_view is not None:
1828
+ self.df_view = self.df_view.lazy().sort(**sort_by).collect()
1923
1829
 
1924
1830
  # Update the dataframe
1925
- self.df = df_sorted.drop(RIDX)
1831
+ self.df = df_sorted
1926
1832
 
1927
1833
  # Recreate table for display
1928
1834
  self.setup_table()
@@ -1969,6 +1875,17 @@ class DataFrameTable(DataTable):
1969
1875
  .alias(col_name)
1970
1876
  )
1971
1877
 
1878
+ # Also update the view if applicable
1879
+ if self.df_view is not None:
1880
+ # Get the RID value for this row in df_view
1881
+ ridx_view = self.df.item(ridx, self.df.columns.index(RID))
1882
+ self.df_view = self.df_view.with_columns(
1883
+ pl.when(pl.col(RID) == ridx_view)
1884
+ .then(pl.lit(new_value))
1885
+ .otherwise(pl.col(col_name))
1886
+ .alias(col_name)
1887
+ )
1888
+
1972
1889
  # Update the display
1973
1890
  cell_value = self.df.item(ridx, cidx)
1974
1891
  if cell_value is None:
@@ -2044,11 +1961,26 @@ class DataFrameTable(DataTable):
2044
1961
 
2045
1962
  try:
2046
1963
  # Apply the expression to the column
2047
- self.df = self.df.with_columns(expr.alias(col_name))
1964
+ self.df = self.df.lazy().with_columns(expr.alias(col_name)).collect()
1965
+
1966
+ # Also update the view if applicable
1967
+ # Update the value of col_name in df_view using the value of col_name from df based on RID mapping between them
1968
+ if self.df_view is not None:
1969
+ # Get updated column from df for rows that exist in df_view
1970
+ col_updated = f"^_{col_name}_^"
1971
+ lf_updated = self.df.lazy().select(RID, pl.col(col_name).alias(col_updated))
1972
+ # Join and use coalesce to prefer updated value or keep original
1973
+ self.df_view = (
1974
+ self.df_view.lazy()
1975
+ .join(lf_updated, on=RID, how="left")
1976
+ .with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
1977
+ .drop(col_updated)
1978
+ .collect()
1979
+ )
2048
1980
  except Exception as e:
2049
1981
  self.notify(
2050
1982
  f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
2051
- title="Edit",
1983
+ title="Edit Column",
2052
1984
  severity="error",
2053
1985
  timeout=10,
2054
1986
  )
@@ -2090,14 +2022,25 @@ class DataFrameTable(DataTable):
2090
2022
  # Rename the column in the dataframe
2091
2023
  self.df = self.df.rename({col_name: new_name})
2092
2024
 
2093
- # Update sorted_columns if this column was sorted
2025
+ # Also update the view if applicable
2026
+ if self.df_view is not None:
2027
+ self.df_view = self.df_view.rename({col_name: new_name})
2028
+
2029
+ # Update sorted_columns if this column was sorted and maintain order
2094
2030
  if col_name in self.sorted_columns:
2095
- self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
2031
+ sorted_columns = {}
2032
+ for col, order in self.sorted_columns.items():
2033
+ if col == col_name:
2034
+ sorted_columns[new_name] = order
2035
+ else:
2036
+ sorted_columns[col] = order
2037
+ self.sorted_columns = sorted_columns
2096
2038
 
2097
- # Update hidden_columns if this column was hidden
2098
- if col_name in self.hidden_columns:
2099
- self.hidden_columns.remove(col_name)
2100
- self.hidden_columns.add(new_name)
2039
+ # Update matches if this column had cell matches
2040
+ for cols in self.matches.values():
2041
+ if col_name in cols:
2042
+ cols.remove(col_name)
2043
+ cols.add(new_name)
2101
2044
 
2102
2045
  # Recreate table for display
2103
2046
  self.setup_table()
@@ -2126,6 +2069,13 @@ class DataFrameTable(DataTable):
2126
2069
  .alias(col_name)
2127
2070
  )
2128
2071
 
2072
+ # Also update the view if applicable
2073
+ if self.df_view is not None:
2074
+ ridx_view = self.df.item(ridx, self.df.columns.index(RID))
2075
+ self.df_view = self.df_view.with_columns(
2076
+ pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2077
+ )
2078
+
2129
2079
  # Update the display
2130
2080
  dtype = self.df.dtypes[cidx]
2131
2081
  dc = DtypeConfig(dtype)
@@ -2144,30 +2094,27 @@ class DataFrameTable(DataTable):
2144
2094
  self.log(f"Error clearing cell ({ridx}, {col_name}): {str(e)}")
2145
2095
  raise e
2146
2096
 
2147
- def do_add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
2097
+ def do_add_column(self, col_name: str = None) -> None:
2148
2098
  """Add acolumn after the current column."""
2149
2099
  cidx = self.cursor_col_idx
2150
2100
 
2151
2101
  if not col_name:
2152
2102
  # Generate a unique column name
2153
2103
  base_name = "new_col"
2154
- new_name = base_name
2104
+ new_col_name = base_name
2155
2105
  counter = 1
2156
- while new_name in self.df.columns:
2157
- new_name = f"{base_name}_{counter}"
2106
+ while new_col_name in self.df.columns:
2107
+ new_col_name = f"{base_name}_{counter}"
2158
2108
  counter += 1
2159
2109
  else:
2160
- new_name = col_name
2110
+ new_col_name = col_name
2161
2111
 
2162
2112
  # Add to history
2163
- self.add_history(f"Added column [$success]{new_name}[/] after column [$accent]{cidx + 1}[/]", dirty=True)
2113
+ self.add_history(f"Added column [$success]{new_col_name}[/] after column [$accent]{cidx + 1}[/]", dirty=True)
2164
2114
 
2165
2115
  try:
2166
2116
  # Create an empty column (all None values)
2167
- if isinstance(col_value, pl.Expr):
2168
- new_col = col_value.alias(new_name)
2169
- else:
2170
- new_col = pl.lit(col_value).alias(new_name)
2117
+ new_col_name = pl.lit(None).alias(new_col_name)
2171
2118
 
2172
2119
  # Get columns up to current, the new column, then remaining columns
2173
2120
  cols = self.df.columns
@@ -2175,8 +2122,12 @@ class DataFrameTable(DataTable):
2175
2122
  cols_after = cols[cidx + 1 :]
2176
2123
 
2177
2124
  # Build the new dataframe with columns reordered
2178
- select_cols = cols_before + [new_name] + cols_after
2179
- self.df = self.df.with_columns(new_col).select(select_cols)
2125
+ select_cols = cols_before + [new_col_name] + cols_after
2126
+ self.df = self.df.lazy().with_columns(new_col_name).select(select_cols).collect()
2127
+
2128
+ # Also update the view if applicable
2129
+ if self.df_view is not None:
2130
+ self.df_view = self.df_view.lazy().with_columns(new_col_name).select(select_cols).collect()
2180
2131
 
2181
2132
  # Recreate table for display
2182
2133
  self.setup_table()
@@ -2186,8 +2137,10 @@ class DataFrameTable(DataTable):
2186
2137
 
2187
2138
  # self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
2188
2139
  except Exception as e:
2189
- self.notify(f"Error adding column [$error]{new_name}[/]", title="Add Column", severity="error", timeout=10)
2190
- self.log(f"Error adding column `{new_name}`: {str(e)}")
2140
+ self.notify(
2141
+ f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
2142
+ )
2143
+ self.log(f"Error adding column `{new_col_name}`: {str(e)}")
2191
2144
  raise e
2192
2145
 
2193
2146
  def do_add_column_expr(self) -> None:
@@ -2219,7 +2172,14 @@ class DataFrameTable(DataTable):
2219
2172
 
2220
2173
  # Build the new dataframe with columns reordered
2221
2174
  select_cols = cols_before + [new_col_name] + cols_after
2222
- self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
2175
+ self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
2176
+
2177
+ # Also update the view if applicable
2178
+ if self.df_view is not None:
2179
+ # Get updated column from df for rows that exist in df_view
2180
+ lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
2181
+ # Join and use coalesce to prefer updated value or keep original
2182
+ self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
2223
2183
 
2224
2184
  # Recreate table for display
2225
2185
  self.setup_table()
@@ -2285,7 +2245,14 @@ class DataFrameTable(DataTable):
2285
2245
 
2286
2246
  # Build the new dataframe with columns reordered
2287
2247
  select_cols = cols_before + [new_col_name] + cols_after
2288
- self.df = self.df.with_columns(new_col).select(select_cols)
2248
+ self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
2249
+
2250
+ # Also update the view if applicable
2251
+ if self.df_view is not None:
2252
+ # Get updated column from df for rows that exist in df_view
2253
+ lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
2254
+ # Join and use coalesce to prefer updated value or keep original
2255
+ self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
2289
2256
 
2290
2257
  # Recreate table for display
2291
2258
  self.setup_table()
@@ -2352,17 +2319,24 @@ class DataFrameTable(DataTable):
2352
2319
  if col_name in self.sorted_columns:
2353
2320
  del self.sorted_columns[col_name]
2354
2321
 
2322
+ # Remove from hidden columns if present
2323
+ for col_name in col_names_to_remove:
2324
+ self.hidden_columns.discard(col_name)
2325
+
2355
2326
  # Remove from matches
2356
- col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
2357
- for row_idx in list(self.matches.keys()):
2358
- self.matches[row_idx].difference_update(col_indices_to_remove)
2327
+ for rid in list(self.matches.keys()):
2328
+ self.matches[rid].difference_update(col_names_to_remove)
2359
2329
  # Remove empty entries
2360
- if not self.matches[row_idx]:
2361
- del self.matches[row_idx]
2330
+ if not self.matches[rid]:
2331
+ del self.matches[rid]
2362
2332
 
2363
2333
  # Remove from dataframe
2364
2334
  self.df = self.df.drop(col_names_to_remove)
2365
2335
 
2336
+ # Also update the view if applicable
2337
+ if self.df_view is not None:
2338
+ self.df_view = self.df_view.drop(col_names_to_remove)
2339
+
2366
2340
  self.notify(message, title="Delete")
2367
2341
 
2368
2342
  def do_duplicate_column(self) -> None:
@@ -2373,29 +2347,28 @@ class DataFrameTable(DataTable):
2373
2347
  col_idx = self.cursor_column
2374
2348
  new_col_name = f"{col_name}_copy"
2375
2349
 
2350
+ # Ensure new column name is unique
2351
+ counter = 1
2352
+ while new_col_name in self.df.columns:
2353
+ new_col_name = f"{new_col_name}{counter}"
2354
+ counter += 1
2355
+
2376
2356
  # Add to history
2377
2357
  self.add_history(f"Duplicated column [$success]{col_name}[/]", dirty=True)
2378
2358
 
2379
2359
  # Create new column and reorder columns to insert after current column
2380
2360
  cols_before = self.df.columns[: cidx + 1]
2381
2361
  cols_after = self.df.columns[cidx + 1 :]
2362
+ cols_new = cols_before + [new_col_name] + cols_after
2382
2363
 
2383
2364
  # Add the new column and reorder columns for insertion after current column
2384
- self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
2385
- list(cols_before) + [new_col_name] + list(cols_after)
2386
- )
2365
+ self.df = self.df.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
2387
2366
 
2388
- # Update matches to account for new column
2389
- new_matches = defaultdict(set)
2390
- for row_idx, cols in self.matches.items():
2391
- new_cols = set()
2392
- for col_idx_in_set in cols:
2393
- if col_idx_in_set <= cidx:
2394
- new_cols.add(col_idx_in_set)
2395
- else:
2396
- new_cols.add(col_idx_in_set + 1)
2397
- new_matches[row_idx] = new_cols
2398
- self.matches = new_matches
2367
+ # Also update the view if applicable
2368
+ if self.df_view is not None:
2369
+ self.df_view = (
2370
+ self.df_view.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
2371
+ )
2399
2372
 
2400
2373
  # Recreate table for display
2401
2374
  self.setup_table()
@@ -2411,58 +2384,61 @@ class DataFrameTable(DataTable):
2411
2384
  Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
2412
2385
  """
2413
2386
  old_count = len(self.df)
2414
- predicates = [True] * len(self.df)
2387
+ rids_to_delete = set()
2415
2388
 
2416
2389
  # Delete all selected rows
2417
- if selected_count := self.selected_rows.count(True):
2390
+ if selected_count := len(self.selected_rows):
2418
2391
  history_desc = f"Deleted {selected_count} selected row(s)"
2419
-
2420
- for ridx, selected in enumerate(self.selected_rows):
2421
- if selected:
2422
- predicates[ridx] = False
2392
+ rids_to_delete = self.selected_rows
2423
2393
 
2424
2394
  # Delete current row and those above
2425
2395
  elif more == "above":
2426
2396
  ridx = self.cursor_row_idx
2427
2397
  history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
2428
- for i in range(ridx + 1):
2429
- predicates[i] = False
2398
+ for rid in self.df[RID][: ridx + 1]:
2399
+ rids_to_delete.add(rid)
2430
2400
 
2431
2401
  # Delete current row and those below
2432
2402
  elif more == "below":
2433
2403
  ridx = self.cursor_row_idx
2434
2404
  history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
2435
- for i in range(ridx, len(self.df)):
2436
- if self.visible_rows[i]:
2437
- predicates[i] = False
2405
+ for rid in self.df[RID][ridx:]:
2406
+ rids_to_delete.add(rid)
2438
2407
 
2439
2408
  # Delete the row at the cursor
2440
2409
  else:
2441
2410
  ridx = self.cursor_row_idx
2442
2411
  history_desc = f"Deleted row [$success]{ridx + 1}[/]"
2443
- if self.visible_rows[ridx]:
2444
- predicates[ridx] = False
2412
+ rids_to_delete.add(self.df[RID][ridx])
2445
2413
 
2446
2414
  # Add to history
2447
2415
  self.add_history(history_desc, dirty=True)
2448
2416
 
2449
2417
  # Apply the filter to remove rows
2450
2418
  try:
2451
- df = self.df.with_row_index(RIDX).filter(predicates)
2419
+ df_filtered = self.df.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
2452
2420
  except Exception as e:
2453
2421
  self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error", timeout=10)
2454
- self.histories.pop() # Remove last history entry
2422
+ self.histories_undo.pop() # Remove last history entry
2455
2423
  return
2456
2424
 
2457
- self.df = df.drop(RIDX)
2425
+ # RIDs of remaining rows
2426
+ ok_rids = set(df_filtered[RID])
2458
2427
 
2459
- # Update selected and visible rows tracking
2460
- old_row_indices = set(df[RIDX].to_list())
2461
- self.selected_rows = [selected for i, selected in enumerate(self.selected_rows) if i in old_row_indices]
2462
- self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
2428
+ # Update selected rows tracking
2429
+ if self.selected_rows:
2430
+ self.selected_rows.intersection_update(ok_rids)
2463
2431
 
2464
- # Clear all matches since row indices have changed
2465
- self.matches = defaultdict(set)
2432
+ # Update the dataframe
2433
+ self.df = df_filtered
2434
+
2435
+ # Update matches since row indices have changed
2436
+ if self.matches:
2437
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
2438
+
2439
+ # Also update the view if applicable
2440
+ if self.df_view is not None:
2441
+ self.df_view = self.df_view.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
2466
2442
 
2467
2443
  # Recreate table for display
2468
2444
  self.setup_table()
@@ -2474,34 +2450,29 @@ class DataFrameTable(DataTable):
2474
2450
  def do_duplicate_row(self) -> None:
2475
2451
  """Duplicate the currently selected row, inserting it right after the current row."""
2476
2452
  ridx = self.cursor_row_idx
2453
+ rid = self.df[RID][ridx]
2454
+
2455
+ lf = self.df.lazy()
2477
2456
 
2478
2457
  # Get the row to duplicate
2479
- row_to_duplicate = self.df.slice(ridx, 1)
2458
+ row_to_duplicate = lf.slice(ridx, 1).with_columns(pl.col(RID) + 1)
2480
2459
 
2481
2460
  # Add to history
2482
2461
  self.add_history(f"Duplicated row [$success]{ridx + 1}[/]", dirty=True)
2483
2462
 
2484
2463
  # Concatenate: rows before + duplicated row + rows after
2485
- df_before = self.df.slice(0, ridx + 1)
2486
- df_after = self.df.slice(ridx + 1)
2464
+ lf_before = lf.slice(0, ridx + 1)
2465
+ lf_after = lf.slice(ridx + 1).with_columns(pl.col(RID) + 1)
2487
2466
 
2488
2467
  # Combine the parts
2489
- self.df = pl.concat([df_before, row_to_duplicate, df_after])
2490
-
2491
- # Update selected and visible rows tracking to account for new row
2492
- new_selected_rows = self.selected_rows[: ridx + 1] + [self.selected_rows[ridx]] + self.selected_rows[ridx + 1 :]
2493
- new_visible_rows = self.visible_rows[: ridx + 1] + [self.visible_rows[ridx]] + self.visible_rows[ridx + 1 :]
2494
- self.selected_rows = new_selected_rows
2495
- self.visible_rows = new_visible_rows
2496
-
2497
- # Update matches to account for new row
2498
- new_matches = defaultdict(set)
2499
- for row_idx, cols in self.matches.items():
2500
- if row_idx <= ridx:
2501
- new_matches[row_idx] = cols
2502
- else:
2503
- new_matches[row_idx + 1] = cols
2504
- self.matches = new_matches
2468
+ self.df = pl.concat([lf_before, row_to_duplicate, lf_after]).collect()
2469
+
2470
+ # Also update the view if applicable
2471
+ if self.df_view is not None:
2472
+ lf_view = self.df_view.lazy()
2473
+ lf_view_before = lf_view.slice(0, rid + 1)
2474
+ lf_view_after = lf_view.slice(rid + 1).with_columns(pl.col(RID) + 1)
2475
+ self.df_view = pl.concat([lf_view_before, row_to_duplicate, lf_view_after]).collect()
2505
2476
 
2506
2477
  # Recreate table for display
2507
2478
  self.setup_table()
@@ -2567,6 +2538,10 @@ class DataFrameTable(DataTable):
2567
2538
  cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
2568
2539
  self.df = self.df.select(cols)
2569
2540
 
2541
+ # Also update the view if applicable
2542
+ if self.df_view is not None:
2543
+ self.df_view = self.df_view.select(cols)
2544
+
2570
2545
  # self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
2571
2546
 
2572
2547
  def do_move_row(self, direction: str) -> None:
@@ -2575,65 +2550,88 @@ class DataFrameTable(DataTable):
2575
2550
  Args:
2576
2551
  direction: "up" to move up, "down" to move down.
2577
2552
  """
2578
- row_idx, col_idx = self.cursor_coordinate
2553
+ curr_row_idx, col_idx = self.cursor_coordinate
2579
2554
 
2580
2555
  # Validate move is possible
2581
2556
  if direction == "up":
2582
- if row_idx <= 0:
2557
+ if curr_row_idx <= 0:
2583
2558
  self.notify("Cannot move row up", title="Move", severity="warning")
2584
2559
  return
2585
- swap_idx = row_idx - 1
2560
+ swap_row_idx = curr_row_idx - 1
2586
2561
  elif direction == "down":
2587
- if row_idx >= len(self.rows) - 1:
2562
+ if curr_row_idx >= len(self.rows) - 1:
2588
2563
  self.notify("Cannot move row down", title="Move", severity="warning")
2589
2564
  return
2590
- swap_idx = row_idx + 1
2565
+ swap_row_idx = curr_row_idx + 1
2591
2566
  else:
2592
2567
  # Invalid direction
2593
2568
  return
2594
2569
 
2595
- row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
2596
- swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
2597
-
2598
2570
  # Add to history
2599
2571
  self.add_history(
2600
- f"Moved row [$success]{row_key.value}[/] [$accent]{direction}[/] (swapped with row [$success]{swap_key.value}[/])",
2572
+ f"Moved row [$success]{curr_row_idx}[/] [$accent]{direction}[/] (swapped with row [$success]{swap_row_idx}[/])",
2601
2573
  dirty=True,
2602
2574
  )
2603
2575
 
2604
2576
  # Swap rows in the table's internal row locations
2577
+ curr_key = self.coordinate_to_cell_key((curr_row_idx, 0)).row_key
2578
+ swap_key = self.coordinate_to_cell_key((swap_row_idx, 0)).row_key
2579
+
2605
2580
  self.check_idle()
2606
2581
 
2607
2582
  (
2608
- self._row_locations[row_key],
2583
+ self._row_locations[curr_key],
2609
2584
  self._row_locations[swap_key],
2610
2585
  ) = (
2611
2586
  self.get_row_idx(swap_key),
2612
- self.get_row_idx(row_key),
2587
+ self.get_row_idx(curr_key),
2613
2588
  )
2614
2589
 
2615
2590
  self._update_count += 1
2616
2591
  self.refresh()
2617
2592
 
2618
2593
  # Restore cursor position on the moved row
2619
- self.move_cursor(row=swap_idx, column=col_idx)
2594
+ self.move_cursor(row=swap_row_idx, column=col_idx)
2620
2595
 
2621
- # Swap rows in the dataframe
2622
- ridx = int(row_key.value) # 0-based
2623
- swap_ridx = int(swap_key.value) # 0-based
2624
- first, second = sorted([ridx, swap_ridx])
2596
+ # Locate the rows to swap
2597
+ curr_ridx = curr_row_idx
2598
+ swap_ridx = swap_row_idx
2599
+ first, second = sorted([curr_ridx, swap_ridx])
2625
2600
 
2601
+ # Swap the rows in the dataframe
2626
2602
  self.df = pl.concat(
2627
2603
  [
2628
- self.df.slice(0, first),
2629
- self.df.slice(second, 1),
2630
- self.df.slice(first + 1, second - first - 1),
2631
- self.df.slice(first, 1),
2632
- self.df.slice(second + 1),
2604
+ self.df.slice(0, first).lazy(),
2605
+ self.df.slice(second, 1).lazy(),
2606
+ self.df.slice(first + 1, second - first - 1).lazy(),
2607
+ self.df.slice(first, 1).lazy(),
2608
+ self.df.slice(second + 1).lazy(),
2633
2609
  ]
2634
- )
2610
+ ).collect()
2611
+
2612
+ # Also update the view if applicable
2613
+ if self.df_view is not None:
2614
+ # Find RID values
2615
+ curr_rid = self.df[RID][curr_row_idx]
2616
+ swap_rid = self.df[RID][swap_row_idx]
2617
+
2618
+ # Locate the rows by RID in the view
2619
+ curr_ridx = self.df_view[RID].index_of(curr_rid)
2620
+ swap_ridx = self.df_view[RID].index_of(swap_rid)
2621
+ first, second = sorted([curr_ridx, swap_ridx])
2622
+
2623
+ # Swap the rows in the view
2624
+ self.df_view = pl.concat(
2625
+ [
2626
+ self.df_view.slice(0, first).lazy(),
2627
+ self.df_view.slice(second, 1).lazy(),
2628
+ self.df_view.slice(first + 1, second - first - 1).lazy(),
2629
+ self.df_view.slice(first, 1).lazy(),
2630
+ self.df_view.slice(second + 1).lazy(),
2631
+ ]
2632
+ ).collect()
2635
2633
 
2636
- # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
2634
+ # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move Row")
2637
2635
 
2638
2636
  # Type casting
2639
2637
  def do_cast_column_dtype(self, dtype: str) -> None:
@@ -2670,6 +2668,10 @@ class DataFrameTable(DataTable):
2670
2668
  # Cast the column using Polars
2671
2669
  self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
2672
2670
 
2671
+ # Also update the view if applicable
2672
+ if self.df_view is not None:
2673
+ self.df_view = self.df_view.with_columns(pl.col(col_name).cast(target_dtype))
2674
+
2673
2675
  # Recreate table for display
2674
2676
  self.setup_table()
2675
2677
 
@@ -2684,17 +2686,26 @@ class DataFrameTable(DataTable):
2684
2686
  self.log(f"Error casting column `{col_name}`: {str(e)}")
2685
2687
 
2686
2688
  # Row selection
2687
- def do_select_row_cursor_value(self) -> None:
2688
- """Search with cursor value in current column."""
2689
+ def do_select_row(self) -> None:
2690
+ """Select rows.
2691
+
2692
+ If there are existing cell matches, use those to select rows.
2693
+ Otherwise, use the current cell value as the search term and select rows matching that value.
2694
+ """
2689
2695
  cidx = self.cursor_col_idx
2690
- col_name = self.cursor_col_name
2691
2696
 
2692
- # Get the value of the currently selected cell
2693
- term = NULL if self.cursor_value is None else str(self.cursor_value)
2694
- if self.cursor_value is None:
2695
- term = pl.col(col_name).is_null()
2697
+ # Use existing cell matches if present
2698
+ if self.matches:
2699
+ term = pl.col(RID).is_in(self.matches)
2696
2700
  else:
2697
- term = pl.col(col_name) == self.cursor_value
2701
+ col_name = self.cursor_col_name
2702
+
2703
+ # Get the value of the currently selected cell
2704
+ term = NULL if self.cursor_value is None else str(self.cursor_value)
2705
+ if self.cursor_value is None:
2706
+ term = pl.col(col_name).is_null()
2707
+ else:
2708
+ term = pl.col(col_name) == self.cursor_value
2698
2709
 
2699
2710
  self.select_row((term, cidx, False, True))
2700
2711
 
@@ -2707,7 +2718,7 @@ class DataFrameTable(DataTable):
2707
2718
 
2708
2719
  # Push the search modal screen
2709
2720
  self.app.push_screen(
2710
- SearchScreen("Search", term, self.df, cidx),
2721
+ SearchScreen("Select", term, self.df, cidx),
2711
2722
  callback=self.select_row,
2712
2723
  )
2713
2724
 
@@ -2717,12 +2728,16 @@ class DataFrameTable(DataTable):
2717
2728
  return
2718
2729
 
2719
2730
  term, cidx, match_nocase, match_whole = result
2720
- col_name = self.df.columns[cidx]
2731
+ col_name = "all columns" if cidx is None else self.df.columns[cidx]
2721
2732
 
2722
2733
  # Already a Polars expression
2723
2734
  if isinstance(term, pl.Expr):
2724
2735
  expr = term
2725
2736
 
2737
+ # bool list or Series
2738
+ elif isinstance(term, (list, pl.Series)):
2739
+ expr = term
2740
+
2726
2741
  # Null case
2727
2742
  elif term == NULL:
2728
2743
  expr = pl.col(col_name).is_null()
@@ -2764,13 +2779,11 @@ class DataFrameTable(DataTable):
2764
2779
  )
2765
2780
 
2766
2781
  # Lazyframe for filtering
2767
- lf = self.df.lazy().with_row_index(RIDX)
2768
- if self.has_hidden_rows:
2769
- lf = lf.filter(self.visible_rows)
2782
+ lf = self.df.lazy()
2770
2783
 
2771
2784
  # Apply filter to get matched row indices
2772
2785
  try:
2773
- matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
2786
+ ok_rids = set(lf.filter(expr).collect()[RID])
2774
2787
  except Exception as e:
2775
2788
  self.notify(
2776
2789
  f"Error applying search filter `[$error]{term}[/]`", title="Search", severity="error", timeout=10
@@ -2778,7 +2791,7 @@ class DataFrameTable(DataTable):
2778
2791
  self.log(f"Error applying search filter `{term}`: {str(e)}")
2779
2792
  return
2780
2793
 
2781
- match_count = len(matches)
2794
+ match_count = len(ok_rids)
2782
2795
  if match_count == 0:
2783
2796
  self.notify(
2784
2797
  f"No matches found for `[$warning]{term}[/]`. Try [$accent](?i)abc[/] for case-insensitive search.",
@@ -2787,14 +2800,13 @@ class DataFrameTable(DataTable):
2787
2800
  )
2788
2801
  return
2789
2802
 
2790
- message = f"Found [$success]{match_count}[/] matching row(s) for `[$accent]{term}[/]`"
2803
+ message = f"Found [$success]{match_count}[/] matching row(s)"
2791
2804
 
2792
2805
  # Add to history
2793
2806
  self.add_history(message)
2794
2807
 
2795
- # Update selected rows to include new matches
2796
- for m in matches:
2797
- self.selected_rows[m] = True
2808
+ # Update selected rows to include new selections
2809
+ self.selected_rows.update(ok_rids)
2798
2810
 
2799
2811
  # Show notification immediately, then start highlighting
2800
2812
  self.notify(message, title="Select Row")
@@ -2807,20 +2819,12 @@ class DataFrameTable(DataTable):
2807
2819
  # Add to history
2808
2820
  self.add_history("Toggled row selection")
2809
2821
 
2810
- if self.has_hidden_rows:
2811
- # Some rows are hidden - invert only selected visible rows and clear selections for hidden rows
2812
- for i in range(len(self.selected_rows)):
2813
- if self.visible_rows[i]:
2814
- self.selected_rows[i] = not self.selected_rows[i]
2815
- else:
2816
- self.selected_rows[i] = False
2817
- else:
2818
- # Invert all selected rows
2819
- self.selected_rows = [not selected for selected in self.selected_rows]
2822
+ # Invert all selected rows
2823
+ self.selected_rows = {rid for rid in self.df[RID] if rid not in self.selected_rows}
2820
2824
 
2821
2825
  # Check if we're highlighting or un-highlighting
2822
- if new_selected_count := self.selected_rows.count(True):
2823
- self.notify(f"Toggled selection for [$success]{new_selected_count}[/] rows", title="Toggle")
2826
+ if selected_count := len(self.selected_rows):
2827
+ self.notify(f"Toggled selection for [$success]{selected_count}[/] rows", title="Toggle")
2824
2828
 
2825
2829
  # Recreate table for display
2826
2830
  self.setup_table()
@@ -2830,16 +2834,25 @@ class DataFrameTable(DataTable):
2830
2834
  # Add to history
2831
2835
  self.add_history("Toggled row selection")
2832
2836
 
2837
+ # Get current row RID
2833
2838
  ridx = self.cursor_row_idx
2834
- self.selected_rows[ridx] = not self.selected_rows[ridx]
2839
+ rid = self.df[RID][ridx]
2840
+
2841
+ if rid in self.selected_rows:
2842
+ self.selected_rows.discard(rid)
2843
+ else:
2844
+ self.selected_rows.add(rid)
2845
+
2846
+ row_key = self.cursor_row_key
2847
+ is_selected = rid in self.selected_rows
2848
+ match_cols = self.matches.get(rid, set())
2835
2849
 
2836
- row_key = str(ridx)
2837
- match_cols = self.matches.get(ridx, set())
2838
2850
  for col_idx, col in enumerate(self.ordered_columns):
2839
2851
  col_key = col.key
2852
+ col_name = col_key.value
2840
2853
  cell_text: Text = self.get_cell(row_key, col_key)
2841
2854
 
2842
- if self.selected_rows[ridx] or (col_idx in match_cols):
2855
+ if is_selected or (col_name in match_cols):
2843
2856
  cell_text.style = HIGHLIGHT_COLOR
2844
2857
  else:
2845
2858
  # Reset to default style based on dtype
@@ -2852,19 +2865,17 @@ class DataFrameTable(DataTable):
2852
2865
  def do_clear_selections_and_matches(self) -> None:
2853
2866
  """Clear all selected rows and matches without removing them from the dataframe."""
2854
2867
  # Check if any selected rows or matches
2855
- if not any(self.selected_rows) and not self.matches:
2868
+ if not self.selected_rows and not self.matches:
2856
2869
  self.notify("No selections to clear", title="Clear", severity="warning")
2857
2870
  return
2858
2871
 
2859
- row_count = sum(
2860
- 1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
2861
- )
2872
+ row_count = len(self.selected_rows | set(self.matches.keys()))
2862
2873
 
2863
2874
  # Add to history
2864
2875
  self.add_history("Cleared all selected rows")
2865
2876
 
2866
2877
  # Clear all selections
2867
- self.selected_rows = [False] * len(self.df)
2878
+ self.selected_rows = set()
2868
2879
  self.matches = defaultdict(set)
2869
2880
 
2870
2881
  # Recreate table for display
@@ -2875,7 +2886,7 @@ class DataFrameTable(DataTable):
2875
2886
  # Find & Replace
2876
2887
  def find_matches(
2877
2888
  self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
2878
- ) -> dict[int, set[int]]:
2889
+ ) -> dict[int, set[str]]:
2879
2890
  """Find matches for a term in the dataframe.
2880
2891
 
2881
2892
  Args:
@@ -2892,12 +2903,10 @@ class DataFrameTable(DataTable):
2892
2903
  Raises:
2893
2904
  Exception: If expression validation or filtering fails.
2894
2905
  """
2895
- matches: dict[int, set[int]] = defaultdict(set)
2906
+ matches: dict[int, set[str]] = defaultdict(set)
2896
2907
 
2897
2908
  # Lazyframe for filtering
2898
- lf = self.df.lazy().with_row_index(RIDX)
2899
- if self.has_hidden_rows:
2900
- lf = lf.filter(self.visible_rows)
2909
+ lf = self.df.lazy()
2901
2910
 
2902
2911
  # Determine which columns to search: single column or all columns
2903
2912
  if cidx is not None:
@@ -2928,14 +2937,14 @@ class DataFrameTable(DataTable):
2928
2937
 
2929
2938
  # Get matched row indices
2930
2939
  try:
2931
- matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
2940
+ matched_ridxs = lf.filter(expr).collect()[RID]
2932
2941
  except Exception as e:
2933
2942
  self.notify(f"Error applying filter: [$error]{expr}[/]", title="Find", severity="error", timeout=10)
2934
2943
  self.log(f"Error applying filter: {str(e)}")
2935
2944
  return matches
2936
2945
 
2937
2946
  for ridx in matched_ridxs:
2938
- matches[ridx].add(col_idx)
2947
+ matches[ridx].add(col_name)
2939
2948
 
2940
2949
  return matches
2941
2950
 
@@ -2997,9 +3006,9 @@ class DataFrameTable(DataTable):
2997
3006
  self.add_history(f"Found `[$success]{term}[/]` in column [$accent]{col_name}[/]")
2998
3007
 
2999
3008
  # Add to matches and count total
3000
- match_count = sum(len(col_idxs) for col_idxs in matches.values())
3001
- for ridx, col_idxs in matches.items():
3002
- self.matches[ridx].update(col_idxs)
3009
+ match_count = sum(len(cols) for cols in matches.values())
3010
+ for rid, cols in matches.items():
3011
+ self.matches[rid].update(cols)
3003
3012
 
3004
3013
  self.notify(f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]`", title="Find")
3005
3014
 
@@ -3031,9 +3040,9 @@ class DataFrameTable(DataTable):
3031
3040
  self.add_history(f"Found `[$success]{term}[/]` across all columns")
3032
3041
 
3033
3042
  # Add to matches and count total
3034
- match_count = sum(len(col_idxs) for col_idxs in matches.values())
3035
- for ridx, col_idxs in matches.items():
3036
- self.matches[ridx].update(col_idxs)
3043
+ match_count = sum(len(cols) for cols in matches.values())
3044
+ for rid, cols in matches.items():
3045
+ self.matches[rid].update(cols)
3037
3046
 
3038
3047
  self.notify(
3039
3048
  f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]` across all columns",
@@ -3095,7 +3104,7 @@ class DataFrameTable(DataTable):
3095
3104
 
3096
3105
  def do_next_selected_row(self) -> None:
3097
3106
  """Move cursor to the next selected row."""
3098
- if not any(self.selected_rows):
3107
+ if not self.selected_rows:
3099
3108
  self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
3100
3109
  return
3101
3110
 
@@ -3117,7 +3126,7 @@ class DataFrameTable(DataTable):
3117
3126
 
3118
3127
  def do_previous_selected_row(self) -> None:
3119
3128
  """Move cursor to the previous selected row."""
3120
- if not any(self.selected_rows):
3129
+ if not self.selected_rows:
3121
3130
  self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
3122
3131
  return
3123
3132
 
@@ -3190,25 +3199,34 @@ class DataFrameTable(DataTable):
3190
3199
  )
3191
3200
 
3192
3201
  # Update matches
3193
- self.matches = {ridx: col_idxs.copy() for ridx, col_idxs in matches.items()}
3202
+ self.matches = matches
3194
3203
 
3195
3204
  # Recreate table for display
3196
3205
  self.setup_table()
3197
3206
 
3198
3207
  # Store state for interactive replacement using dataclass
3199
- sorted_rows = sorted(self.matches.keys())
3208
+ rid2ridx = {rid: ridx for ridx, rid in enumerate(self.df[RID]) if rid in self.matches}
3209
+
3210
+ # Unique columns to replace
3211
+ cols_to_replace = set()
3212
+ for cols in self.matches.values():
3213
+ cols_to_replace.update(cols)
3214
+
3215
+ # Sorted column indices to replace
3216
+ cidx2col = {cidx: col for cidx, col in enumerate(self.df.columns) if col in cols_to_replace}
3217
+
3200
3218
  self.replace_state = ReplaceState(
3201
3219
  term_find=term_find,
3202
3220
  term_replace=term_replace,
3203
3221
  match_nocase=match_nocase,
3204
3222
  match_whole=match_whole,
3205
3223
  cidx=cidx,
3206
- rows=sorted_rows,
3207
- cols_per_row=[sorted(self.matches[ridx]) for ridx in sorted_rows],
3224
+ rows=list(rid2ridx.values()),
3225
+ cols_per_row=[[cidx for cidx, col in cidx2col.items() if col in self.matches[rid]] for rid in rid2ridx],
3208
3226
  current_rpos=0,
3209
3227
  current_cpos=0,
3210
3228
  current_occurrence=0,
3211
- total_occurrence=sum(len(col_idxs) for col_idxs in self.matches.values()),
3229
+ total_occurrence=sum(len(cols) for cols in self.matches.values()),
3212
3230
  replaced_occurrence=0,
3213
3231
  skipped_occurrence=0,
3214
3232
  done=False,
@@ -3292,6 +3310,18 @@ class DataFrameTable(DataTable):
3292
3310
  pl.when(mask).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
3293
3311
  )
3294
3312
 
3313
+ # Also update the view if applicable
3314
+ if self.df_view is not None:
3315
+ col_updated = f"^_{col_name}_^"
3316
+ lf_updated = self.df.lazy().filter(mask).select(pl.col(col_name).alias(col_updated), pl.col(RID))
3317
+ self.df_view = (
3318
+ self.df_view.lazy()
3319
+ .join(lf_updated, on=RID, how="left")
3320
+ .with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
3321
+ .drop(col_updated)
3322
+ .collect()
3323
+ )
3324
+
3295
3325
  state.replaced_occurrence += len(ridxs)
3296
3326
 
3297
3327
  # Recreate table for display
@@ -3303,7 +3333,7 @@ class DataFrameTable(DataTable):
3303
3333
 
3304
3334
  col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
3305
3335
  self.notify(
3306
- f"Replaced [$success]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$s]{col_name}[/]",
3336
+ f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]",
3307
3337
  title="Replace",
3308
3338
  )
3309
3339
 
@@ -3327,7 +3357,7 @@ class DataFrameTable(DataTable):
3327
3357
  if state.done:
3328
3358
  # All done - show final notification
3329
3359
  col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
3330
- msg = f"Replaced [$success]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]"
3360
+ msg = f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]"
3331
3361
  if state.skipped_occurrence > 0:
3332
3362
  msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
3333
3363
  self.notify(msg, title="Replace")
@@ -3362,6 +3392,7 @@ class DataFrameTable(DataTable):
3362
3392
  cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
3363
3393
  col_name = self.df.columns[cidx]
3364
3394
  dtype = self.df.dtypes[cidx]
3395
+ rid = self.df[RID][ridx]
3365
3396
 
3366
3397
  # Replace
3367
3398
  if result is True:
@@ -3374,6 +3405,15 @@ class DataFrameTable(DataTable):
3374
3405
  .otherwise(pl.col(col_name))
3375
3406
  .alias(col_name)
3376
3407
  )
3408
+
3409
+ # Also update the view if applicable
3410
+ if self.df_view is not None:
3411
+ self.df_view = self.df_view.with_columns(
3412
+ pl.when(pl.col(RID) == rid)
3413
+ .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
3414
+ .otherwise(pl.col(col_name))
3415
+ .alias(col_name)
3416
+ )
3377
3417
  else:
3378
3418
  # try to convert replacement value to column dtype
3379
3419
  try:
@@ -3388,6 +3428,12 @@ class DataFrameTable(DataTable):
3388
3428
  .alias(col_name)
3389
3429
  )
3390
3430
 
3431
+ # Also update the view if applicable
3432
+ if self.df_view is not None:
3433
+ self.df_view = self.df_view.with_columns(
3434
+ pl.when(pl.col(RID) == rid).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
3435
+ )
3436
+
3391
3437
  state.replaced_occurrence += 1
3392
3438
 
3393
3439
  # Skip
@@ -3424,18 +3470,16 @@ class DataFrameTable(DataTable):
3424
3470
  def do_view_rows(self) -> None:
3425
3471
  """View rows.
3426
3472
 
3427
- If there are selected rows or matches, view those rows.
3428
- Otherwise, view based on the value of the currently selected cell.
3473
+ If there are selected rows, view those.
3474
+ Otherwise, view based on the cursor value.
3429
3475
  """
3430
3476
 
3431
3477
  cidx = self.cursor_col_idx
3432
- col_name = self.df.columns[cidx]
3478
+ col_name = self.cursor_col_name
3433
3479
 
3434
- # If there are rows with selections or matches, use those
3435
- if any(self.selected_rows) or self.matches:
3436
- term = [
3437
- True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
3438
- ]
3480
+ # If there are selected rows, use those
3481
+ if self.selected_rows:
3482
+ term = pl.col(RID).is_in(self.selected_rows)
3439
3483
  # Otherwise, use the current cell value
3440
3484
  else:
3441
3485
  ridx = self.cursor_row_idx
@@ -3457,7 +3501,7 @@ class DataFrameTable(DataTable):
3457
3501
  )
3458
3502
 
3459
3503
  def view_rows(self, result) -> None:
3460
- """Show only rows with selections or matches, and do hide others. Do not modify the dataframe."""
3504
+ """View selected rows and hide others. Do not modify the dataframe."""
3461
3505
  if result is None:
3462
3506
  return
3463
3507
  term, cidx, match_nocase, match_whole = result
@@ -3467,13 +3511,17 @@ class DataFrameTable(DataTable):
3467
3511
  # Support for polars expression
3468
3512
  if isinstance(term, pl.Expr):
3469
3513
  expr = term
3514
+
3470
3515
  # Support for list of booleans (selected rows)
3471
3516
  elif isinstance(term, (list, pl.Series)):
3472
3517
  expr = term
3518
+
3519
+ # Null case
3473
3520
  elif term == NULL:
3474
3521
  expr = pl.col(col_name).is_null()
3522
+
3523
+ # Support for polars expression in string form
3475
3524
  elif tentative_expr(term):
3476
- # Support for polars expression in string form
3477
3525
  try:
3478
3526
  expr = validate_expr(term, self.df.columns, cidx)
3479
3527
  except Exception as e:
@@ -3482,6 +3530,8 @@ class DataFrameTable(DataTable):
3482
3530
  )
3483
3531
  self.log(f"Error validating expression `{term}`: {str(e)}")
3484
3532
  return
3533
+
3534
+ # Type-aware search based on column dtype
3485
3535
  else:
3486
3536
  dtype = self.df.dtypes[cidx]
3487
3537
  if dtype == pl.String:
@@ -3505,11 +3555,7 @@ class DataFrameTable(DataTable):
3505
3555
  )
3506
3556
 
3507
3557
  # Lazyframe with row indices
3508
- lf = self.df.lazy().with_row_index(RIDX)
3509
-
3510
- # Apply existing visibility filter first
3511
- if self.has_hidden_rows:
3512
- lf = lf.filter(self.visible_rows)
3558
+ lf = self.df.lazy()
3513
3559
 
3514
3560
  expr_str = "boolean list or series" if isinstance(expr, (list, pl.Series)) else str(expr)
3515
3561
 
@@ -3517,7 +3563,7 @@ class DataFrameTable(DataTable):
3517
3563
  try:
3518
3564
  df_filtered = lf.filter(expr).collect()
3519
3565
  except Exception as e:
3520
- self.histories.pop() # Remove last history entry
3566
+ self.histories_undo.pop() # Remove last history entry
3521
3567
  self.notify(f"Error applying filter [$error]{expr_str}[/]", title="Filter", severity="error", timeout=10)
3522
3568
  self.log(f"Error applying filter `{expr_str}`: {str(e)}")
3523
3569
  return
@@ -3530,26 +3576,37 @@ class DataFrameTable(DataTable):
3530
3576
  # Add to history
3531
3577
  self.add_history(f"Filtered by expression [$success]{expr_str}[/]")
3532
3578
 
3533
- # Mark unfiltered rows as invisible
3534
- filtered_row_indices = set(df_filtered[RIDX].to_list())
3535
- if filtered_row_indices:
3536
- for ridx in range(len(self.visible_rows)):
3537
- if ridx not in filtered_row_indices:
3538
- self.visible_rows[ridx] = False
3579
+ ok_rids = set(df_filtered[RID])
3580
+
3581
+ # Create a view of self.df as a copy
3582
+ if self.df_view is None:
3583
+ self.df_view = self.df
3584
+
3585
+ # Update dataframe
3586
+ self.df = df_filtered
3587
+
3588
+ # Update selected rows
3589
+ if self.selected_rows:
3590
+ self.selected_rows.intersection_update(ok_rids)
3591
+
3592
+ # Update matches
3593
+ if self.matches:
3594
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3539
3595
 
3540
3596
  # Recreate table for display
3541
3597
  self.setup_table()
3542
3598
 
3543
- self.notify(f"Filtered to [$success]{matched_count}[/] matching rows", title="Filter")
3599
+ self.notify(f"Filtered to [$success]{matched_count}[/] matching row(s)", title="Filter")
3544
3600
 
3545
3601
  def do_filter_rows(self) -> None:
3546
- """Keep only the rows with selections and cell matches, and remove others."""
3547
- if any(self.selected_rows) or self.matches:
3548
- message = "Filtered to rows with selection and cell matches (other rows removed)"
3549
- filter_expr = [
3550
- True if (selected or ridx in self.matches) else False
3551
- for ridx, selected in enumerate(self.selected_rows)
3552
- ]
3602
+ """Filter rows.
3603
+
3604
+ If there are selected rows, use those.
3605
+ Otherwise, filter based on the cursor value.
3606
+ """
3607
+ if self.selected_rows:
3608
+ message = "Filtered to selected rows (other rows removed)"
3609
+ filter_expr = pl.col(RID).is_in(self.selected_rows)
3553
3610
  else: # Search cursor value in current column
3554
3611
  message = "Filtered to rows matching cursor value (other rows removed)"
3555
3612
  cidx = self.cursor_col_idx
@@ -3565,16 +3622,26 @@ class DataFrameTable(DataTable):
3565
3622
  self.add_history(message, dirty=True)
3566
3623
 
3567
3624
  # Apply filter to dataframe with row indices
3568
- df_filtered = self.df.with_row_index(RIDX).filter(filter_expr)
3625
+ df_filtered = self.df.lazy().filter(filter_expr).collect()
3626
+ ok_rids = set(df_filtered[RID])
3569
3627
 
3570
3628
  # Update selected rows
3571
- selected_rows = [self.selected_rows[df_filtered[RIDX][ridx]] for ridx in range(len(df_filtered))]
3629
+ if self.selected_rows:
3630
+ selected_rows = {rid for rid in self.selected_rows if rid in ok_rids}
3631
+ else:
3632
+ selected_rows = set()
3572
3633
 
3573
3634
  # Update matches
3574
- matches = {ridx: self.matches[df_filtered[RIDX][ridx]] for ridx in range(len(df_filtered))}
3635
+ if self.matches:
3636
+ matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3637
+ else:
3638
+ matches = defaultdict(set)
3575
3639
 
3576
3640
  # Update dataframe
3577
- self.reset_df(df_filtered.drop(RIDX))
3641
+ self.reset_df(df_filtered)
3642
+
3643
+ # Clear view for filter mode
3644
+ self.df_view = None
3578
3645
 
3579
3646
  # Restore selected rows and matches
3580
3647
  self.selected_rows = selected_rows
@@ -3583,7 +3650,7 @@ class DataFrameTable(DataTable):
3583
3650
  # Recreate table for display
3584
3651
  self.setup_table()
3585
3652
 
3586
- self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows", title="Filter")
3653
+ self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter")
3587
3654
 
3588
3655
  # Copy & Save
3589
3656
  def do_copy_to_clipboard(self, content: str, message: str) -> None:
@@ -3609,20 +3676,24 @@ class DataFrameTable(DataTable):
3609
3676
  except FileNotFoundError:
3610
3677
  self.notify("Error copying to clipboard", title="Clipboard", severity="error", timeout=10)
3611
3678
 
3612
- def do_save_to_file(
3613
- self, title: str = "Save to File", all_tabs: bool | None = None, task_after_save: str | None = None
3614
- ) -> None:
3679
+ def do_save_to_file(self, all_tabs: bool | None = None, task_after_save: str | None = None) -> None:
3615
3680
  """Open screen to save file."""
3616
3681
  self._task_after_save = task_after_save
3682
+ tab_count = len(self.app.tabs)
3683
+ save_all = tab_count > 1 and all_tabs is not False
3684
+
3685
+ filepath = Path(self.filename)
3686
+ if save_all:
3687
+ ext = filepath.suffix.lower()
3688
+ if ext in (".xlsx", ".xls"):
3689
+ filename = self.filename
3690
+ else:
3691
+ filename = "all-tabs.xlsx"
3692
+ else:
3693
+ filename = str(filepath.with_stem(self.tabname))
3617
3694
 
3618
- multi_tab = len(self.app.tabs) > 1
3619
- filename = (
3620
- "all-tabs.xlsx"
3621
- if all_tabs or (all_tabs is None and multi_tab)
3622
- else str(Path(self.filename).with_stem(self.tabname))
3623
- )
3624
3695
  self.app.push_screen(
3625
- SaveFileScreen(filename, title=title, all_tabs=all_tabs, multi_tab=multi_tab),
3696
+ SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
3626
3697
  callback=self.save_to_file,
3627
3698
  )
3628
3699
 
@@ -3630,10 +3701,8 @@ class DataFrameTable(DataTable):
3630
3701
  """Handle result from SaveFileScreen."""
3631
3702
  if result is None:
3632
3703
  return
3633
- filename, all_tabs, overwrite_prompt = result
3634
-
3635
- # Whether to save all tabs (for Excel files)
3636
- self._all_tabs = all_tabs
3704
+ filename, save_all, overwrite_prompt = result
3705
+ self._save_all = save_all
3637
3706
 
3638
3707
  # Check if file exists
3639
3708
  if overwrite_prompt and Path(filename).exists():
@@ -3652,7 +3721,7 @@ class DataFrameTable(DataTable):
3652
3721
  else:
3653
3722
  # Go back to SaveFileScreen to allow user to enter a different name
3654
3723
  self.app.push_screen(
3655
- SaveFileScreen(self._pending_filename),
3724
+ SaveFileScreen(self._pending_filename, save_all=self._save_all),
3656
3725
  callback=self.save_to_file,
3657
3726
  )
3658
3727
 
@@ -3660,7 +3729,7 @@ class DataFrameTable(DataTable):
3660
3729
  """Actually save the dataframe to a file."""
3661
3730
  filepath = Path(filename)
3662
3731
  ext = filepath.suffix.lower()
3663
- if ext.endswith(".gz"):
3732
+ if ext == ".gz":
3664
3733
  ext = Path(filename).with_suffix("").suffix.lower()
3665
3734
 
3666
3735
  fmt = ext.removeprefix(".")
@@ -3672,30 +3741,28 @@ class DataFrameTable(DataTable):
3672
3741
  )
3673
3742
  fmt = "csv"
3674
3743
 
3675
- # Add to history
3676
- self.add_history(f"Saved dataframe to [$success]{filename}[/]")
3677
-
3744
+ df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3678
3745
  try:
3679
3746
  if fmt == "csv":
3680
- self.df.write_csv(filename)
3747
+ df.write_csv(filename)
3681
3748
  elif fmt in ("tsv", "tab"):
3682
- self.df.write_csv(filename, separator="\t")
3749
+ df.write_csv(filename, separator="\t")
3683
3750
  elif fmt in ("xlsx", "xls"):
3684
3751
  self.save_excel(filename)
3685
3752
  elif fmt == "json":
3686
- self.df.write_json(filename)
3753
+ df.write_json(filename)
3687
3754
  elif fmt == "ndjson":
3688
- self.df.write_ndjson(filename)
3755
+ df.write_ndjson(filename)
3689
3756
  elif fmt == "parquet":
3690
- self.df.write_parquet(filename)
3757
+ df.write_parquet(filename)
3691
3758
  else: # Fallback to CSV
3692
- self.df.write_csv(filename)
3759
+ df.write_csv(filename)
3693
3760
 
3694
3761
  # Update current filename
3695
3762
  self.filename = filename
3696
3763
 
3697
3764
  # Reset dirty flag after save
3698
- if self._all_tabs:
3765
+ if self._save_all:
3699
3766
  tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3700
3767
  for table in tabs.values():
3701
3768
  table.dirty = False
@@ -3709,7 +3776,7 @@ class DataFrameTable(DataTable):
3709
3776
  self.app.exit()
3710
3777
 
3711
3778
  # From ConfirmScreen callback, so notify accordingly
3712
- if self._all_tabs:
3779
+ if self._save_all:
3713
3780
  self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
3714
3781
  else:
3715
3782
  self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
@@ -3722,16 +3789,18 @@ class DataFrameTable(DataTable):
3722
3789
  """Save to an Excel file."""
3723
3790
  import xlsxwriter
3724
3791
 
3725
- if not self._all_tabs or len(self.app.tabs) == 1:
3792
+ if not self._save_all or len(self.app.tabs) == 1:
3726
3793
  # Single tab - save directly
3727
- self.df.write_excel(filename)
3794
+ df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3795
+ df.write_excel(filename, worksheet=self.tabname)
3728
3796
  else:
3729
3797
  # Multiple tabs - use xlsxwriter to create multiple sheets
3730
3798
  with xlsxwriter.Workbook(filename) as wb:
3731
3799
  tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3732
3800
  for table in tabs.values():
3733
3801
  worksheet = wb.add_worksheet(table.tabname)
3734
- table.df.write_excel(workbook=wb, worksheet=worksheet)
3802
+ df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
3803
+ df.write_excel(workbook=wb, worksheet=worksheet)
3735
3804
 
3736
3805
  # SQL Interface
3737
3806
  def do_simple_sql(self) -> None:
@@ -3775,19 +3844,17 @@ class DataFrameTable(DataTable):
3775
3844
  sql: The SQL query string to execute.
3776
3845
  """
3777
3846
 
3778
- import re
3779
-
3780
- RE_FROM_SELF = re.compile(r"\bfrom\s+self\b", re.IGNORECASE)
3847
+ sql = sql.replace("$#", f"(`{RID}` + 1)")
3848
+ if RID not in sql and "*" not in sql:
3849
+ # Ensure RID is selected
3850
+ import re
3781
3851
 
3782
- sql = RE_FROM_SELF.sub(f", `{RIDX}` FROM self", sql)
3852
+ RE_FROM_SELF = re.compile(r"\bFROM\s+self\b", re.IGNORECASE)
3853
+ sql = RE_FROM_SELF.sub(f", `{RID}` FROM self", sql)
3783
3854
 
3784
3855
  # Execute the SQL query
3785
3856
  try:
3786
- lf = self.df.lazy().with_row_index(RIDX)
3787
- if self.has_hidden_rows:
3788
- lf = lf.filter(self.visible_rows)
3789
-
3790
- df_filtered = lf.sql(sql).collect()
3857
+ df_filtered = self.df.lazy().sql(sql).collect()
3791
3858
 
3792
3859
  if not len(df_filtered):
3793
3860
  self.notify(
@@ -3795,38 +3862,34 @@ class DataFrameTable(DataTable):
3795
3862
  )
3796
3863
  return
3797
3864
 
3798
- # Add to history
3799
- self.add_history(f"SQL Query:\n[$success]{sql}[/]", dirty=not view)
3800
-
3801
- if view:
3802
- # Just view - do not modify the dataframe
3803
- filtered_row_indices = set(df_filtered[RIDX].to_list())
3804
- if filtered_row_indices:
3805
- self.visible_rows = [ridx in filtered_row_indices for ridx in range(len(self.visible_rows))]
3806
-
3807
- filtered_col_names = set(df_filtered.columns)
3808
- if filtered_col_names:
3809
- self.hidden_columns = {
3810
- col_name for col_name in self.df.columns if col_name not in filtered_col_names
3811
- }
3812
- else: # filter - modify the dataframe
3813
- # Update selected rows
3814
- selected_rows = [self.selected_rows[df_filtered[RIDX][ridx]] for ridx in range(len(df_filtered))]
3815
-
3816
- # Update matches
3817
- matches = {ridx: self.matches[df_filtered[RIDX][ridx]] for ridx in range(len(df_filtered))}
3818
-
3819
- # Update dataframe
3820
- self.reset_df(df_filtered.drop(RIDX))
3821
-
3822
- # Restore selected rows and matches
3823
- self.selected_rows = selected_rows
3824
- self.matches = matches
3825
3865
  except Exception as e:
3826
3866
  self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error", timeout=10)
3827
3867
  self.log(f"Error executing SQL query `{sql}`: {str(e)}")
3828
3868
  return
3829
3869
 
3870
+ # Add to history
3871
+ self.add_history(f"SQL Query:\n[$success]{sql}[/]", dirty=not view)
3872
+
3873
+ # Create a view of self.df as a copy
3874
+ if view and self.df_view is None:
3875
+ self.df_view = self.df
3876
+
3877
+ # Clear view for filter mode
3878
+ if not view:
3879
+ self.df_view = None
3880
+
3881
+ # Update dataframe
3882
+ self.df = df_filtered
3883
+ ok_rids = set(df_filtered[RID])
3884
+
3885
+ # Update selected rows
3886
+ if self.selected_rows:
3887
+ self.selected_rows.intersection_update(ok_rids)
3888
+
3889
+ # Update matches
3890
+ if self.matches:
3891
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3892
+
3830
3893
  # Recreate table for display
3831
3894
  self.setup_table()
3832
3895