dataframe-textual 1.9.0__py3-none-any.whl → 2.2.2__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.
@@ -3,13 +3,14 @@
3
3
  import sys
4
4
  from collections import defaultdict, deque
5
5
  from dataclasses import dataclass
6
+ from itertools import zip_longest
6
7
  from pathlib import Path
7
8
  from textwrap import dedent
8
9
  from typing import Any
9
10
 
10
11
  import polars as pl
11
- from rich.text import Text
12
- from textual import work
12
+ from rich.text import Text, TextType
13
+ from textual._two_way_dict import TwoWayDict
13
14
  from textual.coordinate import Coordinate
14
15
  from textual.events import Click
15
16
  from textual.reactive import reactive
@@ -18,8 +19,12 @@ from textual.widgets import DataTable, TabPane
18
19
  from textual.widgets._data_table import (
19
20
  CellDoesNotExist,
20
21
  CellKey,
22
+ CellType,
23
+ Column,
21
24
  ColumnKey,
22
25
  CursorType,
26
+ DuplicateKey,
27
+ Row,
23
28
  RowKey,
24
29
  )
25
30
 
@@ -27,19 +32,19 @@ from .common import (
27
32
  CURSOR_TYPES,
28
33
  NULL,
29
34
  NULL_DISPLAY,
30
- RIDX,
35
+ RID,
31
36
  SUBSCRIPT_DIGITS,
37
+ SUPPORTED_FORMATS,
32
38
  DtypeConfig,
33
39
  format_row,
34
40
  get_next_item,
35
41
  parse_placeholders,
36
- rindex,
37
- sleep_async,
42
+ round_to_nearest_hundreds,
38
43
  tentative_expr,
39
44
  validate_expr,
40
45
  )
41
46
  from .sql_screen import AdvancedSqlScreen, SimpleSqlScreen
42
- from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
47
+ from .table_screen import FrequencyScreen, MetaColumnScreen, MetaShape, RowDetailScreen, StatisticsScreen
43
48
  from .yes_no_screen import (
44
49
  AddColumnScreen,
45
50
  AddLinkScreen,
@@ -57,6 +62,9 @@ from .yes_no_screen import (
57
62
  # Color for highlighting selections and matches
58
63
  HIGHLIGHT_COLOR = "red"
59
64
 
65
+ # Buffer size for loading rows
66
+ BUFFER_SIZE = 5
67
+
60
68
  # Warning threshold for loading rows
61
69
  WARN_ROWS_THRESHOLD = 50_000
62
70
 
@@ -70,16 +78,15 @@ class History:
70
78
 
71
79
  description: str
72
80
  df: pl.DataFrame
81
+ df_view: pl.DataFrame | None
73
82
  filename: str
74
- loaded_rows: int
75
- sorted_columns: dict[str, bool]
76
83
  hidden_columns: set[str]
77
- selected_rows: list[bool]
78
- visible_rows: list[bool]
84
+ selected_rows: set[int]
85
+ sorted_columns: dict[str, bool] # col_name -> descending
86
+ matches: dict[int, set[str]] # RID -> set of col names
79
87
  fixed_rows: int
80
88
  fixed_columns: int
81
89
  cursor_coordinate: Coordinate
82
- matches: dict[int, set[int]]
83
90
  dirty: bool = False # Whether this history state has unsaved changes
84
91
 
85
92
 
@@ -103,6 +110,20 @@ class ReplaceState:
103
110
  done: bool = False # Whether the replace operation is complete
104
111
 
105
112
 
113
+ def add_rid_column(df: pl.DataFrame) -> pl.DataFrame:
114
+ """Add internal row index as last column to the dataframe if not already present.
115
+
116
+ Args:
117
+ df: The Polars DataFrame to modify.
118
+
119
+ Returns:
120
+ The modified DataFrame with the internal row index column added.
121
+ """
122
+ if RID not in df.columns:
123
+ df = df.lazy().with_row_index(RID).select(pl.exclude(RID), RID).collect()
124
+ return df
125
+
126
+
106
127
  class DataFrameTable(DataTable):
107
128
  """Custom DataTable to highlight row/column labels based on cursor position."""
108
129
 
@@ -115,7 +136,7 @@ class DataFrameTable(DataTable):
115
136
  - **g** - ⬆️ Jump to first row
116
137
  - **G** - ⬇️ Jump to last row
117
138
  - **HOME/END** - 🎯 Jump to first/last column
118
- - **Ctrl+HOME/END** - 🎯 Jump to page top/bottom
139
+ - **Ctrl+HOME/END** - 🎯 Jump to page top/top
119
140
  - **Ctrl+F** - 📜 Page down
120
141
  - **Ctrl+B** - 📜 Page up
121
142
  - **PgUp/PgDn** - 📜 Page up/down
@@ -125,14 +146,16 @@ class DataFrameTable(DataTable):
125
146
  - **U** - 🔄 Redo last undone action
126
147
  - **Ctrl+U** - 🔁 Reset to initial state
127
148
 
128
- ## 👁️ Viewing & Display
149
+ ## 👁️ Display
129
150
  - **Enter** - 📋 Show row details in modal
130
151
  - **F** - 📊 Show frequency distribution
131
152
  - **s** - 📈 Show statistics for current column
132
153
  - **S** - 📊 Show statistics for entire dataframe
154
+ - **m** - 📐 Show dataframe metadata (row/column counts)
155
+ - **M** - 📋 Show column metadata (ID, name, type)
133
156
  - **h** - 👁️ Hide current column
134
157
  - **H** - 👀 Show all hidden rows/columns
135
- - **_** - 📏 Expand column to full width
158
+ - **_** - 📏 Toggle column full width
136
159
  - **z** - 📌 Freeze rows and columns
137
160
  - **~** - 🏷️ Toggle row labels
138
161
  - **,** - 🔢 Toggle thousand separator for numeric display
@@ -143,31 +166,31 @@ class DataFrameTable(DataTable):
143
166
  - **]** - 🔽 Sort column descending
144
167
  - *(Multi-column sort supported)*
145
168
 
146
- ## 🔍 Searching & Filtering
147
- - **|** - 🔎 Search in current column with expression
148
- - **\\\\** - 🔎 Search in current column using cursor value
169
+ ## Row Selection
170
+ - **\\\\** - Select rows with cell matches or those matching cursor value in current column
171
+ - **|** - Select rows with expression
172
+ - **'** - ✅ Select/deselect current row
173
+ - **t** - 💡 Toggle row selection (invert all)
174
+ - **T** - 🧹 Clear all selections and matches
175
+ - **{** - ⬆️ Go to previous selected row
176
+ - **}** - ⬇️ Go to next selected row
177
+ - *(Supports case-insensitive & whole-word matching)*
178
+
179
+ ## 🔎 Find & Replace
149
180
  - **/** - 🔎 Find in current column with cursor value
150
181
  - **?** - 🔎 Find in current column with expression
151
182
  - **;** - 🌐 Global find using cursor value
152
183
  - **:** - 🌐 Global find with expression
153
184
  - **n** - ⬇️ Go to next match
154
185
  - **N** - ⬆️ Go to previous match
155
- - **v** - 👁️ View/filter rows by cell or selected rows and hide others
156
- - **V** - 🔧 View/filter rows by expression and hide others
157
- - *(All search/find support case-insensitive & whole-word matching)*
158
-
159
- ## ✏️ Replace
160
186
  - **r** - 🔄 Replace in current column (interactive or all)
161
187
  - **R** - 🔄 Replace across all columns (interactive or all)
162
188
  - *(Supports case-insensitive & whole-word matching)*
163
189
 
164
- ## Selection & Filter
165
- - **'** - ✓️ Select/deselect current row
166
- - **t** - 💡 Toggle row selection (invert all)
167
- - **T** - 🧹 Clear all selections and matches
168
- - **{** - ⬆️ Go to previous selected row
169
- - **}** - ⬇️ Go to next selected row
170
- - **"** - 📍 Filter selected rows and remove others
190
+ ## 👁️ View & Filter
191
+ - **"** - 📍 Filter selected rows (removes others)
192
+ - **v** - 👁️ View selected rows (hides others)
193
+ - **V** - 🔧 View selected rows matching expression (hides others)
171
194
 
172
195
  ## 🔍 SQL Interface
173
196
  - **l** - 💬 Open simple SQL interface (select columns & where clause)
@@ -210,8 +233,8 @@ class DataFrameTable(DataTable):
210
233
  # Navigation
211
234
  ("g", "jump_top", "Jump to top"),
212
235
  ("G", "jump_bottom", "Jump to bottom"),
213
- ("ctrl+f", "forward_page", "Page down"),
214
- ("ctrl+b", "backward_page", "Page up"),
236
+ ("pageup,ctrl+b", "page_up", "Page up"),
237
+ ("pagedown,ctrl+f", "page_down", "Page down"),
215
238
  # Undo/Redo/Reset
216
239
  ("u", "undo", "Undo"),
217
240
  ("U", "redo", "Redo"),
@@ -224,13 +247,16 @@ class DataFrameTable(DataTable):
224
247
  ("z", "freeze_row_column", "Freeze rows/columns"),
225
248
  ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
226
249
  ("underscore", "expand_column", "Expand column to full width"), # `_`
250
+ ("circumflex_accent", "toggle_rid", "Toggle internal row index"), # `^`
227
251
  # Copy
228
252
  ("c", "copy_cell", "Copy cell to clipboard"),
229
253
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
230
254
  ("ctrl+r", "copy_row", "Copy row to clipboard"),
231
255
  # Save
232
256
  ("ctrl+s", "save_to_file", "Save to file"),
233
- # Detail, Frequency, and Statistics
257
+ # Metadata, Detail, Frequency, and Statistics
258
+ ("m", "metadata_shape", "Show metadata for row count and column count"),
259
+ ("M", "metadata_column", "Show metadata for column"),
234
260
  ("enter", "view_row_detail", "View row details"),
235
261
  ("F", "show_frequency", "Show frequency"),
236
262
  ("s", "show_statistics", "Show statistics for column"),
@@ -239,28 +265,26 @@ class DataFrameTable(DataTable):
239
265
  ("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
240
266
  ("right_square_bracket", "sort_descending", "Sort descending"), # `]`
241
267
  # View & Filter
242
- ("v", "view_rows", "View rows"),
243
- ("V", "view_rows_expr", "View rows by expression"),
244
- ("quotation_mark", "filter_rows", "Filter selected"), # `"`
245
- # Search
246
- ("backslash", "search_cursor_value", "Search column with cursor value"), # `\`
247
- ("vertical_line", "search_expr", "Search column with expression"), # `|`
268
+ ("v", "view_rows", "View selected rows"),
269
+ ("V", "view_rows_expr", "View selected rows matching expression"),
270
+ ("quotation_mark", "filter_rows", "Filter selected rows"), # `"`
271
+ # Row Selection
272
+ ("backslash", "select_row", "Select rows with cell matches or those matching cursor value in current column"), # `\`
273
+ ("vertical_line", "select_row_expr", "Select rows with expression"), # `|`
248
274
  ("right_curly_bracket", "next_selected_row", "Go to next selected row"), # `}`
249
275
  ("left_curly_bracket", "previous_selected_row", "Go to previous selected row"), # `{`
250
- # Find
276
+ ("apostrophe", "toggle_row_selection", "Toggle row selection"), # `'`
277
+ ("t", "toggle_selections", "Toggle all row selections"),
278
+ ("T", "clear_selections_and_matches", "Clear selections"),
279
+ # Find & Replace
251
280
  ("slash", "find_cursor_value", "Find in column with cursor value"), # `/`
252
281
  ("question_mark", "find_expr", "Find in column with expression"), # `?`
253
282
  ("semicolon", "find_cursor_value('global')", "Global find with cursor value"), # `;`
254
283
  ("colon", "find_expr('global')", "Global find with expression"), # `:`
255
284
  ("n", "next_match", "Go to next match"), # `n`
256
285
  ("N", "previous_match", "Go to previous match"), # `Shift+n`
257
- # Replace
258
286
  ("r", "replace", "Replace in column"), # `r`
259
287
  ("R", "replace_global", "Replace global"), # `Shift+R`
260
- # Selection
261
- ("apostrophe", "toggle_row_selection", "Toggle row selection"), # `'`
262
- ("t", "toggle_selections", "Toggle all row selections"),
263
- ("T", "clear_selections_and_matches", "Clear selections"),
264
288
  # Delete
265
289
  ("delete", "clear_cell", "Clear cell"),
266
290
  ("minus", "delete_column", "Delete column"), # `-`
@@ -311,34 +335,43 @@ class DataFrameTable(DataTable):
311
335
  super().__init__(**kwargs)
312
336
 
313
337
  # DataFrame state
314
- self.dataframe = df # Original dataframe
315
- self.df = df # Internal/working dataframe
338
+ self.dataframe = add_rid_column(df) # Original dataframe
339
+ self.df = self.dataframe # Internal/working dataframe
316
340
  self.filename = filename or "untitled.csv" # Current filename
317
341
  self.tabname = tabname or Path(filename).stem # Tab name
342
+
343
+ # In view mode, this is the copy of self.df
344
+ self.df_view = None
345
+
318
346
  # Pagination & Loading
319
- self.INITIAL_BATCH_SIZE = (self.app.size.height // 100 + 1) * 100
320
- self.BATCH_SIZE = self.INITIAL_BATCH_SIZE // 2
347
+ self.BATCH_SIZE = max((self.app.size.height // 100 + 1) * 100, 100)
321
348
  self.loaded_rows = 0 # Track how many rows are currently loaded
349
+ self.loaded_ranges: list[tuple[int, int]] = [] # List of (start, end) row indices that are loaded
322
350
 
323
351
  # State tracking (all 0-based indexing)
324
- self.sorted_columns: dict[str, bool] = {} # col_name -> descending
325
352
  self.hidden_columns: set[str] = set() # Set of hidden column names
326
- self.selected_rows: list[bool] = [False] * len(self.df) # Track selected rows
327
- self.visible_rows: list[bool] = [True] * len(self.df) # Track visible rows (for filtering)
328
- self.matches: dict[int, set[int]] = defaultdict(set) # Track search matches: row_idx -> set of col_idx
353
+ self.selected_rows: set[int] = set() # Track selected rows by RID
354
+ self.sorted_columns: dict[str, bool] = {} # col_name -> descending
355
+ self.matches: dict[int, set[str]] = defaultdict(set) # Track search matches: RID -> set of col_names
329
356
 
330
357
  # Freezing
331
358
  self.fixed_rows = 0 # Number of fixed rows
332
359
  self.fixed_columns = 0 # Number of fixed columns
333
360
 
334
361
  # History stack for undo
335
- self.histories: deque[History] = deque()
336
- # Current history state for redo
337
- self.history: History = None
362
+ self.histories_undo: deque[History] = deque()
363
+ # History stack for redo
364
+ self.histories_redo: deque[History] = deque()
338
365
 
339
366
  # Whether to use thousand separator for numeric display
340
367
  self.thousand_separator = False
341
368
 
369
+ # Set of columns expanded to full width
370
+ self.expanded_columns: set[str] = set()
371
+
372
+ # Whether to show internal row index column
373
+ self.show_rid = False
374
+
342
375
  @property
343
376
  def cursor_key(self) -> CellKey:
344
377
  """Get the current cursor position as a CellKey.
@@ -405,7 +438,7 @@ class DataFrameTable(DataTable):
405
438
 
406
439
  @property
407
440
  def cursor_value(self) -> Any:
408
- """Get the current cursor cell value.
441
+ """Get the current cursor cell value in the dataframe.
409
442
 
410
443
  Returns:
411
444
  Any: The value of the cell at the cursor position.
@@ -419,7 +452,7 @@ class DataFrameTable(DataTable):
419
452
  Returns:
420
453
  list[int]: A list of 0-based row indices that are currently selected.
421
454
  """
422
- return [ridx for ridx, selected in enumerate(self.selected_rows) if selected]
455
+ return [ridx for ridx, rid in enumerate(self.df[RID]) if rid in self.selected_rows]
423
456
 
424
457
  @property
425
458
  def ordered_matches(self) -> list[tuple[int, int]]:
@@ -429,19 +462,38 @@ class DataFrameTable(DataTable):
429
462
  list[tuple[int, int]]: A list of (row_idx, col_idx) tuples for matched cells.
430
463
  """
431
464
  matches = []
432
- for ridx in sorted(self.matches.keys()):
433
- for cidx in sorted(self.matches[ridx]):
434
- matches.append((ridx, cidx))
465
+
466
+ # Uniq columns
467
+ cols_to_check = set()
468
+ for cols in self.matches.values():
469
+ cols_to_check.update(cols)
470
+
471
+ # Ordered columns
472
+ cidx2col = {cidx: col for cidx, col in enumerate(self.df.columns) if col in cols_to_check}
473
+
474
+ for ridx, rid in enumerate(self.df[RID]):
475
+ if cols := self.matches.get(rid):
476
+ for cidx, col in cidx2col.items():
477
+ if col in cols:
478
+ matches.append((ridx, cidx))
479
+
435
480
  return matches
436
481
 
437
- @property
438
- def last_history(self) -> History:
439
- """Get the last history state.
482
+ def _round_to_nearest_hundreds(self, num: int):
483
+ """Round a number to the nearest hundreds.
440
484
 
441
- Returns:
442
- History: The most recent History object from the histories deque.
485
+ Args:
486
+ num: The number to round.
443
487
  """
444
- return self.histories[-1] if self.histories else None
488
+ return round_to_nearest_hundreds(num, N=self.BATCH_SIZE)
489
+
490
+ def get_row_idx(self, row_key: RowKey) -> int:
491
+ """Get the row index for a given table row key.
492
+
493
+ Args:
494
+ row_key: Row key as string.
495
+ """
496
+ return super().get_row_index(row_key)
445
497
 
446
498
  def get_row_key(self, row_idx: int) -> RowKey:
447
499
  """Get the row key for a given table row index.
@@ -454,7 +506,18 @@ class DataFrameTable(DataTable):
454
506
  """
455
507
  return self._row_locations.get_key(row_idx)
456
508
 
457
- def get_column_key(self, col_idx: int) -> ColumnKey:
509
+ def get_col_idx(self, col_key: ColumnKey) -> int:
510
+ """Get the column index for a given table column key.
511
+
512
+ Args:
513
+ col_key: Column key as string.
514
+
515
+ Returns:
516
+ Corresponding column index as int.
517
+ """
518
+ return super().get_column_index(col_key)
519
+
520
+ def get_col_key(self, col_idx: int) -> ColumnKey:
458
521
  """Get the column key for a given table column index.
459
522
 
460
523
  Args:
@@ -465,11 +528,11 @@ class DataFrameTable(DataTable):
465
528
  """
466
529
  return self._column_locations.get_key(col_idx)
467
530
 
468
- def should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
531
+ def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
469
532
  """Determine if the given cell should be highlighted because of the cursor.
470
533
 
471
- In "cell" mode, also highlights the row and column headers. In "row" and "column"
472
- modes, highlights the entire row or column respectively.
534
+ In "cell" mode, also highlights the row and column headers. This overrides the default
535
+ behavior of DataTable which only highlights the exact cell under the cursor.
473
536
 
474
537
  Args:
475
538
  cursor: The current position of the cursor.
@@ -566,7 +629,7 @@ class DataFrameTable(DataTable):
566
629
  else:
567
630
  content_tab.remove_class("dirty")
568
631
 
569
- def move_cursor_to(self, ridx: int, cidx: int) -> None:
632
+ def move_cursor_to(self, ridx: int | None = None, cidx: int | None = None) -> None:
570
633
  """Move cursor based on the dataframe indices.
571
634
 
572
635
  Args:
@@ -574,11 +637,11 @@ class DataFrameTable(DataTable):
574
637
  cidx: Column index (0-based) in the dataframe.
575
638
  """
576
639
  # Ensure the target row is loaded
577
- if ridx >= self.loaded_rows:
578
- self.load_rows(stop=ridx + self.BATCH_SIZE)
640
+ start, stop = self._round_to_nearest_hundreds(ridx)
641
+ self.load_rows_range(start, stop)
579
642
 
580
- row_key = str(ridx)
581
- col_key = self.df.columns[cidx]
643
+ row_key = self.cursor_row_key if ridx is None else str(ridx)
644
+ col_key = self.cursor_col_key if cidx is None else self.df.columns[cidx]
582
645
  row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
583
646
  self.move_cursor(row=row_idx, column=col_idx)
584
647
 
@@ -594,15 +657,15 @@ class DataFrameTable(DataTable):
594
657
  def on_key(self, event) -> None:
595
658
  """Handle key press events for pagination.
596
659
 
597
- Currently handles "pagedown" and "down" keys to trigger lazy loading of additional rows
598
- when scrolling near the end of the loaded data.
599
-
600
660
  Args:
601
661
  event: The key event object.
602
662
  """
603
- if event.key in ("pagedown", "down"):
663
+ if event.key == "up":
604
664
  # Let the table handle the navigation first
605
- self.check_and_load_more()
665
+ self.load_rows_up()
666
+ elif event.key == "down":
667
+ # Let the table handle the navigation first
668
+ self.load_rows_down()
606
669
 
607
670
  def on_click(self, event: Click) -> None:
608
671
  """Handle mouse click events on the table.
@@ -615,33 +678,32 @@ class DataFrameTable(DataTable):
615
678
  if self.cursor_type == "cell" and event.chain > 1: # only on double-click or more
616
679
  try:
617
680
  row_idx = event.style.meta["row"]
618
- # col_idx = event.style.meta["column"]
681
+ col_idx = event.style.meta["column"]
619
682
  except (KeyError, TypeError):
620
683
  return # Unable to get row/column info
621
684
 
622
685
  # header row
623
686
  if row_idx == -1:
624
- self.do_rename_column()
687
+ self.do_rename_column(col_idx)
625
688
  else:
626
689
  self.do_edit_cell()
627
690
 
628
691
  # Action handlers for BINDINGS
629
692
  def action_jump_top(self) -> None:
630
693
  """Jump to the top of the table."""
631
- self.move_cursor(row=0)
694
+ self.do_jump_top()
632
695
 
633
696
  def action_jump_bottom(self) -> None:
634
697
  """Jump to the bottom of the table."""
635
- self.load_rows(move_to_end=True)
698
+ self.do_jump_bottom()
636
699
 
637
- def action_forward_page(self) -> None:
638
- """Scroll down one page."""
639
- super().action_page_down()
640
- self.check_and_load_more()
700
+ def action_page_up(self) -> None:
701
+ """Move the cursor one page up."""
702
+ self.do_page_up()
641
703
 
642
- def action_backward_page(self) -> None:
643
- """Scroll up one page."""
644
- super().action_page_up()
704
+ def action_page_down(self) -> None:
705
+ """Move the cursor one page down."""
706
+ self.do_page_down()
645
707
 
646
708
  def action_view_row_detail(self) -> None:
647
709
  """View details of the current row."""
@@ -659,6 +721,10 @@ class DataFrameTable(DataTable):
659
721
  """Expand the current column to its full width."""
660
722
  self.do_expand_column()
661
723
 
724
+ def action_toggle_rid(self) -> None:
725
+ """Toggle the internal row index column visibility."""
726
+ self.do_toggle_rid()
727
+
662
728
  def action_show_hidden_rows_columns(self) -> None:
663
729
  """Show all hidden rows/columns."""
664
730
  self.do_show_hidden_rows_columns()
@@ -687,6 +753,14 @@ class DataFrameTable(DataTable):
687
753
  """
688
754
  self.do_show_statistics(scope)
689
755
 
756
+ def action_metadata_shape(self) -> None:
757
+ """Show metadata about the dataframe (row and column counts)."""
758
+ self.do_metadata_shape()
759
+
760
+ def action_metadata_column(self) -> None:
761
+ """Show metadata for the current column."""
762
+ self.do_metadata_column()
763
+
690
764
  def action_view_rows(self) -> None:
691
765
  """View rows by current cell value."""
692
766
  self.do_view_rows()
@@ -723,13 +797,13 @@ class DataFrameTable(DataTable):
723
797
  """Clear the current cell (set to None)."""
724
798
  self.do_clear_cell()
725
799
 
726
- def action_search_cursor_value(self) -> None:
727
- """Search cursor value in the current column."""
728
- self.do_search_cursor_value()
800
+ def action_select_row(self) -> None:
801
+ """Select rows with cursor value in the current column."""
802
+ self.do_select_row()
729
803
 
730
- def action_search_expr(self) -> None:
731
- """Search by expression in the current column."""
732
- self.do_search_expr()
804
+ def action_select_row_expr(self) -> None:
805
+ """Select rows by expression."""
806
+ self.do_select_row_expr()
733
807
 
734
808
  def action_find_cursor_value(self, scope="column") -> None:
735
809
  """Find by cursor value.
@@ -846,7 +920,12 @@ class DataFrameTable(DataTable):
846
920
  cell_str = str(self.df.item(ridx, cidx))
847
921
  self.do_copy_to_clipboard(cell_str, f"Copied: [$success]{cell_str[:50]}[/]")
848
922
  except IndexError:
849
- self.notify("Error copying cell", title="Clipboard", severity="error")
923
+ self.notify(
924
+ f"Error copying cell ([$error]{ridx}[/], [$accent]{cidx}[/])",
925
+ title="Clipboard",
926
+ severity="error",
927
+ timeout=10,
928
+ )
850
929
 
851
930
  def action_copy_column(self) -> None:
852
931
  """Copy the current column to clipboard (one value per line)."""
@@ -862,7 +941,7 @@ class DataFrameTable(DataTable):
862
941
  f"Copied [$accent]{len(col_values)}[/] values from column [$success]{col_name}[/]",
863
942
  )
864
943
  except (FileNotFoundError, IndexError):
865
- self.notify("Error copying column", title="Clipboard", severity="error")
944
+ self.notify(f"Error copying column [$error]{col_name}[/]", title="Clipboard", severity="error", timeout=10)
866
945
 
867
946
  def action_copy_row(self) -> None:
868
947
  """Copy the current row to clipboard (values separated by tabs)."""
@@ -878,7 +957,7 @@ class DataFrameTable(DataTable):
878
957
  f"Copied row [$accent]{ridx + 1}[/] with [$success]{len(row_values)}[/] values",
879
958
  )
880
959
  except (FileNotFoundError, IndexError):
881
- self.notify("Error copying row", title="Clipboard", severity="error")
960
+ self.notify(f"Error copying row [$error]{ridx}[/]", title="Clipboard", severity="error", timeout=10)
882
961
 
883
962
  def action_show_thousand_separator(self) -> None:
884
963
  """Toggle thousand separator for numeric display."""
@@ -911,56 +990,50 @@ class DataFrameTable(DataTable):
911
990
  """Open the advanced SQL interface screen."""
912
991
  self.do_advanced_sql()
913
992
 
993
+ def on_mouse_scroll_up(self, event) -> None:
994
+ """Load more rows when scrolling up with mouse."""
995
+ self.load_rows_up()
996
+
914
997
  def on_mouse_scroll_down(self, event) -> None:
915
998
  """Load more rows when scrolling down with mouse."""
916
- self.check_and_load_more()
999
+ self.load_rows_down()
917
1000
 
918
1001
  # Setup & Loading
919
- def setup_table(self, reset: bool = False) -> None:
1002
+ def reset_df(self, new_df: pl.DataFrame, dirty: bool = True) -> None:
1003
+ """Reset the dataframe to a new one and refresh the table.
1004
+
1005
+ Args:
1006
+ new_df: The new Polars DataFrame to set.
1007
+ dirty: Whether to mark the table as dirty (unsaved changes). Defaults to True.
1008
+ """
1009
+ # Set new dataframe and reset table
1010
+ self.df = new_df
1011
+ self.loaded_rows = 0
1012
+ self.hidden_columns = set()
1013
+ self.selected_rows = set()
1014
+ self.sorted_columns = {}
1015
+ self.fixed_rows = 0
1016
+ self.fixed_columns = 0
1017
+ self.matches = defaultdict(set)
1018
+ # self.histories.clear()
1019
+ # self.histories2.clear()
1020
+ self.dirty = dirty # Mark as dirty since data changed
1021
+
1022
+ def setup_table(self) -> None:
920
1023
  """Setup the table for display.
921
1024
 
922
1025
  Row keys are 0-based indices, which map directly to dataframe row indices.
923
1026
  Column keys are header names from the dataframe.
924
1027
  """
925
1028
  self.loaded_rows = 0
1029
+ self.loaded_ranges.clear()
926
1030
  self.show_row_labels = True
927
1031
 
928
- # Reset to original dataframe
929
- if reset:
930
- self.df = self.dataframe
931
- self.loaded_rows = 0
932
- self.sorted_columns = {}
933
- self.hidden_columns = set()
934
- self.selected_rows = [False] * len(self.df)
935
- self.visible_rows = [True] * len(self.df)
936
- self.fixed_rows = 0
937
- self.fixed_columns = 0
938
- self.matches = defaultdict(set)
939
- self.histories.clear()
940
- self.history = None
941
- self.dirty = False
942
-
943
- # Lazy load up to INITIAL_BATCH_SIZE visible rows
944
- stop, visible_count = self.INITIAL_BATCH_SIZE, 0
945
- for row_idx, visible in enumerate(self.visible_rows):
946
- if not visible:
947
- continue
948
- visible_count += 1
949
- if visible_count > self.INITIAL_BATCH_SIZE:
950
- stop = row_idx + self.BATCH_SIZE
951
- break
952
- else:
953
- stop = row_idx + self.BATCH_SIZE
954
-
955
- # # Ensure all selected rows or matches are loaded
956
- # stop = max(stop, rindex(self.selected_rows, True) + 1)
957
- # stop = max(stop, max(self.matches.keys(), default=0) + 1)
958
-
959
1032
  # Save current cursor position before clearing
960
1033
  row_idx, col_idx = self.cursor_coordinate
961
1034
 
962
1035
  self.setup_columns()
963
- self.load_rows(stop)
1036
+ self.load_rows_range(0, self.BATCH_SIZE) # Load initial rows
964
1037
 
965
1038
  # Restore cursor position
966
1039
  if row_idx < len(self.rows) and col_idx < len(self.columns):
@@ -980,20 +1053,20 @@ class DataFrameTable(DataTable):
980
1053
  Returns:
981
1054
  dict[str, int]: Mapping of column name to width (None for auto-sizing columns).
982
1055
  """
983
- column_widths = {}
1056
+ col_widths, col_label_widths = {}, {}
984
1057
 
985
1058
  # Get available width for the table (with some padding for borders/scrollbar)
986
- available_width = self.size.width - 4 # Account for borders and scrollbar
1059
+ available_width = self.scrollable_content_region.width
987
1060
 
988
1061
  # Calculate how much width we need for string columns first
989
1062
  string_cols = [col for col, dtype in zip(self.df.columns, self.df.dtypes) if dtype == pl.String]
990
1063
 
991
1064
  # No string columns, let TextualDataTable auto-size all columns
992
1065
  if not string_cols:
993
- return column_widths
1066
+ return col_widths
994
1067
 
995
1068
  # Sample a reasonable number of rows to calculate widths (don't scan entire dataframe)
996
- sample_size = min(self.INITIAL_BATCH_SIZE, len(self.df))
1069
+ sample_size = min(self.BATCH_SIZE, len(self.df))
997
1070
  sample_lf = self.df.lazy().slice(0, sample_size)
998
1071
 
999
1072
  # Determine widths for each column
@@ -1004,37 +1077,42 @@ class DataFrameTable(DataTable):
1004
1077
  # Get column label width
1005
1078
  # Add padding for sort indicators if any
1006
1079
  label_width = measure(self.app.console, col, 1) + 2
1080
+ col_label_widths[col] = label_width
1081
+
1082
+ # Let Textual auto-size for non-string columns and already expanded columns
1083
+ if dtype != pl.String or col in self.expanded_columns:
1084
+ available_width -= label_width
1085
+ continue
1007
1086
 
1008
1087
  try:
1009
1088
  # Get sample values from the column
1010
- sample_values = sample_lf.select(col).collect().get_column(col).to_list()
1089
+ sample_values = sample_lf.select(col).collect().get_column(col).drop_nulls().to_list()
1011
1090
  if any(val.startswith(("https://", "http://")) for val in sample_values):
1012
1091
  continue # Skip link columns so they can auto-size and be clickable
1013
1092
 
1014
1093
  # Find maximum width in sample
1015
1094
  max_cell_width = max(
1016
- (measure(self.app.console, str(val), 1) for val in sample_values if val),
1095
+ (measure(self.app.console, val, 1) for val in sample_values),
1017
1096
  default=label_width,
1018
1097
  )
1019
1098
 
1020
1099
  # Set column width to max of label and sampled data (capped at reasonable max)
1021
1100
  max_width = max(label_width, max_cell_width)
1022
- except Exception:
1101
+ except Exception as e:
1023
1102
  # If any error, let Textual auto-size
1024
1103
  max_width = label_width
1104
+ self.log(f"Error determining width for column '{col}': {e}")
1025
1105
 
1026
- if dtype == pl.String:
1027
- column_widths[col] = max_width
1028
-
1106
+ col_widths[col] = max_width
1029
1107
  available_width -= max_width
1030
1108
 
1031
1109
  # If there's no more available width, auto-size remaining columns
1032
1110
  if available_width < 0:
1033
- for col in column_widths:
1034
- if column_widths[col] > STRING_WIDTH_CAP:
1035
- column_widths[col] = STRING_WIDTH_CAP # Cap string columns
1111
+ for col in col_widths:
1112
+ if col_widths[col] > STRING_WIDTH_CAP and col_label_widths[col] < STRING_WIDTH_CAP:
1113
+ col_widths[col] = STRING_WIDTH_CAP # Cap string columns
1036
1114
 
1037
- return column_widths
1115
+ return col_widths
1038
1116
 
1039
1117
  def setup_columns(self) -> None:
1040
1118
  """Clear table and setup columns.
@@ -1049,8 +1127,8 @@ class DataFrameTable(DataTable):
1049
1127
 
1050
1128
  # Add columns with justified headers
1051
1129
  for col, dtype in zip(self.df.columns, self.df.dtypes):
1052
- if col in self.hidden_columns:
1053
- continue # Skip hidden columns
1130
+ if col in self.hidden_columns or (col == RID and not self.show_rid):
1131
+ continue # Skip hidden columns and internal RID
1054
1132
  for idx, c in enumerate(self.sorted_columns, 1):
1055
1133
  if c == col:
1056
1134
  # Add sort indicator to column header
@@ -1068,178 +1146,393 @@ class DataFrameTable(DataTable):
1068
1146
 
1069
1147
  self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
1070
1148
 
1071
- def load_rows(self, stop: int | None = None, move_to_end: bool = False) -> None:
1072
- """Load a batch of rows into the table (synchronous wrapper).
1149
+ def _calculate_load_range(self, start: int, stop: int) -> list[tuple[int, int]]:
1150
+ """Calculate the actual ranges to load, accounting for already-loaded ranges.
1151
+
1152
+ Handles complex cases where a loaded range is fully contained within the requested
1153
+ range (creating head and tail segments to load). All overlapping/adjacent loaded
1154
+ ranges are merged first to minimize gaps.
1073
1155
 
1074
1156
  Args:
1075
- stop: Stop loading rows when this index is reached.
1076
- If None, load until the end of the dataframe.
1157
+ start: Requested start index (0-based).
1158
+ stop: Requested stop index (0-based, exclusive).
1159
+
1160
+ Returns:
1161
+ List of (actual_start, actual_stop) tuples to load. Empty list if the entire
1162
+ requested range is already loaded.
1163
+
1164
+ Example:
1165
+ If loaded ranges are [(150, 250)] and requesting (100, 300):
1166
+ - Returns [(100, 150), (250, 300)] to load head and tail
1167
+ If loaded ranges are [(0, 100), (100, 200)] and requesting (50, 150):
1168
+ - After merging, loaded_ranges becomes [(0, 200)]
1169
+ - Returns [] (already fully loaded)
1077
1170
  """
1078
- if stop is None or stop > len(self.df):
1079
- stop = len(self.df)
1171
+ if not self.loaded_ranges:
1172
+ return [(start, stop)]
1173
+
1174
+ # Sort loaded ranges by start index
1175
+ sorted_ranges = sorted(self.loaded_ranges)
1176
+
1177
+ # Merge overlapping/adjacent ranges
1178
+ merged = []
1179
+ for range_start, range_stop in sorted_ranges:
1180
+ # Fully covered, no need to load anything
1181
+ if range_start <= start and range_stop >= stop:
1182
+ return []
1183
+ # Overlapping or adjacent: merge
1184
+ elif merged and range_start <= merged[-1][1]:
1185
+ merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
1186
+ else:
1187
+ merged.append((range_start, range_stop))
1188
+
1189
+ self.loaded_ranges = merged
1190
+
1191
+ # Calculate ranges to load by finding gaps in the merged ranges
1192
+ ranges_to_load = []
1193
+ current_pos = start
1194
+
1195
+ for range_start, range_stop in merged:
1196
+ # If there's a gap before this loaded range, add it to load list
1197
+ if current_pos < range_start and current_pos < stop:
1198
+ gap_end = min(range_start, stop)
1199
+ ranges_to_load.append((current_pos, gap_end))
1200
+ current_pos = range_stop
1201
+ elif current_pos >= range_stop:
1202
+ # Already moved past this loaded range
1203
+ continue
1204
+ else:
1205
+ # Current position is inside this loaded range, skip past it
1206
+ current_pos = max(current_pos, range_stop)
1207
+
1208
+ # If there's remaining range after all loaded ranges, add it
1209
+ if current_pos < stop:
1210
+ ranges_to_load.append((current_pos, stop))
1080
1211
 
1081
- # If already loaded enough rows, just move cursor if needed
1082
- if stop <= self.loaded_rows:
1083
- if move_to_end:
1084
- self.move_cursor(row=self.row_count - 1)
1212
+ return ranges_to_load
1085
1213
 
1214
+ def _merge_loaded_ranges(self) -> None:
1215
+ """Merge adjacent and overlapping ranges in self.loaded_ranges.
1216
+
1217
+ Ranges like (0, 100) and (100, 200) are merged into (0, 200).
1218
+ """
1219
+ if len(self.loaded_ranges) <= 1:
1086
1220
  return
1087
1221
 
1088
- # Warn user if loading a large number of rows
1089
- elif (nrows := stop - self.loaded_rows) >= WARN_ROWS_THRESHOLD:
1222
+ # Sort by start index
1223
+ sorted_ranges = sorted(self.loaded_ranges)
1090
1224
 
1091
- def _continue(result: bool) -> None:
1092
- if result:
1093
- self.load_rows_async(stop, move_to_end=move_to_end)
1225
+ # Merge overlapping/adjacent ranges
1226
+ merged = [sorted_ranges[0]]
1227
+ for range_start, range_stop in sorted_ranges[1:]:
1228
+ # Overlapping or adjacent: merge
1229
+ if range_start <= merged[-1][1]:
1230
+ merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
1231
+ else:
1232
+ merged.append((range_start, range_stop))
1094
1233
 
1095
- self.app.push_screen(
1096
- ConfirmScreen(
1097
- f"Load {nrows} Rows",
1098
- label="Loading a large number of rows may cause the application to become unresponsive. Do you want to continue?",
1099
- ),
1100
- callback=_continue,
1101
- )
1234
+ self.loaded_ranges = merged
1102
1235
 
1103
- return
1236
+ def _find_insert_position_for_row(self, ridx: int) -> int:
1237
+ """Find the correct table position to insert a row with the given dataframe index.
1238
+
1239
+ In the table display, rows are ordered by their dataframe index, regardless of
1240
+ the internal row keys. This method finds where a row should be inserted based on
1241
+ its dataframe index and the indices of already-loaded rows.
1104
1242
 
1105
- # Load rows asynchronously
1106
- self.load_rows_async(stop, move_to_end=move_to_end)
1243
+ Args:
1244
+ ridx: The 0-based dataframe row index.
1245
+
1246
+ Returns:
1247
+ The 0-based table position where the row should be inserted.
1248
+ """
1249
+ # Count how many already-loaded rows have lower dataframe indices
1250
+ # Iterate through loaded rows instead of iterating 0..ridx for efficiency
1251
+ insert_pos = 0
1252
+ for row_key in self._row_locations:
1253
+ loaded_ridx = int(row_key.value)
1254
+ if loaded_ridx < ridx:
1255
+ insert_pos += 1
1107
1256
 
1108
- @work(exclusive=True, description="Loading rows...")
1109
- async def load_rows_async(self, stop: int, move_to_end: bool = False) -> None:
1110
- """Perform loading with async to avoid blocking.
1257
+ return insert_pos
1258
+
1259
+ def load_rows_segment(self, segment_start: int, segment_stop: int) -> int:
1260
+ """Load a single contiguous segment of rows into the table.
1261
+
1262
+ This is the core loading logic that inserts rows at correct positions,
1263
+ respecting visibility and selection states. Used by load_rows_range()
1264
+ to handle each segment independently.
1111
1265
 
1112
1266
  Args:
1113
- stop: Stop loading rows when this index is reached.
1114
- move_to_end: If True, move cursor to the last loaded row after loading completes.
1267
+ segment_start: Start loading rows from this index (0-based).
1268
+ segment_stop: Stop loading rows when this index is reached (0-based, exclusive).
1115
1269
  """
1116
- # Load rows in smaller chunks to avoid blocking
1117
- if stop > self.loaded_rows:
1118
- self.log(f"Async loading up to row {self.loaded_rows = }, {stop = }")
1119
- # Load incrementally to avoid one big block
1120
- # Load max BATCH_SIZE rows at a time
1121
- chunk_size = min(self.BATCH_SIZE, stop - self.loaded_rows)
1122
- next_stop = min(self.loaded_rows + chunk_size, stop)
1123
- self.load_rows_batch(next_stop)
1124
-
1125
- # If there's more to load, yield to event loop with delay
1126
- if next_stop < stop:
1127
- await sleep_async(0.05) # 50ms delay to allow UI updates
1128
- self.load_rows_async(stop, move_to_end=move_to_end)
1129
- return
1270
+ # Record this range before loading
1271
+ self.loaded_ranges.append((segment_start, segment_stop))
1272
+
1273
+ # Load the dataframe slice
1274
+ df_slice = self.df.slice(segment_start, segment_stop - segment_start)
1275
+
1276
+ # Load each row at the correct position
1277
+ for (ridx, row), rid in zip(enumerate(df_slice.rows(), segment_start), df_slice[RID]):
1278
+ is_selected = rid in self.selected_rows
1279
+ match_cols = self.matches.get(rid, set())
1280
+
1281
+ vals, dtypes, styles = [], [], []
1282
+ for val, col, dtype in zip(row, self.df.columns, self.df.dtypes, strict=True):
1283
+ if col in self.hidden_columns or (col == RID and not self.show_rid):
1284
+ continue # Skip hidden columns and internal RID
1285
+
1286
+ vals.append(val)
1287
+ dtypes.append(dtype)
1288
+
1289
+ # Highlight entire row with selection or cells with matches
1290
+ styles.append(HIGHLIGHT_COLOR if is_selected or col in match_cols else None)
1291
+
1292
+ formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
1293
+
1294
+ # Find correct insertion position and insert
1295
+ insert_pos = self._find_insert_position_for_row(ridx)
1296
+ self.insert_row(*formatted_row, key=str(ridx), label=str(ridx + 1), position=insert_pos)
1297
+
1298
+ # Number of rows loaded in this segment
1299
+ segment_count = len(df_slice)
1130
1300
 
1131
- # After loading completes, move cursor to end if requested
1132
- if move_to_end:
1133
- self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
1301
+ # Update loaded rows count
1302
+ self.loaded_rows += segment_count
1134
1303
 
1135
- def load_rows_batch(self, stop: int) -> None:
1304
+ return segment_count
1305
+
1306
+ def load_rows_range(self, start: int, stop: int) -> int:
1136
1307
  """Load a batch of rows into the table.
1137
1308
 
1138
1309
  Row keys are 0-based indices as strings, which map directly to dataframe row indices.
1139
1310
  Row labels are 1-based indices as strings.
1140
1311
 
1312
+ Intelligently handles range loading:
1313
+ 1. Calculates which ranges actually need loading (avoiding reloading)
1314
+ 2. Handles complex cases where loaded ranges create "holes" (head and tail segments)
1315
+ 3. Inserts rows at correct positions in the table
1316
+ 4. Merges adjacent/overlapping ranges to optimize future loading
1317
+
1141
1318
  Args:
1142
- stop: Stop loading rows when this index is reached.
1319
+ start: Start loading rows from this index (0-based).
1320
+ stop: Stop loading rows when this index is reached (0-based, exclusive).
1143
1321
  """
1322
+ start = max(0, start) # Clamp to non-negative
1323
+ stop = min(stop, len(self.df)) # Clamp to dataframe length
1324
+
1144
1325
  try:
1145
- start = self.loaded_rows
1146
- df_slice = self.df.slice(start, stop - start)
1326
+ # Calculate actual ranges to load, accounting for already-loaded ranges
1327
+ ranges_to_load = self._calculate_load_range(start, stop)
1147
1328
 
1148
- for ridx, row in enumerate(df_slice.rows(), start):
1149
- if not self.visible_rows[ridx]:
1150
- continue # Skip hidden rows
1329
+ # If nothing needs loading, return early
1330
+ if not ranges_to_load:
1331
+ return 0 # Already loaded
1151
1332
 
1152
- is_selected = self.selected_rows[ridx]
1153
- match_cols = self.matches.get(ridx, set())
1333
+ # Track the number of loaded rows in this range
1334
+ range_count = 0
1154
1335
 
1155
- vals, dtypes, styles = [], [], []
1156
- for cidx, (val, col, dtype) in enumerate(zip(row, self.df.columns, self.df.dtypes)):
1157
- if col in self.hidden_columns:
1158
- continue # Skip hidden columns
1336
+ # Load each segment
1337
+ for segment_start, segment_stop in ranges_to_load:
1338
+ range_count += self.load_rows_segment(segment_start, segment_stop)
1159
1339
 
1160
- vals.append(val)
1161
- dtypes.append(dtype)
1340
+ # Merge adjacent/overlapping ranges to optimize storage
1341
+ self._merge_loaded_ranges()
1162
1342
 
1163
- # Highlight entire row with selection or cells with matches
1164
- styles.append(HIGHLIGHT_COLOR if is_selected or cidx in match_cols else None)
1343
+ self.log(f"Loaded {range_count} rows for range {start}-{stop}/{len(self.df)}")
1344
+ return range_count
1165
1345
 
1166
- formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
1346
+ except Exception as e:
1347
+ self.notify("Error loading rows", title="Load", severity="error", timeout=10)
1348
+ self.log(f"Error loading rows: {str(e)}")
1349
+ return 0
1350
+
1351
+ def load_rows_up(self) -> None:
1352
+ """Check if we need to load more rows and load them."""
1353
+ # If we've loaded everything, no need to check
1354
+ if self.loaded_rows >= len(self.df):
1355
+ return
1167
1356
 
1168
- # Always add labels so they can be shown/hidden via CSS
1169
- self.add_row(*formatted_row, key=str(ridx), label=str(ridx + 1))
1357
+ top_row_index = int(self.scroll_y) + BUFFER_SIZE
1358
+ top_row_key = self.get_row_key(top_row_index)
1170
1359
 
1171
- # Update loaded rows count
1172
- self.loaded_rows = stop
1360
+ if top_row_key:
1361
+ top_ridx = int(top_row_key.value)
1362
+ else:
1363
+ top_ridx = 0 # No top row key at index, default to 0
1173
1364
 
1174
- # self.notify(f"Loaded [$accent]{self.loaded_rows}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
1175
- self.log(f"Loaded {self.loaded_rows}/{len(self.df)} rows from `{self.filename or self.name}`")
1365
+ # Load upward
1366
+ start, stop = self._round_to_nearest_hundreds(top_ridx - BUFFER_SIZE * 2)
1367
+ range_count = self.load_rows_range(start, stop)
1176
1368
 
1177
- except Exception as e:
1178
- self.notify("Error loading rows", title="Load", severity="error")
1179
- self.log(f"Error loading rows: {str(e)}")
1369
+ # Adjust scroll to maintain position if rows were loaded above
1370
+ if range_count > 0:
1371
+ self.move_cursor(row=top_row_index + range_count)
1372
+ self.log(f"Loaded up: {range_count} rows in range {start}-{stop}/{len(self.df)}")
1180
1373
 
1181
- def check_and_load_more(self) -> None:
1374
+ def load_rows_down(self) -> None:
1182
1375
  """Check if we need to load more rows and load them."""
1183
1376
  # If we've loaded everything, no need to check
1184
1377
  if self.loaded_rows >= len(self.df):
1185
1378
  return
1186
1379
 
1187
- visible_row_count = self.size.height - self.header_height
1188
- bottom_visible_row = self.scroll_y + visible_row_count
1380
+ visible_row_count = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
1381
+ bottom_row_index = self.scroll_y + visible_row_count - BUFFER_SIZE
1382
+
1383
+ bottom_row_key = self.get_row_key(bottom_row_index)
1384
+ if bottom_row_key:
1385
+ bottom_ridx = int(bottom_row_key.value)
1386
+ else:
1387
+ bottom_ridx = 0 # No bottom row key at index, default to 0
1388
+
1389
+ # Load downward
1390
+ start, stop = self._round_to_nearest_hundreds(bottom_ridx + BUFFER_SIZE * 2)
1391
+ range_count = self.load_rows_range(start, stop)
1189
1392
 
1190
- # If visible area is close to the end of loaded rows, load more
1191
- if bottom_visible_row >= self.loaded_rows - 10:
1192
- self.load_rows(self.loaded_rows + self.BATCH_SIZE)
1393
+ if range_count > 0:
1394
+ self.log(f"Loaded down: {range_count} rows in range {start}-{stop}/{len(self.df)}")
1193
1395
 
1194
- # Highlighting
1195
- def apply_highlight(self, force: bool = False) -> None:
1196
- """Update all rows, highlighting selected ones and restoring others to default.
1396
+ def insert_row(
1397
+ self,
1398
+ *cells: CellType,
1399
+ height: int | None = 1,
1400
+ key: str | None = None,
1401
+ label: TextType | None = None,
1402
+ position: int | None = None,
1403
+ ) -> RowKey:
1404
+ """Insert a row at a specific position in the DataTable.
1405
+
1406
+ When inserting, all rows at and after the insertion position are shifted down,
1407
+ and their entries in self._row_locations are updated accordingly.
1197
1408
 
1198
1409
  Args:
1199
- force: If True, clear all highlights and restore default styles.
1410
+ *cells: Positional arguments should contain cell data.
1411
+ height: The height of a row (in lines). Use `None` to auto-detect the optimal
1412
+ height.
1413
+ key: A key which uniquely identifies this row. If None, it will be generated
1414
+ for you and returned.
1415
+ label: The label for the row. Will be displayed to the left if supplied.
1416
+ position: The 0-based row index where the new row should be inserted.
1417
+ If None, inserts at the end (same as add_row). If out of bounds,
1418
+ inserts at the nearest valid position.
1419
+
1420
+ Returns:
1421
+ Unique identifier for this row. Can be used to retrieve this row regardless
1422
+ of its current location in the DataTable (it could have moved after
1423
+ being added due to sorting or insertion/deletion of other rows).
1424
+
1425
+ Raises:
1426
+ DuplicateKey: If a row with the given key already exists.
1427
+ ValueError: If more cells are provided than there are columns.
1200
1428
  """
1201
- # Ensure all selected rows or matches are loaded
1202
- stop = rindex(self.selected_rows, True) + 1
1203
- stop = max(stop, max(self.matches.keys(), default=0) + 1)
1204
-
1205
- self.load_rows(stop)
1206
- self.highlight_table(force)
1207
-
1208
- def highlight_table(self, force: bool = False) -> None:
1209
- """Highlight selected rows/cells in red."""
1210
- if not force and not any(self.selected_rows) and not self.matches:
1211
- return # Nothing to highlight
1212
-
1213
- # Update all rows based on selected state
1214
- for row in self.ordered_rows:
1215
- ridx = int(row.key.value) # 0-based index
1216
- is_selected = self.selected_rows[ridx]
1217
- match_cols = self.matches.get(ridx, set())
1218
-
1219
- if not force and not is_selected and not match_cols:
1220
- continue # No highlight needed for this row
1221
-
1222
- # Update all cells in this row
1223
- for col_idx, col in enumerate(self.ordered_columns):
1224
- if not force and not is_selected and col_idx not in match_cols:
1225
- continue # No highlight needed for this cell
1226
-
1227
- cell_text: Text = self.get_cell(row.key, col.key)
1228
- need_update = False
1229
-
1230
- if is_selected or col_idx in match_cols:
1231
- cell_text.style = HIGHLIGHT_COLOR
1232
- need_update = True
1233
- elif force:
1234
- # Restore original style based on dtype
1235
- dtype = self.df.schema[col.key.value]
1236
- dc = DtypeConfig(dtype)
1237
- cell_text.style = dc.style
1238
- need_update = True
1239
-
1240
- # Update the cell in the table
1241
- if need_update:
1242
- self.update_cell(row.key, col.key, cell_text)
1429
+ # Default to appending if position not specified or >= row_count
1430
+ row_count = self.row_count
1431
+ if position is None or position >= row_count:
1432
+ return self.add_row(*cells, height=height, key=key, label=label)
1433
+
1434
+ # Clamp position to valid range [0, row_count)
1435
+ position = max(0, position)
1436
+
1437
+ row_key = RowKey(key)
1438
+ if row_key in self._row_locations:
1439
+ raise DuplicateKey(f"The row key {row_key!r} already exists.")
1440
+
1441
+ if len(cells) > len(self.ordered_columns):
1442
+ raise ValueError("More values provided than there are columns.")
1443
+
1444
+ # TC: Rebuild self._row_locations to shift rows at and after position down by 1
1445
+ # Create a mapping of old index -> new index
1446
+ old_to_new = {}
1447
+ for old_idx in range(row_count):
1448
+ if old_idx < position:
1449
+ old_to_new[old_idx] = old_idx # No change
1450
+ else:
1451
+ old_to_new[old_idx] = old_idx + 1 # Shift down by 1
1452
+
1453
+ # Update _row_locations with the new indices
1454
+ new_row_locations = TwoWayDict({})
1455
+ for row_key_item in self._row_locations:
1456
+ old_idx = self.get_row_idx(row_key_item)
1457
+ new_idx = old_to_new.get(old_idx, old_idx)
1458
+ new_row_locations[row_key_item] = new_idx
1459
+
1460
+ # Update the internal mapping
1461
+ self._row_locations = new_row_locations
1462
+ # TC
1463
+
1464
+ row_index = position
1465
+ # Map the key of this row to its current index
1466
+ self._row_locations[row_key] = row_index
1467
+ self._data[row_key] = {column.key: cell for column, cell in zip_longest(self.ordered_columns, cells)}
1468
+
1469
+ label = Text.from_markup(label, end="") if isinstance(label, str) else label
1470
+
1471
+ # Rows with auto-height get a height of 0 because 1) we need an integer height
1472
+ # to do some intermediate computations and 2) because 0 doesn't impact the data
1473
+ # table while we don't figure out how tall this row is.
1474
+ self.rows[row_key] = Row(
1475
+ row_key,
1476
+ height or 0,
1477
+ label,
1478
+ height is None,
1479
+ )
1480
+ self._new_rows.add(row_key)
1481
+ self._require_update_dimensions = True
1482
+ self.cursor_coordinate = self.cursor_coordinate
1483
+
1484
+ # If a position has opened for the cursor to appear, where it previously
1485
+ # could not (e.g. when there's no data in the table), then a highlighted
1486
+ # event is posted, since there's now a highlighted cell when there wasn't
1487
+ # before.
1488
+ cell_now_available = self.row_count == 1 and len(self.columns) > 0
1489
+ visible_cursor = self.show_cursor and self.cursor_type != "none"
1490
+ if cell_now_available and visible_cursor:
1491
+ self._highlight_cursor()
1492
+
1493
+ self._update_count += 1
1494
+ self.check_idle()
1495
+ return row_key
1496
+
1497
+ # Navigation
1498
+ def do_jump_top(self) -> None:
1499
+ """Jump to the top of the table."""
1500
+ self.move_cursor(row=0)
1501
+
1502
+ def do_jump_bottom(self) -> None:
1503
+ """Jump to the bottom of the table."""
1504
+ stop = len(self.df)
1505
+ start = max(0, stop - self.BATCH_SIZE)
1506
+
1507
+ if start % self.BATCH_SIZE != 0:
1508
+ start = (start // self.BATCH_SIZE + 1) * self.BATCH_SIZE
1509
+
1510
+ if stop - start < self.BATCH_SIZE:
1511
+ start -= self.BATCH_SIZE
1512
+
1513
+ self.load_rows_range(start, stop)
1514
+ self.move_cursor(row=self.row_count - 1)
1515
+
1516
+ def do_page_up(self) -> None:
1517
+ """Move the cursor one page up."""
1518
+ self._set_hover_cursor(False)
1519
+ if self.show_cursor and self.cursor_type in ("cell", "row"):
1520
+ height = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
1521
+
1522
+ col_idx = self.cursor_column
1523
+ ridx = self.cursor_row_idx
1524
+ next_ridx = max(0, ridx - height - BUFFER_SIZE)
1525
+ start, stop = self._round_to_nearest_hundreds(next_ridx)
1526
+ self.load_rows_range(start, stop)
1527
+
1528
+ self.move_cursor(row=self.get_row_idx(str(next_ridx)), column=col_idx)
1529
+ else:
1530
+ super().action_page_up()
1531
+
1532
+ def do_page_down(self) -> None:
1533
+ """Move the cursor one page down."""
1534
+ super().action_page_down()
1535
+ self.load_rows_down()
1243
1536
 
1244
1537
  # History & Undo
1245
1538
  def create_history(self, description: str) -> None:
@@ -1247,16 +1540,15 @@ class DataFrameTable(DataTable):
1247
1540
  return History(
1248
1541
  description=description,
1249
1542
  df=self.df,
1543
+ df_view=self.df_view,
1250
1544
  filename=self.filename,
1251
- loaded_rows=self.loaded_rows,
1252
- sorted_columns=self.sorted_columns.copy(),
1253
1545
  hidden_columns=self.hidden_columns.copy(),
1254
1546
  selected_rows=self.selected_rows.copy(),
1255
- visible_rows=self.visible_rows.copy(),
1547
+ sorted_columns=self.sorted_columns.copy(),
1548
+ matches={k: v.copy() for k, v in self.matches.items()},
1256
1549
  fixed_rows=self.fixed_rows,
1257
1550
  fixed_columns=self.fixed_columns,
1258
1551
  cursor_coordinate=self.cursor_coordinate,
1259
- matches={k: v.copy() for k, v in self.matches.items()},
1260
1552
  dirty=self.dirty,
1261
1553
  )
1262
1554
 
@@ -1267,30 +1559,32 @@ class DataFrameTable(DataTable):
1267
1559
 
1268
1560
  # Restore state
1269
1561
  self.df = history.df
1562
+ self.df_view = history.df_view
1270
1563
  self.filename = history.filename
1271
- self.loaded_rows = history.loaded_rows
1272
- self.sorted_columns = history.sorted_columns.copy()
1273
1564
  self.hidden_columns = history.hidden_columns.copy()
1274
1565
  self.selected_rows = history.selected_rows.copy()
1275
- self.visible_rows = history.visible_rows.copy()
1566
+ self.sorted_columns = history.sorted_columns.copy()
1567
+ self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
1276
1568
  self.fixed_rows = history.fixed_rows
1277
1569
  self.fixed_columns = history.fixed_columns
1278
1570
  self.cursor_coordinate = history.cursor_coordinate
1279
- self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
1280
1571
  self.dirty = history.dirty
1281
1572
 
1282
1573
  # Recreate table for display
1283
1574
  self.setup_table()
1284
1575
 
1285
- def add_history(self, description: str, dirty: bool = False) -> None:
1576
+ def add_history(self, description: str, dirty: bool = False, clear_redo: bool = True) -> None:
1286
1577
  """Add the current state to the history stack.
1287
1578
 
1288
1579
  Args:
1289
1580
  description: Description of the action for this history entry.
1290
1581
  dirty: Whether this operation modifies the data (True) or just display state (False).
1291
1582
  """
1292
- history = self.create_history(description)
1293
- self.histories.append(history)
1583
+ self.histories_undo.append(self.create_history(description))
1584
+
1585
+ # Clear redo stack when a new action is performed
1586
+ if clear_redo:
1587
+ self.histories_redo.clear()
1294
1588
 
1295
1589
  # Mark table as dirty if this operation modifies data
1296
1590
  if dirty:
@@ -1298,15 +1592,13 @@ class DataFrameTable(DataTable):
1298
1592
 
1299
1593
  def do_undo(self) -> None:
1300
1594
  """Undo the last action."""
1301
- if not self.histories:
1595
+ if not self.histories_undo:
1302
1596
  self.notify("No actions to undo", title="Undo", severity="warning")
1303
1597
  return
1304
1598
 
1305
- # Pop the last history state for undo
1306
- history = self.histories.pop()
1307
-
1308
- # Save current state for redo
1309
- self.history = self.create_history(history.description)
1599
+ # Pop the last history state for undo and save to redo stack
1600
+ history = self.histories_undo.pop()
1601
+ self.histories_redo.append(self.create_history(history.description))
1310
1602
 
1311
1603
  # Restore state
1312
1604
  self.apply_history(history)
@@ -1315,35 +1607,28 @@ class DataFrameTable(DataTable):
1315
1607
 
1316
1608
  def do_redo(self) -> None:
1317
1609
  """Redo the last undone action."""
1318
- if self.history is None:
1610
+ if not self.histories_redo:
1319
1611
  self.notify("No actions to redo", title="Redo", severity="warning")
1320
1612
  return
1321
1613
 
1322
- description = self.history.description
1614
+ # Pop the last undone state from redo stack
1615
+ history = self.histories_redo.pop()
1616
+ description = history.description
1323
1617
 
1324
1618
  # Save current state for undo
1325
- self.add_history(description)
1619
+ self.add_history(description, clear_redo=False)
1326
1620
 
1327
1621
  # Restore state
1328
- self.apply_history(self.history)
1329
-
1330
- # Clear redo state
1331
- self.history = None
1622
+ self.apply_history(history)
1332
1623
 
1333
1624
  self.notify(f"Reapplied: {description}", title="Redo")
1334
1625
 
1335
1626
  def do_reset(self) -> None:
1336
1627
  """Reset the table to the initial state."""
1337
- self.setup_table(reset=True)
1628
+ self.reset_df(self.dataframe, dirty=False)
1629
+ self.setup_table()
1338
1630
  self.notify("Restored initial state", title="Reset")
1339
1631
 
1340
- def restore_dirty(self, default: bool | None = None) -> None:
1341
- """Restore the dirty state from the last history entry."""
1342
- if self.last_history:
1343
- self.dirty = self.last_history.dirty
1344
- elif default is not None:
1345
- self.dirty = default
1346
-
1347
1632
  # Display
1348
1633
  def do_cycle_cursor_type(self) -> None:
1349
1634
  """Cycle through cursor types: cell -> row -> column -> cell."""
@@ -1380,6 +1665,14 @@ class DataFrameTable(DataTable):
1380
1665
  cidx = self.cursor_col_idx
1381
1666
  self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
1382
1667
 
1668
+ def do_metadata_shape(self) -> None:
1669
+ """Show metadata about the dataframe (row and column counts)."""
1670
+ self.app.push_screen(MetaShape(self))
1671
+
1672
+ def do_metadata_column(self) -> None:
1673
+ """Show metadata for all columns in the dataframe."""
1674
+ self.app.push_screen(MetaColumnScreen(self))
1675
+
1383
1676
  def do_freeze_row_column(self) -> None:
1384
1677
  """Open the freeze screen to set fixed rows and columns."""
1385
1678
  self.app.push_screen(FreezeScreen(), callback=self.freeze_row_column)
@@ -1396,7 +1689,7 @@ class DataFrameTable(DataTable):
1396
1689
  fixed_rows, fixed_columns = result
1397
1690
 
1398
1691
  # Add to history
1399
- self.add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
1692
+ self.add_history(f"Pinned [$success]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns")
1400
1693
 
1401
1694
  # Apply the pin settings to the table
1402
1695
  if fixed_rows >= 0:
@@ -1404,7 +1697,7 @@ class DataFrameTable(DataTable):
1404
1697
  if fixed_columns >= 0:
1405
1698
  self.fixed_columns = fixed_columns
1406
1699
 
1407
- # self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
1700
+ # self.notify(f"Pinned [$success]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns", title="Pin")
1408
1701
 
1409
1702
  def do_hide_column(self) -> None:
1410
1703
  """Hide the currently selected column from the table display."""
@@ -1425,7 +1718,7 @@ class DataFrameTable(DataTable):
1425
1718
  if col_idx >= len(self.columns):
1426
1719
  self.move_cursor(column=len(self.columns) - 1)
1427
1720
 
1428
- # self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
1721
+ # self.notify(f"Hid column [$success]{col_name}[/]. Press [$accent]H[/] to show hidden columns", title="Hide")
1429
1722
 
1430
1723
  def do_expand_column(self) -> None:
1431
1724
  """Expand the current column to show the widest cell in the loaded data."""
@@ -1438,58 +1731,78 @@ class DataFrameTable(DataTable):
1438
1731
  if dtype != pl.String:
1439
1732
  return
1440
1733
 
1734
+ # The column to expand/shrink
1735
+ col: Column = self.columns[col_key]
1736
+
1441
1737
  # Calculate the maximum width across all loaded rows
1442
- max_width = len(col_name) + 2 # Start with column name width + padding
1738
+ label_width = len(col_name) + 2 # Start with column name width + padding
1443
1739
 
1444
1740
  try:
1741
+ need_expand = False
1742
+ max_width = label_width
1743
+
1445
1744
  # Scan through all loaded rows that are visible to find max width
1446
1745
  for row_idx in range(self.loaded_rows):
1447
- if not self.visible_rows[row_idx]:
1448
- continue # Skip hidden rows
1449
1746
  cell_value = str(self.df.item(row_idx, col_idx))
1450
1747
  cell_width = measure(self.app.console, cell_value, 1)
1748
+
1749
+ if cell_width > max_width:
1750
+ need_expand = True
1451
1751
  max_width = max(max_width, cell_width)
1452
1752
 
1453
- # Update the column width
1454
- col = self.columns[col_key]
1455
- col.width = max_width
1753
+ if not need_expand:
1754
+ return
1755
+
1756
+ if col_name in self.expanded_columns:
1757
+ col.width = max(label_width, STRING_WIDTH_CAP)
1758
+ self.expanded_columns.remove(col_name)
1759
+ else:
1760
+ self.expanded_columns.add(col_name)
1456
1761
 
1457
- # Force a refresh
1458
- self._update_count += 1
1459
- self._require_update_dimensions = True
1460
- self.refresh(layout=True)
1762
+ # Update the column width
1763
+ col.width = max_width
1461
1764
 
1462
- # self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
1463
1765
  except Exception as e:
1464
- self.notify("Error expanding column", title="Expand", severity="error")
1766
+ self.notify(
1767
+ f"Error expanding column [$error]{col_name}[/]", title="Expand Column", severity="error", timeout=10
1768
+ )
1465
1769
  self.log(f"Error expanding column `{col_name}`: {str(e)}")
1466
1770
 
1467
- def do_show_hidden_rows_columns(self) -> None:
1468
- """Show all hidden rows/columns by recreating the table."""
1469
- # Get currently visible columns
1470
- visible_cols = set(col.key for col in self.ordered_columns)
1771
+ # Force a refresh
1772
+ self._update_count += 1
1773
+ self._require_update_dimensions = True
1774
+ self.refresh(layout=True)
1471
1775
 
1472
- hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
1473
- hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
1776
+ # self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
1474
1777
 
1475
- if not hidden_row_count and not hidden_col_count:
1476
- self.notify("No hidden columns or rows to show", title="Show", severity="warning")
1778
+ def do_toggle_rid(self) -> None:
1779
+ """Toggle display of the internal RID column."""
1780
+ self.show_rid = not self.show_rid
1781
+
1782
+ # Recreate table for display
1783
+ self.setup_table()
1784
+
1785
+ def do_show_hidden_rows_columns(self) -> None:
1786
+ """Show all hidden rows/columns by recreating the table."""
1787
+ if not self.hidden_columns and self.df_view is None:
1788
+ self.notify("No hidden rows or columns to show", title="Show", severity="warning")
1477
1789
  return
1478
1790
 
1479
1791
  # Add to history
1480
1792
  self.add_history("Showed hidden rows/columns")
1481
1793
 
1794
+ # If in a filtered view, restore the full dataframe
1795
+ if self.df_view is not None:
1796
+ self.df = self.df_view
1797
+ self.df_view = None
1798
+
1482
1799
  # Clear hidden rows/columns tracking
1483
- self.visible_rows = [True] * len(self.df)
1484
1800
  self.hidden_columns.clear()
1485
1801
 
1486
1802
  # Recreate table for display
1487
1803
  self.setup_table()
1488
1804
 
1489
- self.notify(
1490
- f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
1491
- title="Show",
1492
- )
1805
+ self.notify("Showed hidden row(s) and/or hidden column(s)", title="Show")
1493
1806
 
1494
1807
  # Sort
1495
1808
  def do_sort_by_column(self, descending: bool = False) -> None:
@@ -1510,32 +1823,40 @@ class DataFrameTable(DataTable):
1510
1823
 
1511
1824
  # Add to history
1512
1825
  self.add_history(f"Sorted on column [$success]{col_name}[/]", dirty=True)
1826
+
1827
+ # New column - add to sort
1513
1828
  if old_desc is None:
1514
- # Add new column to sort
1515
1829
  self.sorted_columns[col_name] = descending
1830
+
1831
+ # Old column, same direction - remove from sort
1516
1832
  elif old_desc == descending:
1517
- # Same direction - remove from sort
1518
1833
  del self.sorted_columns[col_name]
1834
+
1835
+ # Old column, different direction - add to sort at end
1519
1836
  else:
1520
- # Move to end of sort order
1521
1837
  del self.sorted_columns[col_name]
1522
1838
  self.sorted_columns[col_name] = descending
1523
1839
 
1840
+ lf = self.df.lazy()
1841
+ sort_by = {}
1842
+
1524
1843
  # Apply multi-column sort
1525
1844
  if sort_cols := list(self.sorted_columns.keys()):
1526
1845
  descending_flags = list(self.sorted_columns.values())
1527
- df_sorted = self.df.with_row_index(RIDX).sort(sort_cols, descending=descending_flags, nulls_last=True)
1846
+ sort_by = {"by": sort_cols, "descending": descending_flags, "nulls_last": True}
1528
1847
  else:
1529
- # No sort columns - restore original order
1530
- df_sorted = self.df.with_row_index(RIDX)
1848
+ # No sort - restore original order by adding a temporary index column
1849
+ sort_by = {"by": RID}
1531
1850
 
1532
- # Updated selected_rows and visible_rows to match new order
1533
- old_row_indices = df_sorted[RIDX].to_list()
1534
- self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
1535
- self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
1851
+ # Perform the sort
1852
+ df_sorted = lf.sort(**sort_by).collect()
1853
+
1854
+ # Also update df_view if applicable
1855
+ if self.df_view is not None:
1856
+ self.df_view = self.df_view.lazy().sort(**sort_by).collect()
1536
1857
 
1537
1858
  # Update the dataframe
1538
- self.df = df_sorted.drop(RIDX)
1859
+ self.df = df_sorted
1539
1860
 
1540
1861
  # Recreate table for display
1541
1862
  self.setup_table()
@@ -1582,6 +1903,17 @@ class DataFrameTable(DataTable):
1582
1903
  .alias(col_name)
1583
1904
  )
1584
1905
 
1906
+ # Also update the view if applicable
1907
+ if self.df_view is not None:
1908
+ # Get the RID value for this row in df_view
1909
+ ridx_view = self.df.item(ridx, self.df.columns.index(RID))
1910
+ self.df_view = self.df_view.with_columns(
1911
+ pl.when(pl.col(RID) == ridx_view)
1912
+ .then(pl.lit(new_value))
1913
+ .otherwise(pl.col(col_name))
1914
+ .alias(col_name)
1915
+ )
1916
+
1585
1917
  # Update the display
1586
1918
  cell_value = self.df.item(ridx, cidx)
1587
1919
  if cell_value is None:
@@ -1595,10 +1927,15 @@ class DataFrameTable(DataTable):
1595
1927
  col_key = col_name
1596
1928
  self.update_cell(row_key, col_key, formatted_value, update_width=True)
1597
1929
 
1598
- # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1930
+ # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit Cell")
1599
1931
  except Exception as e:
1600
- self.notify("Error updating cell", title="Edit", severity="error")
1601
- self.log(f"Error updating cell: {str(e)}")
1932
+ self.notify(
1933
+ f"Error updating cell ([$error]{ridx}[/], [$accent]{col_name}[/])",
1934
+ title="Edit Cell",
1935
+ severity="error",
1936
+ timeout=10,
1937
+ )
1938
+ self.log(f"Error updating cell ({ridx}, {col_name}): {str(e)}")
1602
1939
 
1603
1940
  def do_edit_column(self) -> None:
1604
1941
  """Open modal to edit the entire column with an expression."""
@@ -1627,7 +1964,9 @@ class DataFrameTable(DataTable):
1627
1964
  try:
1628
1965
  expr = validate_expr(term, self.df.columns, cidx)
1629
1966
  except Exception as e:
1630
- self.notify(f"Error validating expression [$error]{term}[/]", title="Edit", severity="error")
1967
+ self.notify(
1968
+ f"Error validating expression [$error]{term}[/]", title="Edit Column", severity="error", timeout=10
1969
+ )
1631
1970
  self.log(f"Error validating expression `{term}`: {str(e)}")
1632
1971
  return
1633
1972
 
@@ -1639,23 +1978,47 @@ class DataFrameTable(DataTable):
1639
1978
  expr = pl.lit(value)
1640
1979
  except Exception:
1641
1980
  self.notify(
1642
- f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1981
+ f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
1643
1982
  title="Edit",
1644
1983
  severity="error",
1645
1984
  )
1646
1985
  expr = pl.lit(str(term))
1647
1986
 
1648
1987
  # Add to history
1649
- self.add_history(f"Edited column [$accent]{col_name}[/] with expression", dirty=True)
1988
+ self.add_history(f"Edited column [$success]{col_name}[/] with expression", dirty=True)
1650
1989
 
1651
1990
  try:
1652
1991
  # Apply the expression to the column
1653
- self.df = self.df.with_columns(expr.alias(col_name))
1992
+ self.df = self.df.lazy().with_columns(expr.alias(col_name)).collect()
1993
+
1994
+ # Also update the view if applicable
1995
+ # Update the value of col_name in df_view using the value of col_name from df based on RID mapping between them
1996
+ if self.df_view is not None:
1997
+ # Get updated column from df for rows that exist in df_view
1998
+ col_updated = f"^_{col_name}_^"
1999
+ col_exists = "^_exists_^"
2000
+ lf_updated = self.df.lazy().select(
2001
+ RID, pl.col(col_name).alias(col_updated), pl.lit(True).alias(col_exists)
2002
+ )
2003
+ # Join and use when/then/otherwise to handle all updates including NULLs
2004
+ self.df_view = (
2005
+ self.df_view.lazy()
2006
+ .join(lf_updated, on=RID, how="left")
2007
+ .with_columns(
2008
+ pl.when(pl.col(col_exists))
2009
+ .then(pl.col(col_updated))
2010
+ .otherwise(pl.col(col_name))
2011
+ .alias(col_name)
2012
+ )
2013
+ .drop(col_updated, col_exists)
2014
+ .collect()
2015
+ )
1654
2016
  except Exception as e:
1655
2017
  self.notify(
1656
2018
  f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
1657
- title="Edit",
2019
+ title="Edit Column",
1658
2020
  severity="error",
2021
+ timeout=10,
1659
2022
  )
1660
2023
  self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
1661
2024
  return
@@ -1663,12 +2026,12 @@ class DataFrameTable(DataTable):
1663
2026
  # Recreate table for display
1664
2027
  self.setup_table()
1665
2028
 
1666
- # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
2029
+ # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit Column")
1667
2030
 
1668
- def do_rename_column(self) -> None:
2031
+ def do_rename_column(self, col_idx: int | None) -> None:
1669
2032
  """Open modal to rename the selected column."""
1670
- col_name = self.cursor_col_name
1671
- col_idx = self.cursor_column
2033
+ col_idx = self.cursor_column if col_idx is None else col_idx
2034
+ col_name = self.get_col_key(col_idx).value
1672
2035
 
1673
2036
  # Push the rename column modal screen
1674
2037
  self.app.push_screen(
@@ -1690,19 +2053,30 @@ class DataFrameTable(DataTable):
1690
2053
  return
1691
2054
 
1692
2055
  # Add to history
1693
- self.add_history(f"Renamed column [$accent]{col_name}[/] to [$success]{new_name}[/]", dirty=True)
2056
+ self.add_history(f"Renamed column [$success]{col_name}[/] to [$accent]{new_name}[/]", dirty=True)
1694
2057
 
1695
2058
  # Rename the column in the dataframe
1696
2059
  self.df = self.df.rename({col_name: new_name})
1697
2060
 
1698
- # Update sorted_columns if this column was sorted
2061
+ # Also update the view if applicable
2062
+ if self.df_view is not None:
2063
+ self.df_view = self.df_view.rename({col_name: new_name})
2064
+
2065
+ # Update sorted_columns if this column was sorted and maintain order
1699
2066
  if col_name in self.sorted_columns:
1700
- self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
2067
+ sorted_columns = {}
2068
+ for col, order in self.sorted_columns.items():
2069
+ if col == col_name:
2070
+ sorted_columns[new_name] = order
2071
+ else:
2072
+ sorted_columns[col] = order
2073
+ self.sorted_columns = sorted_columns
1701
2074
 
1702
- # Update hidden_columns if this column was hidden
1703
- if col_name in self.hidden_columns:
1704
- self.hidden_columns.remove(col_name)
1705
- self.hidden_columns.add(new_name)
2075
+ # Update matches if this column had cell matches
2076
+ for cols in self.matches.values():
2077
+ if col_name in cols:
2078
+ cols.remove(col_name)
2079
+ cols.add(new_name)
1706
2080
 
1707
2081
  # Recreate table for display
1708
2082
  self.setup_table()
@@ -1731,6 +2105,13 @@ class DataFrameTable(DataTable):
1731
2105
  .alias(col_name)
1732
2106
  )
1733
2107
 
2108
+ # Also update the view if applicable
2109
+ if self.df_view is not None:
2110
+ ridx_view = self.df.item(ridx, self.df.columns.index(RID))
2111
+ self.df_view = self.df_view.with_columns(
2112
+ pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2113
+ )
2114
+
1734
2115
  # Update the display
1735
2116
  dtype = self.df.dtypes[cidx]
1736
2117
  dc = DtypeConfig(dtype)
@@ -1738,36 +2119,38 @@ class DataFrameTable(DataTable):
1738
2119
 
1739
2120
  self.update_cell(row_key, col_key, formatted_value)
1740
2121
 
1741
- # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
2122
+ # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear Cell")
1742
2123
  except Exception as e:
1743
- self.notify("Error clearing cell", title="Clear", severity="error")
1744
- self.log(f"Error clearing cell: {str(e)}")
2124
+ self.notify(
2125
+ f"Error clearing cell ([$error]{ridx}[/], [$accent]{col_name}[/])",
2126
+ title="Clear Cell",
2127
+ severity="error",
2128
+ timeout=10,
2129
+ )
2130
+ self.log(f"Error clearing cell ({ridx}, {col_name}): {str(e)}")
1745
2131
  raise e
1746
2132
 
1747
- def do_add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
2133
+ def do_add_column(self, col_name: str = None) -> None:
1748
2134
  """Add acolumn after the current column."""
1749
2135
  cidx = self.cursor_col_idx
1750
2136
 
1751
2137
  if not col_name:
1752
2138
  # Generate a unique column name
1753
2139
  base_name = "new_col"
1754
- new_name = base_name
2140
+ new_col_name = base_name
1755
2141
  counter = 1
1756
- while new_name in self.df.columns:
1757
- new_name = f"{base_name}_{counter}"
2142
+ while new_col_name in self.df.columns:
2143
+ new_col_name = f"{base_name}_{counter}"
1758
2144
  counter += 1
1759
2145
  else:
1760
- new_name = col_name
2146
+ new_col_name = col_name
1761
2147
 
1762
2148
  # Add to history
1763
- self.add_history(f"Added column [$success]{new_name}[/] after column {cidx + 1}", dirty=True)
2149
+ self.add_history(f"Added column [$success]{new_col_name}[/] after column [$accent]{cidx + 1}[/]", dirty=True)
1764
2150
 
1765
2151
  try:
1766
2152
  # Create an empty column (all None values)
1767
- if isinstance(col_value, pl.Expr):
1768
- new_col = col_value.alias(new_name)
1769
- else:
1770
- new_col = pl.lit(col_value).alias(new_name)
2153
+ new_col_name = pl.lit(None).alias(new_col_name)
1771
2154
 
1772
2155
  # Get columns up to current, the new column, then remaining columns
1773
2156
  cols = self.df.columns
@@ -1775,8 +2158,12 @@ class DataFrameTable(DataTable):
1775
2158
  cols_after = cols[cidx + 1 :]
1776
2159
 
1777
2160
  # Build the new dataframe with columns reordered
1778
- select_cols = cols_before + [new_name] + cols_after
1779
- self.df = self.df.with_columns(new_col).select(select_cols)
2161
+ select_cols = cols_before + [new_col_name] + cols_after
2162
+ self.df = self.df.lazy().with_columns(new_col_name).select(select_cols).collect()
2163
+
2164
+ # Also update the view if applicable
2165
+ if self.df_view is not None:
2166
+ self.df_view = self.df_view.lazy().with_columns(new_col_name).select(select_cols).collect()
1780
2167
 
1781
2168
  # Recreate table for display
1782
2169
  self.setup_table()
@@ -1786,8 +2173,10 @@ class DataFrameTable(DataTable):
1786
2173
 
1787
2174
  # self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1788
2175
  except Exception as e:
1789
- self.notify("Error adding column", title="Add Column", severity="error")
1790
- self.log(f"Error adding column: {str(e)}")
2176
+ self.notify(
2177
+ f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
2178
+ )
2179
+ self.log(f"Error adding column `{new_col_name}`: {str(e)}")
1791
2180
  raise e
1792
2181
 
1793
2182
  def do_add_column_expr(self) -> None:
@@ -1806,7 +2195,7 @@ class DataFrameTable(DataTable):
1806
2195
  cidx, new_col_name, expr = result
1807
2196
 
1808
2197
  # Add to history
1809
- self.add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.", dirty=True)
2198
+ self.add_history(f"Added column [$success]{new_col_name}[/] with expression [$accent]{expr}[/].", dirty=True)
1810
2199
 
1811
2200
  try:
1812
2201
  # Create the column
@@ -1819,7 +2208,14 @@ class DataFrameTable(DataTable):
1819
2208
 
1820
2209
  # Build the new dataframe with columns reordered
1821
2210
  select_cols = cols_before + [new_col_name] + cols_after
1822
- self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
2211
+ self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
2212
+
2213
+ # Also update the view if applicable
2214
+ if self.df_view is not None:
2215
+ # Get updated column from df for rows that exist in df_view
2216
+ lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
2217
+ # Join and use coalesce to prefer updated value or keep original
2218
+ self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
1823
2219
 
1824
2220
  # Recreate table for display
1825
2221
  self.setup_table()
@@ -1829,7 +2225,9 @@ class DataFrameTable(DataTable):
1829
2225
 
1830
2226
  # self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
1831
2227
  except Exception as e:
1832
- self.notify("Error adding column", title="Add Column", severity="error")
2228
+ self.notify(
2229
+ f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
2230
+ )
1833
2231
  self.log(f"Error adding column `{new_col_name}`: {str(e)}")
1834
2232
 
1835
2233
  def do_add_link_column(self) -> None:
@@ -1841,10 +2239,10 @@ class DataFrameTable(DataTable):
1841
2239
  def add_link_column(self, result: tuple[str, str] | None) -> None:
1842
2240
  """Handle result from AddLinkScreen.
1843
2241
 
1844
- Creates a new link column in the dataframe with clickable links based on a
1845
- user-provided template. Supports multiple placeholder types:
2242
+ Creates a new link column in the dataframe based on a user-provided template.
2243
+ Supports multiple placeholder types:
1846
2244
  - `$_` - Current column (based on cursor position)
1847
- - `$1`, `$2`, etc. - Column by 1-based position index
2245
+ - `$1`, `$2`, etc. - Column by index (1-based)
1848
2246
  - `$name` - Column by name (e.g., `$id`, `$product_name`)
1849
2247
 
1850
2248
  The template is evaluated for each row using Polars expressions with vectorized
@@ -1858,7 +2256,7 @@ class DataFrameTable(DataTable):
1858
2256
  cidx, new_col_name, link_template = result
1859
2257
 
1860
2258
  self.add_history(
1861
- f"Added link column [$accent]{new_col_name}[/] with template [$success]{link_template}[/].", dirty=True
2259
+ f"Added link column [$success]{new_col_name}[/] with template [$accent]{link_template}[/].", dirty=True
1862
2260
  )
1863
2261
 
1864
2262
  try:
@@ -1883,7 +2281,14 @@ class DataFrameTable(DataTable):
1883
2281
 
1884
2282
  # Build the new dataframe with columns reordered
1885
2283
  select_cols = cols_before + [new_col_name] + cols_after
1886
- self.df = self.df.with_columns(new_col).select(select_cols)
2284
+ self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
2285
+
2286
+ # Also update the view if applicable
2287
+ if self.df_view is not None:
2288
+ # Get updated column from df for rows that exist in df_view
2289
+ lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
2290
+ # Join and use coalesce to prefer updated value or keep original
2291
+ self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
1887
2292
 
1888
2293
  # Recreate table for display
1889
2294
  self.setup_table()
@@ -1894,14 +2299,21 @@ class DataFrameTable(DataTable):
1894
2299
  self.notify(f"Added link column [$success]{new_col_name}[/]. Use Ctrl/Cmd click to open.", title="Add Link")
1895
2300
 
1896
2301
  except Exception as e:
1897
- self.notify(f"Error adding link column [$error]{new_col_name}[/]", title="Add Link", severity="error")
2302
+ self.notify(
2303
+ f"Error adding link column [$error]{new_col_name}[/]", title="Add Link", severity="error", timeout=10
2304
+ )
1898
2305
  self.log(f"Error adding link column: {str(e)}")
1899
2306
 
1900
2307
  def do_delete_column(self, more: str = None) -> None:
1901
2308
  """Remove the currently selected column from the table."""
1902
2309
  # Get the column to remove
1903
2310
  col_idx = self.cursor_column
1904
- col_name = self.cursor_col_name
2311
+ try:
2312
+ col_name = self.cursor_col_name
2313
+ except CellDoesNotExist:
2314
+ self.notify("No column to delete at the current cursor position", title="Delete Column", severity="warning")
2315
+ return
2316
+
1905
2317
  col_key = self.cursor_col_key
1906
2318
 
1907
2319
  col_names_to_remove = []
@@ -1910,7 +2322,7 @@ class DataFrameTable(DataTable):
1910
2322
  # Remove all columns before the current column
1911
2323
  if more == "before":
1912
2324
  for i in range(col_idx + 1):
1913
- col_key = self.get_column_key(i)
2325
+ col_key = self.get_col_key(i)
1914
2326
  col_names_to_remove.append(col_key.value)
1915
2327
  col_keys_to_remove.append(col_key)
1916
2328
 
@@ -1919,7 +2331,7 @@ class DataFrameTable(DataTable):
1919
2331
  # Remove all columns after the current column
1920
2332
  elif more == "after":
1921
2333
  for i in range(col_idx, len(self.columns)):
1922
- col_key = self.get_column_key(i)
2334
+ col_key = self.get_col_key(i)
1923
2335
  col_names_to_remove.append(col_key.value)
1924
2336
  col_keys_to_remove.append(col_key)
1925
2337
 
@@ -1948,18 +2360,25 @@ class DataFrameTable(DataTable):
1948
2360
  if col_name in self.sorted_columns:
1949
2361
  del self.sorted_columns[col_name]
1950
2362
 
2363
+ # Remove from hidden columns if present
2364
+ for col_name in col_names_to_remove:
2365
+ self.hidden_columns.discard(col_name)
2366
+
1951
2367
  # Remove from matches
1952
- col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
1953
- for row_idx in list(self.matches.keys()):
1954
- self.matches[row_idx].difference_update(col_indices_to_remove)
2368
+ for rid in list(self.matches.keys()):
2369
+ self.matches[rid].difference_update(col_names_to_remove)
1955
2370
  # Remove empty entries
1956
- if not self.matches[row_idx]:
1957
- del self.matches[row_idx]
2371
+ if not self.matches[rid]:
2372
+ del self.matches[rid]
1958
2373
 
1959
2374
  # Remove from dataframe
1960
2375
  self.df = self.df.drop(col_names_to_remove)
1961
2376
 
1962
- self.notify(message, title="Delete")
2377
+ # Also update the view if applicable
2378
+ if self.df_view is not None:
2379
+ self.df_view = self.df_view.drop(col_names_to_remove)
2380
+
2381
+ self.notify(message, title="Delete Column")
1963
2382
 
1964
2383
  def do_duplicate_column(self) -> None:
1965
2384
  """Duplicate the currently selected column, inserting it right after the current column."""
@@ -1969,29 +2388,28 @@ class DataFrameTable(DataTable):
1969
2388
  col_idx = self.cursor_column
1970
2389
  new_col_name = f"{col_name}_copy"
1971
2390
 
2391
+ # Ensure new column name is unique
2392
+ counter = 1
2393
+ while new_col_name in self.df.columns:
2394
+ new_col_name = f"{new_col_name}{counter}"
2395
+ counter += 1
2396
+
1972
2397
  # Add to history
1973
2398
  self.add_history(f"Duplicated column [$success]{col_name}[/]", dirty=True)
1974
2399
 
1975
2400
  # Create new column and reorder columns to insert after current column
1976
2401
  cols_before = self.df.columns[: cidx + 1]
1977
2402
  cols_after = self.df.columns[cidx + 1 :]
2403
+ cols_new = cols_before + [new_col_name] + cols_after
1978
2404
 
1979
2405
  # Add the new column and reorder columns for insertion after current column
1980
- self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
1981
- list(cols_before) + [new_col_name] + list(cols_after)
1982
- )
2406
+ self.df = self.df.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
1983
2407
 
1984
- # Update matches to account for new column
1985
- new_matches = defaultdict(set)
1986
- for row_idx, cols in self.matches.items():
1987
- new_cols = set()
1988
- for col_idx_in_set in cols:
1989
- if col_idx_in_set <= cidx:
1990
- new_cols.add(col_idx_in_set)
1991
- else:
1992
- new_cols.add(col_idx_in_set + 1)
1993
- new_matches[row_idx] = new_cols
1994
- self.matches = new_matches
2408
+ # Also update the view if applicable
2409
+ if self.df_view is not None:
2410
+ self.df_view = (
2411
+ self.df_view.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
2412
+ )
1995
2413
 
1996
2414
  # Recreate table for display
1997
2415
  self.setup_table()
@@ -1999,7 +2417,7 @@ class DataFrameTable(DataTable):
1999
2417
  # Move cursor to the new duplicated column
2000
2418
  self.move_cursor(column=col_idx + 1)
2001
2419
 
2002
- # self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
2420
+ # self.notify(f"Duplicated column [$success]{col_name}[/] as [$accent]{new_col_name}[/]", title="Duplicate")
2003
2421
 
2004
2422
  def do_delete_row(self, more: str = None) -> None:
2005
2423
  """Delete rows from the table and dataframe.
@@ -2007,97 +2425,95 @@ class DataFrameTable(DataTable):
2007
2425
  Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
2008
2426
  """
2009
2427
  old_count = len(self.df)
2010
- predicates = [True] * len(self.df)
2428
+ rids_to_delete = set()
2011
2429
 
2012
2430
  # Delete all selected rows
2013
- if selected_count := self.selected_rows.count(True):
2431
+ if selected_count := len(self.selected_rows):
2014
2432
  history_desc = f"Deleted {selected_count} selected row(s)"
2015
-
2016
- for ridx, selected in enumerate(self.selected_rows):
2017
- if selected:
2018
- predicates[ridx] = False
2433
+ rids_to_delete.update(self.selected_rows)
2019
2434
 
2020
2435
  # Delete current row and those above
2021
2436
  elif more == "above":
2022
2437
  ridx = self.cursor_row_idx
2023
2438
  history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
2024
- for i in range(ridx + 1):
2025
- predicates[i] = False
2439
+ for rid in self.df[RID][: ridx + 1]:
2440
+ rids_to_delete.add(rid)
2026
2441
 
2027
2442
  # Delete current row and those below
2028
2443
  elif more == "below":
2029
2444
  ridx = self.cursor_row_idx
2030
2445
  history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
2031
- for i in range(ridx, len(self.df)):
2032
- if self.visible_rows[i]:
2033
- predicates[i] = False
2446
+ for rid in self.df[RID][ridx:]:
2447
+ rids_to_delete.add(rid)
2034
2448
 
2035
2449
  # Delete the row at the cursor
2036
2450
  else:
2037
2451
  ridx = self.cursor_row_idx
2038
2452
  history_desc = f"Deleted row [$success]{ridx + 1}[/]"
2039
- if self.visible_rows[ridx]:
2040
- predicates[ridx] = False
2453
+ rids_to_delete.add(self.df[RID][ridx])
2041
2454
 
2042
2455
  # Add to history
2043
2456
  self.add_history(history_desc, dirty=True)
2044
2457
 
2045
2458
  # Apply the filter to remove rows
2046
2459
  try:
2047
- df = self.df.with_row_index(RIDX).filter(predicates)
2460
+ df_filtered = self.df.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
2048
2461
  except Exception as e:
2049
- self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error")
2050
- self.histories.pop() # Remove last history entry
2462
+ self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error", timeout=10)
2463
+ self.histories_undo.pop() # Remove last history entry
2051
2464
  return
2052
2465
 
2053
- self.df = df.drop(RIDX)
2466
+ # RIDs of remaining rows
2467
+ ok_rids = set(df_filtered[RID])
2054
2468
 
2055
- # Update selected and visible rows tracking
2056
- old_row_indices = set(df[RIDX].to_list())
2057
- self.selected_rows = [selected for i, selected in enumerate(self.selected_rows) if i in old_row_indices]
2058
- self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
2469
+ # Update selected rows tracking
2470
+ if self.selected_rows:
2471
+ self.selected_rows.intersection_update(ok_rids)
2059
2472
 
2060
- # Clear all matches since row indices have changed
2061
- self.matches = defaultdict(set)
2473
+ # Update the dataframe
2474
+ self.df = df_filtered
2475
+
2476
+ # Update matches since row indices have changed
2477
+ if self.matches:
2478
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
2479
+
2480
+ # Also update the view if applicable
2481
+ if self.df_view is not None:
2482
+ self.df_view = self.df_view.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
2062
2483
 
2063
2484
  # Recreate table for display
2064
2485
  self.setup_table()
2065
2486
 
2066
2487
  deleted_count = old_count - len(self.df)
2067
2488
  if deleted_count > 0:
2068
- self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
2489
+ self.notify(f"Deleted [$success]{deleted_count}[/] row(s)", title="Delete")
2069
2490
 
2070
2491
  def do_duplicate_row(self) -> None:
2071
2492
  """Duplicate the currently selected row, inserting it right after the current row."""
2072
2493
  ridx = self.cursor_row_idx
2494
+ rid = self.df[RID][ridx]
2495
+
2496
+ lf = self.df.lazy()
2073
2497
 
2074
2498
  # Get the row to duplicate
2075
- row_to_duplicate = self.df.slice(ridx, 1)
2499
+ row_to_duplicate = lf.slice(ridx, 1).with_columns(pl.col(RID) + 1)
2076
2500
 
2077
2501
  # Add to history
2078
2502
  self.add_history(f"Duplicated row [$success]{ridx + 1}[/]", dirty=True)
2079
2503
 
2080
2504
  # Concatenate: rows before + duplicated row + rows after
2081
- df_before = self.df.slice(0, ridx + 1)
2082
- df_after = self.df.slice(ridx + 1)
2505
+ lf_before = lf.slice(0, ridx + 1)
2506
+ lf_after = lf.slice(ridx + 1).with_columns(pl.col(RID) + 1)
2083
2507
 
2084
2508
  # Combine the parts
2085
- self.df = pl.concat([df_before, row_to_duplicate, df_after])
2086
-
2087
- # Update selected and visible rows tracking to account for new row
2088
- new_selected_rows = self.selected_rows[: ridx + 1] + [self.selected_rows[ridx]] + self.selected_rows[ridx + 1 :]
2089
- new_visible_rows = self.visible_rows[: ridx + 1] + [self.visible_rows[ridx]] + self.visible_rows[ridx + 1 :]
2090
- self.selected_rows = new_selected_rows
2091
- self.visible_rows = new_visible_rows
2092
-
2093
- # Update matches to account for new row
2094
- new_matches = defaultdict(set)
2095
- for row_idx, cols in self.matches.items():
2096
- if row_idx <= ridx:
2097
- new_matches[row_idx] = cols
2098
- else:
2099
- new_matches[row_idx + 1] = cols
2100
- self.matches = new_matches
2509
+ self.df = pl.concat([lf_before, row_to_duplicate, lf_after]).collect()
2510
+
2511
+ # Also update the view if applicable
2512
+ if self.df_view is not None:
2513
+ lf_view = self.df_view.lazy()
2514
+ lf_view_before = lf_view.slice(0, rid + 1)
2515
+ lf_view_after = lf_view.slice(rid + 1).with_columns(pl.col(RID) + 1)
2516
+ self.df_view = pl.concat([lf_view_before, row_to_duplicate, lf_view_after]).collect()
2101
2517
 
2102
2518
  # Recreate table for display
2103
2519
  self.setup_table()
@@ -2137,7 +2553,8 @@ class DataFrameTable(DataTable):
2137
2553
 
2138
2554
  # Add to history
2139
2555
  self.add_history(
2140
- f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])", dirty=True
2556
+ f"Moved column [$success]{col_name}[/] [$accent]{direction}[/] (swapped with [$success]{swap_name}[/])",
2557
+ dirty=True,
2141
2558
  )
2142
2559
 
2143
2560
  # Swap columns in the table's internal column locations
@@ -2162,6 +2579,10 @@ class DataFrameTable(DataTable):
2162
2579
  cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
2163
2580
  self.df = self.df.select(cols)
2164
2581
 
2582
+ # Also update the view if applicable
2583
+ if self.df_view is not None:
2584
+ self.df_view = self.df_view.select(cols)
2585
+
2165
2586
  # self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
2166
2587
 
2167
2588
  def do_move_row(self, direction: str) -> None:
@@ -2170,65 +2591,88 @@ class DataFrameTable(DataTable):
2170
2591
  Args:
2171
2592
  direction: "up" to move up, "down" to move down.
2172
2593
  """
2173
- row_idx, col_idx = self.cursor_coordinate
2594
+ curr_row_idx, col_idx = self.cursor_coordinate
2174
2595
 
2175
2596
  # Validate move is possible
2176
2597
  if direction == "up":
2177
- if row_idx <= 0:
2598
+ if curr_row_idx <= 0:
2178
2599
  self.notify("Cannot move row up", title="Move", severity="warning")
2179
2600
  return
2180
- swap_idx = row_idx - 1
2601
+ swap_row_idx = curr_row_idx - 1
2181
2602
  elif direction == "down":
2182
- if row_idx >= len(self.rows) - 1:
2603
+ if curr_row_idx >= len(self.rows) - 1:
2183
2604
  self.notify("Cannot move row down", title="Move", severity="warning")
2184
2605
  return
2185
- swap_idx = row_idx + 1
2606
+ swap_row_idx = curr_row_idx + 1
2186
2607
  else:
2187
2608
  # Invalid direction
2188
2609
  return
2189
2610
 
2190
- row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
2191
- swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
2192
-
2193
2611
  # Add to history
2194
2612
  self.add_history(
2195
- f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])",
2613
+ f"Moved row [$success]{curr_row_idx}[/] [$accent]{direction}[/] (swapped with row [$success]{swap_row_idx}[/])",
2196
2614
  dirty=True,
2197
2615
  )
2198
2616
 
2199
2617
  # Swap rows in the table's internal row locations
2618
+ curr_key = self.coordinate_to_cell_key((curr_row_idx, 0)).row_key
2619
+ swap_key = self.coordinate_to_cell_key((swap_row_idx, 0)).row_key
2620
+
2200
2621
  self.check_idle()
2201
2622
 
2202
2623
  (
2203
- self._row_locations[row_key],
2624
+ self._row_locations[curr_key],
2204
2625
  self._row_locations[swap_key],
2205
2626
  ) = (
2206
- self._row_locations.get(swap_key),
2207
- self._row_locations.get(row_key),
2627
+ self.get_row_idx(swap_key),
2628
+ self.get_row_idx(curr_key),
2208
2629
  )
2209
2630
 
2210
2631
  self._update_count += 1
2211
2632
  self.refresh()
2212
2633
 
2213
2634
  # Restore cursor position on the moved row
2214
- self.move_cursor(row=swap_idx, column=col_idx)
2635
+ self.move_cursor(row=swap_row_idx, column=col_idx)
2215
2636
 
2216
- # Swap rows in the dataframe
2217
- ridx = int(row_key.value) # 0-based
2218
- swap_ridx = int(swap_key.value) # 0-based
2219
- first, second = sorted([ridx, swap_ridx])
2637
+ # Locate the rows to swap
2638
+ curr_ridx = curr_row_idx
2639
+ swap_ridx = swap_row_idx
2640
+ first, second = sorted([curr_ridx, swap_ridx])
2220
2641
 
2642
+ # Swap the rows in the dataframe
2221
2643
  self.df = pl.concat(
2222
2644
  [
2223
- self.df.slice(0, first),
2224
- self.df.slice(second, 1),
2225
- self.df.slice(first + 1, second - first - 1),
2226
- self.df.slice(first, 1),
2227
- self.df.slice(second + 1),
2645
+ self.df.slice(0, first).lazy(),
2646
+ self.df.slice(second, 1).lazy(),
2647
+ self.df.slice(first + 1, second - first - 1).lazy(),
2648
+ self.df.slice(first, 1).lazy(),
2649
+ self.df.slice(second + 1).lazy(),
2228
2650
  ]
2229
- )
2651
+ ).collect()
2652
+
2653
+ # Also update the view if applicable
2654
+ if self.df_view is not None:
2655
+ # Find RID values
2656
+ curr_rid = self.df[RID][curr_row_idx]
2657
+ swap_rid = self.df[RID][swap_row_idx]
2230
2658
 
2231
- # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
2659
+ # Locate the rows by RID in the view
2660
+ curr_ridx = self.df_view[RID].index_of(curr_rid)
2661
+ swap_ridx = self.df_view[RID].index_of(swap_rid)
2662
+ first, second = sorted([curr_ridx, swap_ridx])
2663
+
2664
+ # Swap the rows in the view
2665
+ self.df_view = pl.concat(
2666
+ [
2667
+ self.df_view.slice(0, first).lazy(),
2668
+ self.df_view.slice(second, 1).lazy(),
2669
+ self.df_view.slice(first + 1, second - first - 1).lazy(),
2670
+ self.df_view.slice(first, 1).lazy(),
2671
+ self.df_view.slice(second + 1).lazy(),
2672
+ ]
2673
+ ).collect()
2674
+
2675
+ # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move Row")
2232
2676
 
2233
2677
  # Type casting
2234
2678
  def do_cast_column_dtype(self, dtype: str) -> None:
@@ -2244,12 +2688,12 @@ class DataFrameTable(DataTable):
2244
2688
  try:
2245
2689
  target_dtype = eval(dtype)
2246
2690
  except Exception:
2247
- self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
2691
+ self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error", timeout=10)
2248
2692
  return
2249
2693
 
2250
2694
  if current_dtype == target_dtype:
2251
2695
  self.notify(
2252
- f"Column [$accent]{col_name}[/] is already of type [$success]{target_dtype}[/]",
2696
+ f"Column [$warning]{col_name}[/] is already of type [$accent]{target_dtype}[/]",
2253
2697
  title="Cast",
2254
2698
  severity="warning",
2255
2699
  )
@@ -2257,7 +2701,7 @@ class DataFrameTable(DataTable):
2257
2701
 
2258
2702
  # Add to history
2259
2703
  self.add_history(
2260
- f"Cast column [$accent]{col_name}[/] from [$success]{current_dtype}[/] to [$success]{target_dtype}[/]",
2704
+ f"Cast column [$success]{col_name}[/] from [$accent]{current_dtype}[/] to [$success]{target_dtype}[/]",
2261
2705
  dirty=True,
2262
2706
  )
2263
2707
 
@@ -2265,30 +2709,49 @@ class DataFrameTable(DataTable):
2265
2709
  # Cast the column using Polars
2266
2710
  self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
2267
2711
 
2712
+ # Also update the view if applicable
2713
+ if self.df_view is not None:
2714
+ self.df_view = self.df_view.with_columns(pl.col(col_name).cast(target_dtype))
2715
+
2268
2716
  # Recreate table for display
2269
2717
  self.setup_table()
2270
2718
 
2271
- self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
2719
+ self.notify(f"Cast column [$success]{col_name}[/] to [$accent]{target_dtype}[/]", title="Cast")
2272
2720
  except Exception as e:
2273
2721
  self.notify(
2274
- f"Error casting column [$accent]{col_name}[/] to [$error]{target_dtype}[/]",
2722
+ f"Error casting column [$error]{col_name}[/] to [$accent]{target_dtype}[/]",
2275
2723
  title="Cast",
2276
2724
  severity="error",
2725
+ timeout=10,
2277
2726
  )
2278
2727
  self.log(f"Error casting column `{col_name}`: {str(e)}")
2279
2728
 
2280
- # Search
2281
- def do_search_cursor_value(self) -> None:
2282
- """Search with cursor value in current column."""
2729
+ # Row selection
2730
+ def do_select_row(self) -> None:
2731
+ """Select rows.
2732
+
2733
+ If there are existing cell matches, use those to select rows.
2734
+ Otherwise, use the current cell value as the search term and select rows matching that value.
2735
+ """
2283
2736
  cidx = self.cursor_col_idx
2284
2737
 
2285
- # Get the value of the currently selected cell
2286
- term = NULL if self.cursor_value is None else str(self.cursor_value)
2738
+ # Use existing cell matches if present
2739
+ if self.matches:
2740
+ term = pl.col(RID).is_in(self.matches)
2741
+ else:
2742
+ col_name = self.cursor_col_name
2287
2743
 
2288
- self.search((term, cidx, False, True))
2744
+ # Get the value of the currently selected cell
2745
+ term = NULL if self.cursor_value is None else str(self.cursor_value)
2746
+ if self.cursor_value is None:
2747
+ term = pl.col(col_name).is_null()
2748
+ else:
2749
+ term = pl.col(col_name) == self.cursor_value
2750
+
2751
+ self.select_row((term, cidx, False, True))
2289
2752
 
2290
- def do_search_expr(self) -> None:
2291
- """Search by expression."""
2753
+ def do_select_row_expr(self) -> None:
2754
+ """Select rows by expression."""
2292
2755
  cidx = self.cursor_col_idx
2293
2756
 
2294
2757
  # Use current cell value as default search term
@@ -2296,27 +2759,38 @@ class DataFrameTable(DataTable):
2296
2759
 
2297
2760
  # Push the search modal screen
2298
2761
  self.app.push_screen(
2299
- SearchScreen("Search", term, self.df, cidx),
2300
- callback=self.search,
2762
+ SearchScreen("Select", term, self.df, cidx),
2763
+ callback=self.select_row,
2301
2764
  )
2302
2765
 
2303
- def search(self, result) -> None:
2304
- """Search for a term."""
2766
+ def select_row(self, result) -> None:
2767
+ """Select rows by value or expression."""
2305
2768
  if result is None:
2306
2769
  return
2307
2770
 
2308
2771
  term, cidx, match_nocase, match_whole = result
2309
- col_name = self.df.columns[cidx]
2772
+ col_name = "all columns" if cidx is None else self.df.columns[cidx]
2310
2773
 
2311
- if term == NULL:
2774
+ # Already a Polars expression
2775
+ if isinstance(term, pl.Expr):
2776
+ expr = term
2777
+
2778
+ # bool list or Series
2779
+ elif isinstance(term, (list, pl.Series)):
2780
+ expr = term
2781
+
2782
+ # Null case
2783
+ elif term == NULL:
2312
2784
  expr = pl.col(col_name).is_null()
2313
2785
 
2314
- # Support for polars expressions
2786
+ # Expression in string form
2315
2787
  elif tentative_expr(term):
2316
2788
  try:
2317
2789
  expr = validate_expr(term, self.df.columns, cidx)
2318
2790
  except Exception as e:
2319
- self.notify(f"Error validating expression [$error]{term}[/]", title="Search", severity="error")
2791
+ self.notify(
2792
+ f"Error validating expression [$error]{term}[/]", title="Search", severity="error", timeout=10
2793
+ )
2320
2794
  self.log(f"Error validating expression `{term}`: {str(e)}")
2321
2795
  return
2322
2796
 
@@ -2340,55 +2814,127 @@ class DataFrameTable(DataTable):
2340
2814
  term = f"(?i){term}"
2341
2815
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2342
2816
  self.notify(
2343
- f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2817
+ f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
2344
2818
  title="Search",
2345
2819
  severity="warning",
2346
2820
  )
2347
2821
 
2348
2822
  # Lazyframe for filtering
2349
- lf = self.df.lazy().with_row_index(RIDX)
2350
- if False in self.visible_rows:
2351
- lf = lf.filter(self.visible_rows)
2823
+ lf = self.df.lazy()
2352
2824
 
2353
2825
  # Apply filter to get matched row indices
2354
2826
  try:
2355
- matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
2827
+ ok_rids = set(lf.filter(expr).collect()[RID])
2356
2828
  except Exception as e:
2357
- self.notify(f"Error applying search filter [$error]{term}[/]", title="Search", severity="error")
2829
+ self.notify(
2830
+ f"Error applying search filter `[$error]{term}[/]`", title="Search", severity="error", timeout=10
2831
+ )
2358
2832
  self.log(f"Error applying search filter `{term}`: {str(e)}")
2359
2833
  return
2360
2834
 
2361
- match_count = len(matches)
2835
+ match_count = len(ok_rids)
2362
2836
  if match_count == 0:
2363
2837
  self.notify(
2364
- f"No matches found for [$accent]{term}[/]. Try [$warning](?i)abc[/] for case-insensitive search.",
2838
+ f"No matches found for `[$warning]{term}[/]`. Try [$accent](?i)abc[/] for case-insensitive search.",
2365
2839
  title="Search",
2366
2840
  severity="warning",
2367
2841
  )
2368
2842
  return
2369
2843
 
2844
+ message = f"Found [$success]{match_count}[/] matching row(s)"
2845
+
2370
2846
  # Add to history
2371
- self.add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
2847
+ self.add_history(message)
2372
2848
 
2373
- # Update selected rows to include new matches
2374
- for m in matches:
2375
- self.selected_rows[m] = True
2849
+ # Update selected rows to include new selections
2850
+ self.selected_rows.update(ok_rids)
2376
2851
 
2377
2852
  # Show notification immediately, then start highlighting
2378
- self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
2853
+ self.notify(message, title="Select Row")
2379
2854
 
2380
2855
  # Recreate table for display
2381
2856
  self.setup_table()
2382
2857
 
2383
- # Find
2858
+ def do_toggle_selections(self) -> None:
2859
+ """Toggle selected rows highlighting on/off."""
2860
+ # Add to history
2861
+ self.add_history("Toggled row selection")
2862
+
2863
+ # Invert all selected rows
2864
+ self.selected_rows = {rid for rid in self.df[RID] if rid not in self.selected_rows}
2865
+
2866
+ # Check if we're highlighting or un-highlighting
2867
+ if selected_count := len(self.selected_rows):
2868
+ self.notify(f"Toggled selection for [$success]{selected_count}[/] rows", title="Toggle")
2869
+
2870
+ # Recreate table for display
2871
+ self.setup_table()
2872
+
2873
+ def do_toggle_row_selection(self) -> None:
2874
+ """Select/deselect current row."""
2875
+ # Add to history
2876
+ self.add_history("Toggled row selection")
2877
+
2878
+ # Get current row RID
2879
+ ridx = self.cursor_row_idx
2880
+ rid = self.df[RID][ridx]
2881
+
2882
+ if rid in self.selected_rows:
2883
+ self.selected_rows.discard(rid)
2884
+ else:
2885
+ self.selected_rows.add(rid)
2886
+
2887
+ row_key = self.cursor_row_key
2888
+ is_selected = rid in self.selected_rows
2889
+ match_cols = self.matches.get(rid, set())
2890
+
2891
+ for col_idx, col in enumerate(self.ordered_columns):
2892
+ col_key = col.key
2893
+ col_name = col_key.value
2894
+ cell_text: Text = self.get_cell(row_key, col_key)
2895
+
2896
+ if is_selected or (col_name in match_cols):
2897
+ cell_text.style = HIGHLIGHT_COLOR
2898
+ else:
2899
+ # Reset to default style based on dtype
2900
+ dtype = self.df.dtypes[col_idx]
2901
+ dc = DtypeConfig(dtype)
2902
+ cell_text.style = dc.style
2903
+
2904
+ self.update_cell(row_key, col_key, cell_text)
2905
+
2906
+ def do_clear_selections_and_matches(self) -> None:
2907
+ """Clear all selected rows and matches without removing them from the dataframe."""
2908
+ # Check if any selected rows or matches
2909
+ if not self.selected_rows and not self.matches:
2910
+ self.notify("No selections to clear", title="Clear", severity="warning")
2911
+ return
2912
+
2913
+ row_count = len(self.selected_rows | set(self.matches.keys()))
2914
+
2915
+ # Add to history
2916
+ self.add_history("Cleared all selected rows")
2917
+
2918
+ # Clear all selections
2919
+ self.selected_rows = set()
2920
+ self.matches = defaultdict(set)
2921
+
2922
+ # Recreate table for display
2923
+ self.setup_table()
2924
+
2925
+ self.notify(f"Cleared selections for [$success]{row_count}[/] rows", title="Clear")
2926
+
2927
+ # Find & Replace
2384
2928
  def find_matches(
2385
2929
  self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
2386
- ) -> dict[int, set[int]]:
2930
+ ) -> dict[int, set[str]]:
2387
2931
  """Find matches for a term in the dataframe.
2388
2932
 
2389
2933
  Args:
2390
2934
  term: The search term (can be NULL, expression, or plain text)
2391
2935
  cidx: Column index for column-specific search. If None, searches all columns.
2936
+ match_nocase: Whether to perform case-insensitive matching (for string terms)
2937
+ match_whole: Whether to match the whole cell content (for string terms)
2392
2938
 
2393
2939
  Returns:
2394
2940
  Dictionary mapping row indices to sets of column indices containing matches.
@@ -2398,12 +2944,10 @@ class DataFrameTable(DataTable):
2398
2944
  Raises:
2399
2945
  Exception: If expression validation or filtering fails.
2400
2946
  """
2401
- matches: dict[int, set[int]] = defaultdict(set)
2947
+ matches: dict[int, set[str]] = defaultdict(set)
2402
2948
 
2403
2949
  # Lazyframe for filtering
2404
- lf = self.df.lazy().with_row_index(RIDX)
2405
- if False in self.visible_rows:
2406
- lf = lf.filter(self.visible_rows)
2950
+ lf = self.df.lazy()
2407
2951
 
2408
2952
  # Determine which columns to search: single column or all columns
2409
2953
  if cidx is not None:
@@ -2420,7 +2964,9 @@ class DataFrameTable(DataTable):
2420
2964
  try:
2421
2965
  expr = validate_expr(term, self.df.columns, col_idx)
2422
2966
  except Exception as e:
2423
- self.notify(f"Error validating expression [$error]{term}[/]", title="Find", severity="error")
2967
+ self.notify(
2968
+ f"Error validating expression [$error]{term}[/]", title="Find", severity="error", timeout=10
2969
+ )
2424
2970
  self.log(f"Error validating expression `{term}`: {str(e)}")
2425
2971
  return matches
2426
2972
  else:
@@ -2432,14 +2978,14 @@ class DataFrameTable(DataTable):
2432
2978
 
2433
2979
  # Get matched row indices
2434
2980
  try:
2435
- matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
2981
+ matched_ridxs = lf.filter(expr).collect()[RID]
2436
2982
  except Exception as e:
2437
- self.notify(f"Error applying filter: {expr}", title="Find", severity="error")
2983
+ self.notify(f"Error applying filter: [$error]{expr}[/]", title="Find", severity="error", timeout=10)
2438
2984
  self.log(f"Error applying filter: {str(e)}")
2439
2985
  return matches
2440
2986
 
2441
2987
  for ridx in matched_ridxs:
2442
- matches[ridx].add(col_idx)
2988
+ matches[ridx].add(col_name)
2443
2989
 
2444
2990
  return matches
2445
2991
 
@@ -2485,27 +3031,27 @@ class DataFrameTable(DataTable):
2485
3031
  try:
2486
3032
  matches = self.find_matches(term, cidx, match_nocase, match_whole)
2487
3033
  except Exception as e:
2488
- self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
3034
+ self.notify(f"Error finding matches for `[$error]{term}[/]`", title="Find", severity="error", timeout=10)
2489
3035
  self.log(f"Error finding matches for `{term}`: {str(e)}")
2490
3036
  return
2491
3037
 
2492
3038
  if not matches:
2493
3039
  self.notify(
2494
- f"No matches found for [$accent]{term}[/] in current column. Try [$warning](?i)abc[/] for case-insensitive search.",
3040
+ f"No matches found for `[$warning]{term}[/]` in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
2495
3041
  title="Find",
2496
3042
  severity="warning",
2497
3043
  )
2498
3044
  return
2499
3045
 
2500
3046
  # Add to history
2501
- self.add_history(f"Found [$accent]{term}[/] in column [$success]{col_name}[/]")
3047
+ self.add_history(f"Found `[$success]{term}[/]` in column [$accent]{col_name}[/]")
2502
3048
 
2503
3049
  # Add to matches and count total
2504
- match_count = sum(len(col_idxs) for col_idxs in matches.values())
2505
- for ridx, col_idxs in matches.items():
2506
- self.matches[ridx].update(col_idxs)
3050
+ match_count = sum(len(cols) for cols in matches.values())
3051
+ for rid, cols in matches.items():
3052
+ self.matches[rid].update(cols)
2507
3053
 
2508
- self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
3054
+ self.notify(f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]`", title="Find")
2509
3055
 
2510
3056
  # Recreate table for display
2511
3057
  self.setup_table()
@@ -2519,28 +3065,29 @@ class DataFrameTable(DataTable):
2519
3065
  try:
2520
3066
  matches = self.find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
2521
3067
  except Exception as e:
2522
- self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
3068
+ self.notify(f"Error finding matches for `[$error]{term}[/]`", title="Find", severity="error", timeout=10)
2523
3069
  self.log(f"Error finding matches for `{term}`: {str(e)}")
2524
3070
  return
2525
3071
 
2526
3072
  if not matches:
2527
3073
  self.notify(
2528
- f"No matches found for [$accent]{term}[/] in any column. Try [$warning](?i)abc[/] for case-insensitive search.",
3074
+ f"No matches found for `[$warning]{term}[/]` in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
2529
3075
  title="Global Find",
2530
3076
  severity="warning",
2531
3077
  )
2532
3078
  return
2533
3079
 
2534
3080
  # Add to history
2535
- self.add_history(f"Found [$success]{term}[/] across all columns")
3081
+ self.add_history(f"Found `[$success]{term}[/]` across all columns")
2536
3082
 
2537
3083
  # Add to matches and count total
2538
- match_count = sum(len(col_idxs) for col_idxs in matches.values())
2539
- for ridx, col_idxs in matches.items():
2540
- self.matches[ridx].update(col_idxs)
3084
+ match_count = sum(len(cols) for cols in matches.values())
3085
+ for rid, cols in matches.items():
3086
+ self.matches[rid].update(cols)
2541
3087
 
2542
3088
  self.notify(
2543
- f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
3089
+ f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]` across all columns",
3090
+ title="Global Find",
2544
3091
  )
2545
3092
 
2546
3093
  # Recreate table for display
@@ -2598,7 +3145,7 @@ class DataFrameTable(DataTable):
2598
3145
 
2599
3146
  def do_next_selected_row(self) -> None:
2600
3147
  """Move cursor to the next selected row."""
2601
- if not any(self.selected_rows):
3148
+ if not self.selected_rows:
2602
3149
  self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
2603
3150
  return
2604
3151
 
@@ -2620,7 +3167,7 @@ class DataFrameTable(DataTable):
2620
3167
 
2621
3168
  def do_previous_selected_row(self) -> None:
2622
3169
  """Move cursor to the previous selected row."""
2623
- if not any(self.selected_rows):
3170
+ if not self.selected_rows:
2624
3171
  self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
2625
3172
  return
2626
3173
 
@@ -2640,7 +3187,6 @@ class DataFrameTable(DataTable):
2640
3187
  last_ridx = selected_row_indices[-1]
2641
3188
  self.move_cursor_to(last_ridx, self.cursor_col_idx)
2642
3189
 
2643
- # Replace
2644
3190
  def do_replace(self) -> None:
2645
3191
  """Open replace screen for current column."""
2646
3192
  # Push the replace modal screen
@@ -2690,29 +3236,38 @@ class DataFrameTable(DataTable):
2690
3236
 
2691
3237
  # Add to history
2692
3238
  self.add_history(
2693
- f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
3239
+ f"Replaced [$success]{term_find}[/] with [$accent]{term_replace}[/] in column [$success]{col_name}[/]"
2694
3240
  )
2695
3241
 
2696
3242
  # Update matches
2697
- self.matches = {ridx: col_idxs.copy() for ridx, col_idxs in matches.items()}
3243
+ self.matches = matches
2698
3244
 
2699
3245
  # Recreate table for display
2700
3246
  self.setup_table()
2701
3247
 
2702
3248
  # Store state for interactive replacement using dataclass
2703
- sorted_rows = sorted(self.matches.keys())
3249
+ rid2ridx = {rid: ridx for ridx, rid in enumerate(self.df[RID]) if rid in self.matches}
3250
+
3251
+ # Unique columns to replace
3252
+ cols_to_replace = set()
3253
+ for cols in self.matches.values():
3254
+ cols_to_replace.update(cols)
3255
+
3256
+ # Sorted column indices to replace
3257
+ cidx2col = {cidx: col for cidx, col in enumerate(self.df.columns) if col in cols_to_replace}
3258
+
2704
3259
  self.replace_state = ReplaceState(
2705
3260
  term_find=term_find,
2706
3261
  term_replace=term_replace,
2707
3262
  match_nocase=match_nocase,
2708
3263
  match_whole=match_whole,
2709
3264
  cidx=cidx,
2710
- rows=sorted_rows,
2711
- cols_per_row=[sorted(self.matches[ridx]) for ridx in sorted_rows],
3265
+ rows=list(rid2ridx.values()),
3266
+ cols_per_row=[[cidx for cidx, col in cidx2col.items() if col in self.matches[rid]] for rid in rid2ridx],
2712
3267
  current_rpos=0,
2713
3268
  current_cpos=0,
2714
3269
  current_occurrence=0,
2715
- total_occurrence=sum(len(col_idxs) for col_idxs in self.matches.values()),
3270
+ total_occurrence=sum(len(cols) for cols in self.matches.values()),
2716
3271
  replaced_occurrence=0,
2717
3272
  skipped_occurrence=0,
2718
3273
  done=False,
@@ -2728,9 +3283,10 @@ class DataFrameTable(DataTable):
2728
3283
 
2729
3284
  except Exception as e:
2730
3285
  self.notify(
2731
- f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
3286
+ f"Error replacing [$error]{term_find}[/] with [$accent]{term_replace}[/]",
2732
3287
  title="Replace",
2733
3288
  severity="error",
3289
+ timeout=10,
2734
3290
  )
2735
3291
  self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
2736
3292
 
@@ -2740,7 +3296,7 @@ class DataFrameTable(DataTable):
2740
3296
  self.app.push_screen(
2741
3297
  ConfirmScreen(
2742
3298
  "Replace All",
2743
- label=f"Replace [$success]{term_find}[/] with [$success]{term_replace or repr('')}[/] for all [$accent]{state.total_occurrence}[/] occurrences?",
3299
+ label=f"Replace `[$success]{term_find}[/]` with `[$success]{term_replace}[/]` for all [$accent]{state.total_occurrence}[/] occurrences?",
2744
3300
  ),
2745
3301
  callback=self.handle_replace_all_confirmation,
2746
3302
  )
@@ -2795,6 +3351,18 @@ class DataFrameTable(DataTable):
2795
3351
  pl.when(mask).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
2796
3352
  )
2797
3353
 
3354
+ # Also update the view if applicable
3355
+ if self.df_view is not None:
3356
+ col_updated = f"^_{col_name}_^"
3357
+ lf_updated = self.df.lazy().filter(mask).select(pl.col(col_name).alias(col_updated), pl.col(RID))
3358
+ self.df_view = (
3359
+ self.df_view.lazy()
3360
+ .join(lf_updated, on=RID, how="left")
3361
+ .with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
3362
+ .drop(col_updated)
3363
+ .collect()
3364
+ )
3365
+
2798
3366
  state.replaced_occurrence += len(ridxs)
2799
3367
 
2800
3368
  # Recreate table for display
@@ -2806,7 +3374,7 @@ class DataFrameTable(DataTable):
2806
3374
 
2807
3375
  col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
2808
3376
  self.notify(
2809
- f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]",
3377
+ f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]",
2810
3378
  title="Replace",
2811
3379
  )
2812
3380
 
@@ -2817,9 +3385,10 @@ class DataFrameTable(DataTable):
2817
3385
  self.show_next_replace_confirmation()
2818
3386
  except Exception as e:
2819
3387
  self.notify(
2820
- f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
3388
+ f"Error replacing [$error]{term_find}[/] with [$accent]{term_replace}[/]",
2821
3389
  title="Replace",
2822
3390
  severity="error",
3391
+ timeout=10,
2823
3392
  )
2824
3393
  self.log(f"Error in interactive replace: {str(e)}")
2825
3394
 
@@ -2829,7 +3398,7 @@ class DataFrameTable(DataTable):
2829
3398
  if state.done:
2830
3399
  # All done - show final notification
2831
3400
  col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
2832
- msg = f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]"
3401
+ msg = f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]"
2833
3402
  if state.skipped_occurrence > 0:
2834
3403
  msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
2835
3404
  self.notify(msg, title="Replace")
@@ -2847,7 +3416,7 @@ class DataFrameTable(DataTable):
2847
3416
  state.current_occurrence += 1
2848
3417
 
2849
3418
  # Show confirmation
2850
- label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] ({state.current_occurrence} of {state.total_occurrence})?"
3419
+ label = f"Replace `[$warning]{state.term_find}[/]` with `[$success]{state.term_replace}[/]` ({state.current_occurrence} of {state.total_occurrence})?"
2851
3420
 
2852
3421
  self.app.push_screen(
2853
3422
  ConfirmScreen("Replace", label=label, maybe="Skip"),
@@ -2864,6 +3433,7 @@ class DataFrameTable(DataTable):
2864
3433
  cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
2865
3434
  col_name = self.df.columns[cidx]
2866
3435
  dtype = self.df.dtypes[cidx]
3436
+ rid = self.df[RID][ridx]
2867
3437
 
2868
3438
  # Replace
2869
3439
  if result is True:
@@ -2876,6 +3446,15 @@ class DataFrameTable(DataTable):
2876
3446
  .otherwise(pl.col(col_name))
2877
3447
  .alias(col_name)
2878
3448
  )
3449
+
3450
+ # Also update the view if applicable
3451
+ if self.df_view is not None:
3452
+ self.df_view = self.df_view.with_columns(
3453
+ pl.when(pl.col(RID) == rid)
3454
+ .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
3455
+ .otherwise(pl.col(col_name))
3456
+ .alias(col_name)
3457
+ )
2879
3458
  else:
2880
3459
  # try to convert replacement value to column dtype
2881
3460
  try:
@@ -2890,6 +3469,12 @@ class DataFrameTable(DataTable):
2890
3469
  .alias(col_name)
2891
3470
  )
2892
3471
 
3472
+ # Also update the view if applicable
3473
+ if self.df_view is not None:
3474
+ self.df_view = self.df_view.with_columns(
3475
+ pl.when(pl.col(RID) == rid).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
3476
+ )
3477
+
2893
3478
  state.replaced_occurrence += 1
2894
3479
 
2895
3480
  # Skip
@@ -2922,128 +3507,25 @@ class DataFrameTable(DataTable):
2922
3507
  # Show next confirmation
2923
3508
  self.show_next_replace_confirmation()
2924
3509
 
2925
- # Selection & Match
2926
- def do_toggle_selections(self) -> None:
2927
- """Toggle selected rows highlighting on/off."""
2928
- # Add to history
2929
- self.add_history("Toggled row selection")
2930
-
2931
- if False in self.visible_rows:
2932
- # Some rows are hidden - invert only selected visible rows and clear selections for hidden rows
2933
- for i in range(len(self.selected_rows)):
2934
- if self.visible_rows[i]:
2935
- self.selected_rows[i] = not self.selected_rows[i]
2936
- else:
2937
- self.selected_rows[i] = False
2938
- else:
2939
- # Invert all selected rows
2940
- self.selected_rows = [not selected for selected in self.selected_rows]
2941
-
2942
- # Check if we're highlighting or un-highlighting
2943
- if new_selected_count := self.selected_rows.count(True):
2944
- self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
2945
-
2946
- # Recreate table for display
2947
- self.setup_table()
2948
-
2949
- def do_toggle_row_selection(self) -> None:
2950
- """Select/deselect current row."""
2951
- # Add to history
2952
- self.add_history("Toggled row selection")
2953
-
2954
- ridx = self.cursor_row_idx
2955
- self.selected_rows[ridx] = not self.selected_rows[ridx]
2956
-
2957
- row_key = str(ridx)
2958
- match_cols = self.matches.get(ridx, set())
2959
- for col_idx, col in enumerate(self.ordered_columns):
2960
- col_key = col.key
2961
- cell_text: Text = self.get_cell(row_key, col_key)
2962
-
2963
- if self.selected_rows[ridx] or (col_idx in match_cols):
2964
- cell_text.style = HIGHLIGHT_COLOR
2965
- else:
2966
- # Reset to default style based on dtype
2967
- dtype = self.df.dtypes[col_idx]
2968
- dc = DtypeConfig(dtype)
2969
- cell_text.style = dc.style
2970
-
2971
- self.update_cell(row_key, col_key, cell_text)
2972
-
2973
- def do_clear_selections_and_matches(self) -> None:
2974
- """Clear all selected rows and matches without removing them from the dataframe."""
2975
- # Check if any selected rows or matches
2976
- if not any(self.selected_rows) and not self.matches:
2977
- self.notify("No selections to clear", title="Clear", severity="warning")
2978
- return
2979
-
2980
- row_count = sum(
2981
- 1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
2982
- )
2983
-
2984
- # Add to history
2985
- self.add_history("Cleared all selected rows")
2986
-
2987
- # Clear all selections
2988
- self.selected_rows = [False] * len(self.df)
2989
- self.matches = defaultdict(set)
2990
-
2991
- # Recreate table for display
2992
- self.setup_table()
2993
-
2994
- self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
2995
-
2996
- # Filter & View
2997
- def do_filter_rows(self) -> None:
2998
- """Keep only the rows with selections and matches, and remove others."""
2999
- if not any(self.selected_rows) and not self.matches:
3000
- self.notify("No rows to filter", title="Filter", severity="warning")
3001
- return
3002
-
3003
- filter_expr = [
3004
- True if (selected or ridx in self.matches) else False for ridx, selected in enumerate(self.selected_rows)
3005
- ]
3006
-
3007
- # Add to history
3008
- self.add_history("Filtered to selections and matches", dirty=True)
3009
-
3010
- # Apply filter to dataframe with row indices
3011
- df_filtered = self.df.with_row_index(RIDX).filter(filter_expr)
3012
-
3013
- # Update selections and matches
3014
- self.selected_rows = [self.selected_rows[ridx] for ridx in df_filtered[RIDX]]
3015
- self.matches = {
3016
- idx: self.matches[ridx].copy() for idx, ridx in enumerate(df_filtered[RIDX]) if ridx in self.matches
3017
- }
3018
-
3019
- # Update dataframe
3020
- self.df = df_filtered.drop(RIDX)
3021
-
3022
- # Recreate table for display
3023
- self.setup_table()
3024
-
3025
- self.notify(
3026
- f"Removed rows without selections or matches. Now showing [$accent]{len(self.df)}[/] rows", title="Filter"
3027
- )
3028
-
3510
+ # View & Filter
3029
3511
  def do_view_rows(self) -> None:
3030
3512
  """View rows.
3031
3513
 
3032
- If there are selected rows or matches, view those rows.
3033
- Otherwise, view based on the value of the currently selected cell.
3514
+ If there are selected rows, view those.
3515
+ Otherwise, view based on the cursor value.
3034
3516
  """
3035
3517
 
3036
3518
  cidx = self.cursor_col_idx
3519
+ col_name = self.cursor_col_name
3037
3520
 
3038
- # If there are rows with selections or matches, use those
3039
- if any(self.selected_rows) or self.matches:
3040
- term = [
3041
- True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
3042
- ]
3521
+ # If there are selected rows, use those
3522
+ if self.selected_rows:
3523
+ term = pl.col(RID).is_in(self.selected_rows)
3043
3524
  # Otherwise, use the current cell value
3044
3525
  else:
3045
3526
  ridx = self.cursor_row_idx
3046
- term = str(self.df.item(ridx, cidx))
3527
+ value = self.df.item(ridx, cidx)
3528
+ term = pl.col(col_name).is_null() if value is None else pl.col(col_name) == value
3047
3529
 
3048
3530
  self.view_rows((term, cidx, False, True))
3049
3531
 
@@ -3051,34 +3533,46 @@ class DataFrameTable(DataTable):
3051
3533
  """Open the filter screen to enter an expression."""
3052
3534
  ridx = self.cursor_row_idx
3053
3535
  cidx = self.cursor_col_idx
3054
- cursor_value = str(self.df.item(ridx, cidx))
3536
+ cursor_value = self.df.item(ridx, cidx)
3537
+ term = NULL if cursor_value is None else str(cursor_value)
3055
3538
 
3056
3539
  self.app.push_screen(
3057
- FilterScreen(self.df, cidx, cursor_value),
3540
+ FilterScreen(self.df, cidx, term),
3058
3541
  callback=self.view_rows,
3059
3542
  )
3060
3543
 
3061
3544
  def view_rows(self, result) -> None:
3062
- """Show only rows with selections or matches, and do hide others. Do not modify the dataframe."""
3545
+ """View selected rows and hide others. Do not modify the dataframe."""
3063
3546
  if result is None:
3064
3547
  return
3065
3548
  term, cidx, match_nocase, match_whole = result
3066
3549
 
3067
3550
  col_name = self.df.columns[cidx]
3068
3551
 
3069
- if term == NULL:
3070
- expr = pl.col(col_name).is_null()
3552
+ # Support for polars expression
3553
+ if isinstance(term, pl.Expr):
3554
+ expr = term
3555
+
3556
+ # Support for list of booleans (selected rows)
3071
3557
  elif isinstance(term, (list, pl.Series)):
3072
- # Support for list of booleans (selected rows)
3073
3558
  expr = term
3559
+
3560
+ # Null case
3561
+ elif term == NULL:
3562
+ expr = pl.col(col_name).is_null()
3563
+
3564
+ # Support for polars expression in string form
3074
3565
  elif tentative_expr(term):
3075
- # Support for polars expressions
3076
3566
  try:
3077
3567
  expr = validate_expr(term, self.df.columns, cidx)
3078
3568
  except Exception as e:
3079
- self.notify(f"Error validating expression [$error]{term}[/]", title="Filter", severity="error")
3569
+ self.notify(
3570
+ f"Error validating expression [$error]{term}[/]", title="Filter", severity="error", timeout=10
3571
+ )
3080
3572
  self.log(f"Error validating expression `{term}`: {str(e)}")
3081
3573
  return
3574
+
3575
+ # Type-aware search based on column dtype
3082
3576
  else:
3083
3577
  dtype = self.df.dtypes[cidx]
3084
3578
  if dtype == pl.String:
@@ -3102,23 +3596,16 @@ class DataFrameTable(DataTable):
3102
3596
  )
3103
3597
 
3104
3598
  # Lazyframe with row indices
3105
- lf = self.df.lazy().with_row_index(RIDX)
3599
+ lf = self.df.lazy()
3106
3600
 
3107
- # Apply existing visibility filter first
3108
- if False in self.visible_rows:
3109
- lf = lf.filter(self.visible_rows)
3110
-
3111
- if isinstance(expr, (list, pl.Series)):
3112
- expr_str = str(list(expr)[:10]) + ("..." if len(expr) > 10 else "")
3113
- else:
3114
- expr_str = str(expr)
3601
+ expr_str = "boolean list or series" if isinstance(expr, (list, pl.Series)) else str(expr)
3115
3602
 
3116
3603
  # Apply the filter expression
3117
3604
  try:
3118
3605
  df_filtered = lf.filter(expr).collect()
3119
3606
  except Exception as e:
3120
- self.histories.pop() # Remove last history entry
3121
- self.notify(f"Error applying filter [$error]{expr_str}[/]", title="Filter", severity="error")
3607
+ self.histories_undo.pop() # Remove last history entry
3608
+ self.notify(f"Error applying filter [$error]{expr_str}[/]", title="Filter", severity="error", timeout=10)
3122
3609
  self.log(f"Error applying filter `{expr_str}`: {str(e)}")
3123
3610
  return
3124
3611
 
@@ -3128,19 +3615,83 @@ class DataFrameTable(DataTable):
3128
3615
  return
3129
3616
 
3130
3617
  # Add to history
3131
- self.add_history(f"Filtered by expression [$success]{expr_str}[/]", dirty=True)
3618
+ self.add_history(f"Filtered by expression [$success]{expr_str}[/]")
3619
+
3620
+ ok_rids = set(df_filtered[RID])
3621
+
3622
+ # Create a view of self.df as a copy
3623
+ if self.df_view is None:
3624
+ self.df_view = self.df
3625
+
3626
+ # Update dataframe
3627
+ self.df = df_filtered
3628
+
3629
+ # Update selected rows
3630
+ if self.selected_rows:
3631
+ self.selected_rows.intersection_update(ok_rids)
3632
+
3633
+ # Update matches
3634
+ if self.matches:
3635
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3636
+
3637
+ # Recreate table for display
3638
+ self.setup_table()
3639
+
3640
+ self.notify(f"Filtered to [$success]{matched_count}[/] matching row(s)", title="Filter")
3641
+
3642
+ def do_filter_rows(self) -> None:
3643
+ """Filter rows.
3644
+
3645
+ If there are selected rows, use those.
3646
+ Otherwise, filter based on the cursor value.
3647
+ """
3648
+ if self.selected_rows:
3649
+ message = "Filtered to selected rows (other rows removed)"
3650
+ filter_expr = pl.col(RID).is_in(self.selected_rows)
3651
+ else: # Search cursor value in current column
3652
+ message = "Filtered to rows matching cursor value (other rows removed)"
3653
+ cidx = self.cursor_col_idx
3654
+ col_name = self.df.columns[cidx]
3655
+ value = self.cursor_value
3656
+
3657
+ if value is None:
3658
+ filter_expr = pl.col(col_name).is_null()
3659
+ else:
3660
+ filter_expr = pl.col(col_name) == value
3661
+
3662
+ # Add to history
3663
+ self.add_history(message, dirty=True)
3664
+
3665
+ # Apply filter to dataframe with row indices
3666
+ df_filtered = self.df.lazy().filter(filter_expr).collect()
3667
+ ok_rids = set(df_filtered[RID])
3668
+
3669
+ # Update selected rows
3670
+ if self.selected_rows:
3671
+ selected_rows = {rid for rid in self.selected_rows if rid in ok_rids}
3672
+ else:
3673
+ selected_rows = set()
3674
+
3675
+ # Update matches
3676
+ if self.matches:
3677
+ matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3678
+ else:
3679
+ matches = defaultdict(set)
3680
+
3681
+ # Update dataframe
3682
+ self.reset_df(df_filtered)
3132
3683
 
3133
- # Mark unfiltered rows as invisible
3134
- filtered_row_indices = set(df_filtered[RIDX].to_list())
3135
- if filtered_row_indices:
3136
- for ridx in range(len(self.visible_rows)):
3137
- if ridx not in filtered_row_indices:
3138
- self.visible_rows[ridx] = False
3684
+ # Clear view for filter mode
3685
+ self.df_view = None
3686
+
3687
+ # Restore selected rows and matches
3688
+ self.selected_rows = selected_rows
3689
+ self.matches = matches
3139
3690
 
3140
3691
  # Recreate table for display
3141
3692
  self.setup_table()
3142
3693
 
3143
- self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
3694
+ self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter")
3144
3695
 
3145
3696
  # Copy & Save
3146
3697
  def do_copy_to_clipboard(self, content: str, message: str) -> None:
@@ -3164,22 +3715,26 @@ class DataFrameTable(DataTable):
3164
3715
  )
3165
3716
  self.notify(message, title="Clipboard")
3166
3717
  except FileNotFoundError:
3167
- self.notify("Error copying to clipboard", title="Clipboard", severity="error")
3718
+ self.notify("Error copying to clipboard", title="Clipboard", severity="error", timeout=10)
3168
3719
 
3169
- def do_save_to_file(
3170
- self, title: str = "Save to File", all_tabs: bool | None = None, task_after_save: str | None = None
3171
- ) -> None:
3720
+ def do_save_to_file(self, all_tabs: bool | None = None, task_after_save: str | None = None) -> None:
3172
3721
  """Open screen to save file."""
3173
3722
  self._task_after_save = task_after_save
3723
+ tab_count = len(self.app.tabs)
3724
+ save_all = tab_count > 1 and all_tabs is not False
3725
+
3726
+ filepath = Path(self.filename)
3727
+ if save_all:
3728
+ ext = filepath.suffix.lower()
3729
+ if ext in (".xlsx", ".xls"):
3730
+ filename = self.filename
3731
+ else:
3732
+ filename = "all-tabs.xlsx"
3733
+ else:
3734
+ filename = str(filepath.with_stem(self.tabname))
3174
3735
 
3175
- multi_tab = len(self.app.tabs) > 1
3176
- filename = (
3177
- "all-tabs.xlsx"
3178
- if all_tabs or (all_tabs is None and multi_tab)
3179
- else str(Path(self.filename).with_stem(self.tabname))
3180
- )
3181
3736
  self.app.push_screen(
3182
- SaveFileScreen(filename, title=title, all_tabs=all_tabs, multi_tab=multi_tab),
3737
+ SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
3183
3738
  callback=self.save_to_file,
3184
3739
  )
3185
3740
 
@@ -3187,13 +3742,11 @@ class DataFrameTable(DataTable):
3187
3742
  """Handle result from SaveFileScreen."""
3188
3743
  if result is None:
3189
3744
  return
3190
- filename, all_tabs = result
3191
-
3192
- # Whether to save all tabs (for Excel files)
3193
- self._all_tabs = all_tabs
3745
+ filename, save_all, overwrite_prompt = result
3746
+ self._save_all = save_all
3194
3747
 
3195
3748
  # Check if file exists
3196
- if Path(filename).exists():
3749
+ if overwrite_prompt and Path(filename).exists():
3197
3750
  self._pending_filename = filename
3198
3751
  self.app.push_screen(
3199
3752
  ConfirmScreen("File already exists. Overwrite?"),
@@ -3209,7 +3762,7 @@ class DataFrameTable(DataTable):
3209
3762
  else:
3210
3763
  # Go back to SaveFileScreen to allow user to enter a different name
3211
3764
  self.app.push_screen(
3212
- SaveFileScreen(self._pending_filename),
3765
+ SaveFileScreen(self._pending_filename, save_all=self._save_all),
3213
3766
  callback=self.save_to_file,
3214
3767
  )
3215
3768
 
@@ -3217,62 +3770,78 @@ class DataFrameTable(DataTable):
3217
3770
  """Actually save the dataframe to a file."""
3218
3771
  filepath = Path(filename)
3219
3772
  ext = filepath.suffix.lower()
3773
+ if ext == ".gz":
3774
+ ext = Path(filename).with_suffix("").suffix.lower()
3220
3775
 
3221
- # Add to history
3222
- self.add_history(f"Saved dataframe to [$success]{filename}[/]")
3776
+ fmt = ext.removeprefix(".")
3777
+ if fmt not in SUPPORTED_FORMATS:
3778
+ self.notify(
3779
+ f"Unsupported file format [$success]{fmt}[/]. Use [$accent]CSV[/] as fallback. Supported formats: {', '.join(SUPPORTED_FORMATS)}",
3780
+ title="Save to File",
3781
+ severity="warning",
3782
+ )
3783
+ fmt = "csv"
3223
3784
 
3785
+ df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3224
3786
  try:
3225
- if ext in (".xlsx", ".xls"):
3787
+ if fmt == "csv":
3788
+ df.write_csv(filename)
3789
+ elif fmt in ("tsv", "tab"):
3790
+ df.write_csv(filename, separator="\t")
3791
+ elif fmt in ("xlsx", "xls"):
3226
3792
  self.save_excel(filename)
3227
- elif ext in (".tsv", ".tab"):
3228
- self.df.write_csv(filename, separator="\t")
3229
- elif ext == ".json":
3230
- self.df.write_json(filename)
3231
- elif ext == ".parquet":
3232
- self.df.write_parquet(filename)
3233
- else:
3234
- self.df.write_csv(filename)
3235
-
3236
- self.dataframe = self.df # Update original dataframe
3237
- self.filename = filename # Update current filename
3793
+ elif fmt == "json":
3794
+ df.write_json(filename)
3795
+ elif fmt == "ndjson":
3796
+ df.write_ndjson(filename)
3797
+ elif fmt == "parquet":
3798
+ df.write_parquet(filename)
3799
+ else: # Fallback to CSV
3800
+ df.write_csv(filename)
3801
+
3802
+ # Update current filename
3803
+ self.filename = filename
3238
3804
 
3239
3805
  # Reset dirty flag after save
3240
- if self._all_tabs:
3806
+ if self._save_all:
3241
3807
  tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3242
3808
  for table in tabs.values():
3243
3809
  table.dirty = False
3244
3810
  else:
3245
3811
  self.dirty = False
3246
3812
 
3247
- if self._task_after_save == "close_tab":
3248
- self.app.do_close_tab()
3249
- elif self._task_after_save == "quit_app":
3250
- self.app.exit()
3813
+ if hasattr(self, "_task_after_save"):
3814
+ if self._task_after_save == "close_tab":
3815
+ self.app.do_close_tab()
3816
+ elif self._task_after_save == "quit_app":
3817
+ self.app.exit()
3251
3818
 
3252
3819
  # From ConfirmScreen callback, so notify accordingly
3253
- if self._all_tabs:
3820
+ if self._save_all:
3254
3821
  self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
3255
3822
  else:
3256
3823
  self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
3257
3824
 
3258
3825
  except Exception as e:
3259
- self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error")
3826
+ self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error", timeout=10)
3260
3827
  self.log(f"Error saving file `{filename}`: {str(e)}")
3261
3828
 
3262
3829
  def save_excel(self, filename: str) -> None:
3263
3830
  """Save to an Excel file."""
3264
3831
  import xlsxwriter
3265
3832
 
3266
- if not self._all_tabs or len(self.app.tabs) == 1:
3833
+ if not self._save_all or len(self.app.tabs) == 1:
3267
3834
  # Single tab - save directly
3268
- self.df.write_excel(filename)
3835
+ df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3836
+ df.write_excel(filename, worksheet=self.tabname)
3269
3837
  else:
3270
3838
  # Multiple tabs - use xlsxwriter to create multiple sheets
3271
3839
  with xlsxwriter.Workbook(filename) as wb:
3272
3840
  tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3273
3841
  for table in tabs.values():
3274
3842
  worksheet = wb.add_worksheet(table.tabname)
3275
- table.df.write_excel(workbook=wb, worksheet=worksheet)
3843
+ df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
3844
+ df.write_excel(workbook=wb, worksheet=worksheet)
3276
3845
 
3277
3846
  # SQL Interface
3278
3847
  def do_simple_sql(self) -> None:
@@ -3316,19 +3885,17 @@ class DataFrameTable(DataTable):
3316
3885
  sql: The SQL query string to execute.
3317
3886
  """
3318
3887
 
3319
- import re
3320
-
3321
- RE_FROM_SELF = re.compile(r"\bfrom\s+self\b", re.IGNORECASE)
3888
+ sql = sql.replace("$#", f"(`{RID}` + 1)")
3889
+ if RID not in sql and "*" not in sql:
3890
+ # Ensure RID is selected
3891
+ import re
3322
3892
 
3323
- sql = RE_FROM_SELF.sub(f", `{RIDX}` FROM self", sql)
3893
+ RE_FROM_SELF = re.compile(r"\bFROM\s+self\b", re.IGNORECASE)
3894
+ sql = RE_FROM_SELF.sub(f", `{RID}` FROM self", sql)
3324
3895
 
3325
3896
  # Execute the SQL query
3326
3897
  try:
3327
- lf = self.df.lazy().with_row_index(RIDX)
3328
- if False in self.visible_rows:
3329
- lf = lf.filter(self.visible_rows)
3330
-
3331
- df_filtered = lf.sql(sql).collect()
3898
+ df_filtered = self.df.lazy().sql(sql).collect()
3332
3899
 
3333
3900
  if not len(df_filtered):
3334
3901
  self.notify(
@@ -3336,29 +3903,34 @@ class DataFrameTable(DataTable):
3336
3903
  )
3337
3904
  return
3338
3905
 
3339
- # Add to history
3340
- self.add_history(f"SQL Query:\n[$accent]{sql}[/]", dirty=not view)
3341
-
3342
- if view:
3343
- # Just view - do not modify the dataframe
3344
- filtered_row_indices = set(df_filtered[RIDX].to_list())
3345
- if filtered_row_indices:
3346
- self.visible_rows = [ridx in filtered_row_indices for ridx in range(len(self.visible_rows))]
3347
-
3348
- filtered_col_names = set(df_filtered.columns)
3349
- if filtered_col_names:
3350
- self.hidden_columns = {
3351
- col_name for col_name in self.df.columns if col_name not in filtered_col_names
3352
- }
3353
- else: # filter - modify the dataframe
3354
- self.df = df_filtered.drop(RIDX)
3355
- self.visible_rows = [True] * len(self.df)
3356
- self.hidden_columns.clear()
3357
3906
  except Exception as e:
3358
3907
  self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error", timeout=10)
3359
3908
  self.log(f"Error executing SQL query `{sql}`: {str(e)}")
3360
3909
  return
3361
3910
 
3911
+ # Add to history
3912
+ self.add_history(f"SQL Query:\n[$success]{sql}[/]", dirty=not view)
3913
+
3914
+ # Create a view of self.df as a copy
3915
+ if view and self.df_view is None:
3916
+ self.df_view = self.df
3917
+
3918
+ # Clear view for filter mode
3919
+ if not view:
3920
+ self.df_view = None
3921
+
3922
+ # Update dataframe
3923
+ self.df = df_filtered
3924
+ ok_rids = set(df_filtered[RID])
3925
+
3926
+ # Update selected rows
3927
+ if self.selected_rows:
3928
+ self.selected_rows.intersection_update(ok_rids)
3929
+
3930
+ # Update matches
3931
+ if self.matches:
3932
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3933
+
3362
3934
  # Recreate table for display
3363
3935
  self.setup_table()
3364
3936