dataframe-textual 1.9.0__py3-none-any.whl → 2.2.3__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"),
@@ -222,15 +245,18 @@ class DataFrameTable(DataTable):
222
245
  ("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
223
246
  ("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
224
247
  ("z", "freeze_row_column", "Freeze rows/columns"),
225
- ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
248
+ ("comma", "toggle_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.
@@ -831,7 +905,7 @@ class DataFrameTable(DataTable):
831
905
  """Toggle row labels visibility."""
832
906
  self.show_row_labels = not self.show_row_labels
833
907
  # status = "shown" if self.show_row_labels else "hidden"
834
- # self.notify(f"Row labels {status}", title="Labels")
908
+ # self.notify(f"Row labels {status}", title="Toggle Row Labels")
835
909
 
836
910
  def action_cast_column_dtype(self, dtype: str | pl.DataType) -> None:
837
911
  """Cast the current column to a different data type."""
@@ -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}[/]) to clipboard",
925
+ title="Copy Cell",
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,12 @@ 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(
945
+ f"Error copying column [$error]{col_name}[/] to clipboard",
946
+ title="Copy Column",
947
+ severity="error",
948
+ timeout=10,
949
+ )
866
950
 
867
951
  def action_copy_row(self) -> None:
868
952
  """Copy the current row to clipboard (values separated by tabs)."""
@@ -878,14 +962,16 @@ class DataFrameTable(DataTable):
878
962
  f"Copied row [$accent]{ridx + 1}[/] with [$success]{len(row_values)}[/] values",
879
963
  )
880
964
  except (FileNotFoundError, IndexError):
881
- self.notify("Error copying row", title="Clipboard", severity="error")
965
+ self.notify(
966
+ f"Error copying row [$error]{ridx}[/] to clipboard", title="Copy Row", severity="error", timeout=10
967
+ )
882
968
 
883
- def action_show_thousand_separator(self) -> None:
969
+ def action_toggle_thousand_separator(self) -> None:
884
970
  """Toggle thousand separator for numeric display."""
885
971
  self.thousand_separator = not self.thousand_separator
886
972
  self.setup_table()
887
973
  # status = "enabled" if self.thousand_separator else "disabled"
888
- # self.notify(f"Thousand separator {status}", title="Display")
974
+ # self.notify(f"Thousand separator {status}", title="Toggle Thousand Separator")
889
975
 
890
976
  def action_next_match(self) -> None:
891
977
  """Go to the next matched cell."""
@@ -911,56 +997,50 @@ class DataFrameTable(DataTable):
911
997
  """Open the advanced SQL interface screen."""
912
998
  self.do_advanced_sql()
913
999
 
1000
+ def on_mouse_scroll_up(self, event) -> None:
1001
+ """Load more rows when scrolling up with mouse."""
1002
+ self.load_rows_up()
1003
+
914
1004
  def on_mouse_scroll_down(self, event) -> None:
915
1005
  """Load more rows when scrolling down with mouse."""
916
- self.check_and_load_more()
1006
+ self.load_rows_down()
917
1007
 
918
1008
  # Setup & Loading
919
- def setup_table(self, reset: bool = False) -> None:
1009
+ def reset_df(self, new_df: pl.DataFrame, dirty: bool = True) -> None:
1010
+ """Reset the dataframe to a new one and refresh the table.
1011
+
1012
+ Args:
1013
+ new_df: The new Polars DataFrame to set.
1014
+ dirty: Whether to mark the table as dirty (unsaved changes). Defaults to True.
1015
+ """
1016
+ # Set new dataframe and reset table
1017
+ self.df = new_df
1018
+ self.loaded_rows = 0
1019
+ self.hidden_columns = set()
1020
+ self.selected_rows = set()
1021
+ self.sorted_columns = {}
1022
+ self.fixed_rows = 0
1023
+ self.fixed_columns = 0
1024
+ self.matches = defaultdict(set)
1025
+ # self.histories.clear()
1026
+ # self.histories2.clear()
1027
+ self.dirty = dirty # Mark as dirty since data changed
1028
+
1029
+ def setup_table(self) -> None:
920
1030
  """Setup the table for display.
921
1031
 
922
1032
  Row keys are 0-based indices, which map directly to dataframe row indices.
923
1033
  Column keys are header names from the dataframe.
924
1034
  """
925
1035
  self.loaded_rows = 0
1036
+ self.loaded_ranges.clear()
926
1037
  self.show_row_labels = True
927
1038
 
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
1039
  # Save current cursor position before clearing
960
1040
  row_idx, col_idx = self.cursor_coordinate
961
1041
 
962
1042
  self.setup_columns()
963
- self.load_rows(stop)
1043
+ self.load_rows_range(0, self.BATCH_SIZE) # Load initial rows
964
1044
 
965
1045
  # Restore cursor position
966
1046
  if row_idx < len(self.rows) and col_idx < len(self.columns):
@@ -980,20 +1060,20 @@ class DataFrameTable(DataTable):
980
1060
  Returns:
981
1061
  dict[str, int]: Mapping of column name to width (None for auto-sizing columns).
982
1062
  """
983
- column_widths = {}
1063
+ col_widths, col_label_widths = {}, {}
984
1064
 
985
1065
  # Get available width for the table (with some padding for borders/scrollbar)
986
- available_width = self.size.width - 4 # Account for borders and scrollbar
1066
+ available_width = self.scrollable_content_region.width
987
1067
 
988
1068
  # Calculate how much width we need for string columns first
989
1069
  string_cols = [col for col, dtype in zip(self.df.columns, self.df.dtypes) if dtype == pl.String]
990
1070
 
991
1071
  # No string columns, let TextualDataTable auto-size all columns
992
1072
  if not string_cols:
993
- return column_widths
1073
+ return col_widths
994
1074
 
995
1075
  # 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))
1076
+ sample_size = min(self.BATCH_SIZE, len(self.df))
997
1077
  sample_lf = self.df.lazy().slice(0, sample_size)
998
1078
 
999
1079
  # Determine widths for each column
@@ -1004,37 +1084,42 @@ class DataFrameTable(DataTable):
1004
1084
  # Get column label width
1005
1085
  # Add padding for sort indicators if any
1006
1086
  label_width = measure(self.app.console, col, 1) + 2
1087
+ col_label_widths[col] = label_width
1088
+
1089
+ # Let Textual auto-size for non-string columns and already expanded columns
1090
+ if dtype != pl.String or col in self.expanded_columns:
1091
+ available_width -= label_width
1092
+ continue
1007
1093
 
1008
1094
  try:
1009
1095
  # Get sample values from the column
1010
- sample_values = sample_lf.select(col).collect().get_column(col).to_list()
1096
+ sample_values = sample_lf.select(col).collect().get_column(col).drop_nulls().to_list()
1011
1097
  if any(val.startswith(("https://", "http://")) for val in sample_values):
1012
1098
  continue # Skip link columns so they can auto-size and be clickable
1013
1099
 
1014
1100
  # Find maximum width in sample
1015
1101
  max_cell_width = max(
1016
- (measure(self.app.console, str(val), 1) for val in sample_values if val),
1102
+ (measure(self.app.console, val, 1) for val in sample_values),
1017
1103
  default=label_width,
1018
1104
  )
1019
1105
 
1020
1106
  # Set column width to max of label and sampled data (capped at reasonable max)
1021
1107
  max_width = max(label_width, max_cell_width)
1022
- except Exception:
1108
+ except Exception as e:
1023
1109
  # If any error, let Textual auto-size
1024
1110
  max_width = label_width
1111
+ self.log(f"Error determining width for column '{col}': {e}")
1025
1112
 
1026
- if dtype == pl.String:
1027
- column_widths[col] = max_width
1028
-
1113
+ col_widths[col] = max_width
1029
1114
  available_width -= max_width
1030
1115
 
1031
1116
  # If there's no more available width, auto-size remaining columns
1032
1117
  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
1118
+ for col in col_widths:
1119
+ if col_widths[col] > STRING_WIDTH_CAP and col_label_widths[col] < STRING_WIDTH_CAP:
1120
+ col_widths[col] = STRING_WIDTH_CAP # Cap string columns
1036
1121
 
1037
- return column_widths
1122
+ return col_widths
1038
1123
 
1039
1124
  def setup_columns(self) -> None:
1040
1125
  """Clear table and setup columns.
@@ -1049,8 +1134,8 @@ class DataFrameTable(DataTable):
1049
1134
 
1050
1135
  # Add columns with justified headers
1051
1136
  for col, dtype in zip(self.df.columns, self.df.dtypes):
1052
- if col in self.hidden_columns:
1053
- continue # Skip hidden columns
1137
+ if col in self.hidden_columns or (col == RID and not self.show_rid):
1138
+ continue # Skip hidden columns and internal RID
1054
1139
  for idx, c in enumerate(self.sorted_columns, 1):
1055
1140
  if c == col:
1056
1141
  # Add sort indicator to column header
@@ -1068,178 +1153,393 @@ class DataFrameTable(DataTable):
1068
1153
 
1069
1154
  self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
1070
1155
 
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).
1156
+ def _calculate_load_range(self, start: int, stop: int) -> list[tuple[int, int]]:
1157
+ """Calculate the actual ranges to load, accounting for already-loaded ranges.
1158
+
1159
+ Handles complex cases where a loaded range is fully contained within the requested
1160
+ range (creating head and tail segments to load). All overlapping/adjacent loaded
1161
+ ranges are merged first to minimize gaps.
1073
1162
 
1074
1163
  Args:
1075
- stop: Stop loading rows when this index is reached.
1076
- If None, load until the end of the dataframe.
1164
+ start: Requested start index (0-based).
1165
+ stop: Requested stop index (0-based, exclusive).
1166
+
1167
+ Returns:
1168
+ List of (actual_start, actual_stop) tuples to load. Empty list if the entire
1169
+ requested range is already loaded.
1170
+
1171
+ Example:
1172
+ If loaded ranges are [(150, 250)] and requesting (100, 300):
1173
+ - Returns [(100, 150), (250, 300)] to load head and tail
1174
+ If loaded ranges are [(0, 100), (100, 200)] and requesting (50, 150):
1175
+ - After merging, loaded_ranges becomes [(0, 200)]
1176
+ - Returns [] (already fully loaded)
1077
1177
  """
1078
- if stop is None or stop > len(self.df):
1079
- stop = len(self.df)
1178
+ if not self.loaded_ranges:
1179
+ return [(start, stop)]
1180
+
1181
+ # Sort loaded ranges by start index
1182
+ sorted_ranges = sorted(self.loaded_ranges)
1183
+
1184
+ # Merge overlapping/adjacent ranges
1185
+ merged = []
1186
+ for range_start, range_stop in sorted_ranges:
1187
+ # Fully covered, no need to load anything
1188
+ if range_start <= start and range_stop >= stop:
1189
+ return []
1190
+ # Overlapping or adjacent: merge
1191
+ elif merged and range_start <= merged[-1][1]:
1192
+ merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
1193
+ else:
1194
+ merged.append((range_start, range_stop))
1195
+
1196
+ self.loaded_ranges = merged
1197
+
1198
+ # Calculate ranges to load by finding gaps in the merged ranges
1199
+ ranges_to_load = []
1200
+ current_pos = start
1201
+
1202
+ for range_start, range_stop in merged:
1203
+ # If there's a gap before this loaded range, add it to load list
1204
+ if current_pos < range_start and current_pos < stop:
1205
+ gap_end = min(range_start, stop)
1206
+ ranges_to_load.append((current_pos, gap_end))
1207
+ current_pos = range_stop
1208
+ elif current_pos >= range_stop:
1209
+ # Already moved past this loaded range
1210
+ continue
1211
+ else:
1212
+ # Current position is inside this loaded range, skip past it
1213
+ current_pos = max(current_pos, range_stop)
1214
+
1215
+ # If there's remaining range after all loaded ranges, add it
1216
+ if current_pos < stop:
1217
+ ranges_to_load.append((current_pos, stop))
1218
+
1219
+ return ranges_to_load
1080
1220
 
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)
1221
+ def _merge_loaded_ranges(self) -> None:
1222
+ """Merge adjacent and overlapping ranges in self.loaded_ranges.
1085
1223
 
1224
+ Ranges like (0, 100) and (100, 200) are merged into (0, 200).
1225
+ """
1226
+ if len(self.loaded_ranges) <= 1:
1086
1227
  return
1087
1228
 
1088
- # Warn user if loading a large number of rows
1089
- elif (nrows := stop - self.loaded_rows) >= WARN_ROWS_THRESHOLD:
1229
+ # Sort by start index
1230
+ sorted_ranges = sorted(self.loaded_ranges)
1090
1231
 
1091
- def _continue(result: bool) -> None:
1092
- if result:
1093
- self.load_rows_async(stop, move_to_end=move_to_end)
1232
+ # Merge overlapping/adjacent ranges
1233
+ merged = [sorted_ranges[0]]
1234
+ for range_start, range_stop in sorted_ranges[1:]:
1235
+ # Overlapping or adjacent: merge
1236
+ if range_start <= merged[-1][1]:
1237
+ merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
1238
+ else:
1239
+ merged.append((range_start, range_stop))
1094
1240
 
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
- )
1241
+ self.loaded_ranges = merged
1102
1242
 
1103
- return
1243
+ def _find_insert_position_for_row(self, ridx: int) -> int:
1244
+ """Find the correct table position to insert a row with the given dataframe index.
1245
+
1246
+ In the table display, rows are ordered by their dataframe index, regardless of
1247
+ the internal row keys. This method finds where a row should be inserted based on
1248
+ its dataframe index and the indices of already-loaded rows.
1249
+
1250
+ Args:
1251
+ ridx: The 0-based dataframe row index.
1252
+
1253
+ Returns:
1254
+ The 0-based table position where the row should be inserted.
1255
+ """
1256
+ # Count how many already-loaded rows have lower dataframe indices
1257
+ # Iterate through loaded rows instead of iterating 0..ridx for efficiency
1258
+ insert_pos = 0
1259
+ for row_key in self._row_locations:
1260
+ loaded_ridx = int(row_key.value)
1261
+ if loaded_ridx < ridx:
1262
+ insert_pos += 1
1104
1263
 
1105
- # Load rows asynchronously
1106
- self.load_rows_async(stop, move_to_end=move_to_end)
1264
+ return insert_pos
1107
1265
 
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.
1266
+ def load_rows_segment(self, segment_start: int, segment_stop: int) -> int:
1267
+ """Load a single contiguous segment of rows into the table.
1268
+
1269
+ This is the core loading logic that inserts rows at correct positions,
1270
+ respecting visibility and selection states. Used by load_rows_range()
1271
+ to handle each segment independently.
1111
1272
 
1112
1273
  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.
1274
+ segment_start: Start loading rows from this index (0-based).
1275
+ segment_stop: Stop loading rows when this index is reached (0-based, exclusive).
1115
1276
  """
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
1277
+ # Record this range before loading
1278
+ self.loaded_ranges.append((segment_start, segment_stop))
1279
+
1280
+ # Load the dataframe slice
1281
+ df_slice = self.df.slice(segment_start, segment_stop - segment_start)
1282
+
1283
+ # Load each row at the correct position
1284
+ for (ridx, row), rid in zip(enumerate(df_slice.rows(), segment_start), df_slice[RID]):
1285
+ is_selected = rid in self.selected_rows
1286
+ match_cols = self.matches.get(rid, set())
1287
+
1288
+ vals, dtypes, styles = [], [], []
1289
+ for val, col, dtype in zip(row, self.df.columns, self.df.dtypes, strict=True):
1290
+ if col in self.hidden_columns or (col == RID and not self.show_rid):
1291
+ continue # Skip hidden columns and internal RID
1292
+
1293
+ vals.append(val)
1294
+ dtypes.append(dtype)
1295
+
1296
+ # Highlight entire row with selection or cells with matches
1297
+ styles.append(HIGHLIGHT_COLOR if is_selected or col in match_cols else None)
1130
1298
 
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))
1299
+ formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
1134
1300
 
1135
- def load_rows_batch(self, stop: int) -> None:
1301
+ # Find correct insertion position and insert
1302
+ insert_pos = self._find_insert_position_for_row(ridx)
1303
+ self.insert_row(*formatted_row, key=str(ridx), label=str(ridx + 1), position=insert_pos)
1304
+
1305
+ # Number of rows loaded in this segment
1306
+ segment_count = len(df_slice)
1307
+
1308
+ # Update loaded rows count
1309
+ self.loaded_rows += segment_count
1310
+
1311
+ return segment_count
1312
+
1313
+ def load_rows_range(self, start: int, stop: int) -> int:
1136
1314
  """Load a batch of rows into the table.
1137
1315
 
1138
1316
  Row keys are 0-based indices as strings, which map directly to dataframe row indices.
1139
1317
  Row labels are 1-based indices as strings.
1140
1318
 
1319
+ Intelligently handles range loading:
1320
+ 1. Calculates which ranges actually need loading (avoiding reloading)
1321
+ 2. Handles complex cases where loaded ranges create "holes" (head and tail segments)
1322
+ 3. Inserts rows at correct positions in the table
1323
+ 4. Merges adjacent/overlapping ranges to optimize future loading
1324
+
1141
1325
  Args:
1142
- stop: Stop loading rows when this index is reached.
1326
+ start: Start loading rows from this index (0-based).
1327
+ stop: Stop loading rows when this index is reached (0-based, exclusive).
1143
1328
  """
1329
+ start = max(0, start) # Clamp to non-negative
1330
+ stop = min(stop, len(self.df)) # Clamp to dataframe length
1331
+
1144
1332
  try:
1145
- start = self.loaded_rows
1146
- df_slice = self.df.slice(start, stop - start)
1333
+ # Calculate actual ranges to load, accounting for already-loaded ranges
1334
+ ranges_to_load = self._calculate_load_range(start, stop)
1147
1335
 
1148
- for ridx, row in enumerate(df_slice.rows(), start):
1149
- if not self.visible_rows[ridx]:
1150
- continue # Skip hidden rows
1336
+ # If nothing needs loading, return early
1337
+ if not ranges_to_load:
1338
+ return 0 # Already loaded
1151
1339
 
1152
- is_selected = self.selected_rows[ridx]
1153
- match_cols = self.matches.get(ridx, set())
1340
+ # Track the number of loaded rows in this range
1341
+ range_count = 0
1154
1342
 
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
1343
+ # Load each segment
1344
+ for segment_start, segment_stop in ranges_to_load:
1345
+ range_count += self.load_rows_segment(segment_start, segment_stop)
1159
1346
 
1160
- vals.append(val)
1161
- dtypes.append(dtype)
1347
+ # Merge adjacent/overlapping ranges to optimize storage
1348
+ self._merge_loaded_ranges()
1162
1349
 
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)
1350
+ self.log(f"Loaded {range_count} rows for range {start}-{stop}/{len(self.df)}")
1351
+ return range_count
1165
1352
 
1166
- formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
1353
+ except Exception as e:
1354
+ self.notify("Error loading rows", title="Load Rows", severity="error", timeout=10)
1355
+ self.log(f"Error loading rows: {str(e)}")
1356
+ return 0
1167
1357
 
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))
1358
+ def load_rows_up(self) -> None:
1359
+ """Check if we need to load more rows and load them."""
1360
+ # If we've loaded everything, no need to check
1361
+ if self.loaded_rows >= len(self.df):
1362
+ return
1170
1363
 
1171
- # Update loaded rows count
1172
- self.loaded_rows = stop
1364
+ top_row_index = int(self.scroll_y) + BUFFER_SIZE
1365
+ top_row_key = self.get_row_key(top_row_index)
1173
1366
 
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}`")
1367
+ if top_row_key:
1368
+ top_ridx = int(top_row_key.value)
1369
+ else:
1370
+ top_ridx = 0 # No top row key at index, default to 0
1176
1371
 
1177
- except Exception as e:
1178
- self.notify("Error loading rows", title="Load", severity="error")
1179
- self.log(f"Error loading rows: {str(e)}")
1372
+ # Load upward
1373
+ start, stop = self._round_to_nearest_hundreds(top_ridx - BUFFER_SIZE * 2)
1374
+ range_count = self.load_rows_range(start, stop)
1375
+
1376
+ # Adjust scroll to maintain position if rows were loaded above
1377
+ if range_count > 0:
1378
+ self.move_cursor(row=top_row_index + range_count)
1379
+ self.log(f"Loaded up: {range_count} rows in range {start}-{stop}/{len(self.df)}")
1180
1380
 
1181
- def check_and_load_more(self) -> None:
1381
+ def load_rows_down(self) -> None:
1182
1382
  """Check if we need to load more rows and load them."""
1183
1383
  # If we've loaded everything, no need to check
1184
1384
  if self.loaded_rows >= len(self.df):
1185
1385
  return
1186
1386
 
1187
- visible_row_count = self.size.height - self.header_height
1188
- bottom_visible_row = self.scroll_y + visible_row_count
1387
+ visible_row_count = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
1388
+ bottom_row_index = self.scroll_y + visible_row_count - BUFFER_SIZE
1389
+
1390
+ bottom_row_key = self.get_row_key(bottom_row_index)
1391
+ if bottom_row_key:
1392
+ bottom_ridx = int(bottom_row_key.value)
1393
+ else:
1394
+ bottom_ridx = 0 # No bottom row key at index, default to 0
1395
+
1396
+ # Load downward
1397
+ start, stop = self._round_to_nearest_hundreds(bottom_ridx + BUFFER_SIZE * 2)
1398
+ range_count = self.load_rows_range(start, stop)
1399
+
1400
+ if range_count > 0:
1401
+ self.log(f"Loaded down: {range_count} rows in range {start}-{stop}/{len(self.df)}")
1189
1402
 
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)
1403
+ def insert_row(
1404
+ self,
1405
+ *cells: CellType,
1406
+ height: int | None = 1,
1407
+ key: str | None = None,
1408
+ label: TextType | None = None,
1409
+ position: int | None = None,
1410
+ ) -> RowKey:
1411
+ """Insert a row at a specific position in the DataTable.
1193
1412
 
1194
- # Highlighting
1195
- def apply_highlight(self, force: bool = False) -> None:
1196
- """Update all rows, highlighting selected ones and restoring others to default.
1413
+ When inserting, all rows at and after the insertion position are shifted down,
1414
+ and their entries in self._row_locations are updated accordingly.
1197
1415
 
1198
1416
  Args:
1199
- force: If True, clear all highlights and restore default styles.
1417
+ *cells: Positional arguments should contain cell data.
1418
+ height: The height of a row (in lines). Use `None` to auto-detect the optimal
1419
+ height.
1420
+ key: A key which uniquely identifies this row. If None, it will be generated
1421
+ for you and returned.
1422
+ label: The label for the row. Will be displayed to the left if supplied.
1423
+ position: The 0-based row index where the new row should be inserted.
1424
+ If None, inserts at the end (same as add_row). If out of bounds,
1425
+ inserts at the nearest valid position.
1426
+
1427
+ Returns:
1428
+ Unique identifier for this row. Can be used to retrieve this row regardless
1429
+ of its current location in the DataTable (it could have moved after
1430
+ being added due to sorting or insertion/deletion of other rows).
1431
+
1432
+ Raises:
1433
+ DuplicateKey: If a row with the given key already exists.
1434
+ ValueError: If more cells are provided than there are columns.
1200
1435
  """
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)
1436
+ # Default to appending if position not specified or >= row_count
1437
+ row_count = self.row_count
1438
+ if position is None or position >= row_count:
1439
+ return self.add_row(*cells, height=height, key=key, label=label)
1440
+
1441
+ # Clamp position to valid range [0, row_count)
1442
+ position = max(0, position)
1443
+
1444
+ row_key = RowKey(key)
1445
+ if row_key in self._row_locations:
1446
+ raise DuplicateKey(f"The row key {row_key!r} already exists.")
1447
+
1448
+ if len(cells) > len(self.ordered_columns):
1449
+ raise ValueError("More values provided than there are columns.")
1450
+
1451
+ # TC: Rebuild self._row_locations to shift rows at and after position down by 1
1452
+ # Create a mapping of old index -> new index
1453
+ old_to_new = {}
1454
+ for old_idx in range(row_count):
1455
+ if old_idx < position:
1456
+ old_to_new[old_idx] = old_idx # No change
1457
+ else:
1458
+ old_to_new[old_idx] = old_idx + 1 # Shift down by 1
1459
+
1460
+ # Update _row_locations with the new indices
1461
+ new_row_locations = TwoWayDict({})
1462
+ for row_key_item in self._row_locations:
1463
+ old_idx = self.get_row_idx(row_key_item)
1464
+ new_idx = old_to_new.get(old_idx, old_idx)
1465
+ new_row_locations[row_key_item] = new_idx
1466
+
1467
+ # Update the internal mapping
1468
+ self._row_locations = new_row_locations
1469
+ # TC
1470
+
1471
+ row_index = position
1472
+ # Map the key of this row to its current index
1473
+ self._row_locations[row_key] = row_index
1474
+ self._data[row_key] = {column.key: cell for column, cell in zip_longest(self.ordered_columns, cells)}
1475
+
1476
+ label = Text.from_markup(label, end="") if isinstance(label, str) else label
1477
+
1478
+ # Rows with auto-height get a height of 0 because 1) we need an integer height
1479
+ # to do some intermediate computations and 2) because 0 doesn't impact the data
1480
+ # table while we don't figure out how tall this row is.
1481
+ self.rows[row_key] = Row(
1482
+ row_key,
1483
+ height or 0,
1484
+ label,
1485
+ height is None,
1486
+ )
1487
+ self._new_rows.add(row_key)
1488
+ self._require_update_dimensions = True
1489
+ self.cursor_coordinate = self.cursor_coordinate
1490
+
1491
+ # If a position has opened for the cursor to appear, where it previously
1492
+ # could not (e.g. when there's no data in the table), then a highlighted
1493
+ # event is posted, since there's now a highlighted cell when there wasn't
1494
+ # before.
1495
+ cell_now_available = self.row_count == 1 and len(self.columns) > 0
1496
+ visible_cursor = self.show_cursor and self.cursor_type != "none"
1497
+ if cell_now_available and visible_cursor:
1498
+ self._highlight_cursor()
1499
+
1500
+ self._update_count += 1
1501
+ self.check_idle()
1502
+ return row_key
1503
+
1504
+ # Navigation
1505
+ def do_jump_top(self) -> None:
1506
+ """Jump to the top of the table."""
1507
+ self.move_cursor(row=0)
1508
+
1509
+ def do_jump_bottom(self) -> None:
1510
+ """Jump to the bottom of the table."""
1511
+ stop = len(self.df)
1512
+ start = max(0, stop - self.BATCH_SIZE)
1513
+
1514
+ if start % self.BATCH_SIZE != 0:
1515
+ start = (start // self.BATCH_SIZE + 1) * self.BATCH_SIZE
1516
+
1517
+ if stop - start < self.BATCH_SIZE:
1518
+ start -= self.BATCH_SIZE
1519
+
1520
+ self.load_rows_range(start, stop)
1521
+ self.move_cursor(row=self.row_count - 1)
1522
+
1523
+ def do_page_up(self) -> None:
1524
+ """Move the cursor one page up."""
1525
+ self._set_hover_cursor(False)
1526
+ if self.show_cursor and self.cursor_type in ("cell", "row"):
1527
+ height = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
1528
+
1529
+ col_idx = self.cursor_column
1530
+ ridx = self.cursor_row_idx
1531
+ next_ridx = max(0, ridx - height - BUFFER_SIZE)
1532
+ start, stop = self._round_to_nearest_hundreds(next_ridx)
1533
+ self.load_rows_range(start, stop)
1534
+
1535
+ self.move_cursor(row=self.get_row_idx(str(next_ridx)), column=col_idx)
1536
+ else:
1537
+ super().action_page_up()
1538
+
1539
+ def do_page_down(self) -> None:
1540
+ """Move the cursor one page down."""
1541
+ super().action_page_down()
1542
+ self.load_rows_down()
1243
1543
 
1244
1544
  # History & Undo
1245
1545
  def create_history(self, description: str) -> None:
@@ -1247,16 +1547,15 @@ class DataFrameTable(DataTable):
1247
1547
  return History(
1248
1548
  description=description,
1249
1549
  df=self.df,
1550
+ df_view=self.df_view,
1250
1551
  filename=self.filename,
1251
- loaded_rows=self.loaded_rows,
1252
- sorted_columns=self.sorted_columns.copy(),
1253
1552
  hidden_columns=self.hidden_columns.copy(),
1254
1553
  selected_rows=self.selected_rows.copy(),
1255
- visible_rows=self.visible_rows.copy(),
1554
+ sorted_columns=self.sorted_columns.copy(),
1555
+ matches={k: v.copy() for k, v in self.matches.items()},
1256
1556
  fixed_rows=self.fixed_rows,
1257
1557
  fixed_columns=self.fixed_columns,
1258
1558
  cursor_coordinate=self.cursor_coordinate,
1259
- matches={k: v.copy() for k, v in self.matches.items()},
1260
1559
  dirty=self.dirty,
1261
1560
  )
1262
1561
 
@@ -1267,30 +1566,32 @@ class DataFrameTable(DataTable):
1267
1566
 
1268
1567
  # Restore state
1269
1568
  self.df = history.df
1569
+ self.df_view = history.df_view
1270
1570
  self.filename = history.filename
1271
- self.loaded_rows = history.loaded_rows
1272
- self.sorted_columns = history.sorted_columns.copy()
1273
1571
  self.hidden_columns = history.hidden_columns.copy()
1274
1572
  self.selected_rows = history.selected_rows.copy()
1275
- self.visible_rows = history.visible_rows.copy()
1573
+ self.sorted_columns = history.sorted_columns.copy()
1574
+ self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
1276
1575
  self.fixed_rows = history.fixed_rows
1277
1576
  self.fixed_columns = history.fixed_columns
1278
1577
  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
1578
  self.dirty = history.dirty
1281
1579
 
1282
1580
  # Recreate table for display
1283
1581
  self.setup_table()
1284
1582
 
1285
- def add_history(self, description: str, dirty: bool = False) -> None:
1583
+ def add_history(self, description: str, dirty: bool = False, clear_redo: bool = True) -> None:
1286
1584
  """Add the current state to the history stack.
1287
1585
 
1288
1586
  Args:
1289
1587
  description: Description of the action for this history entry.
1290
1588
  dirty: Whether this operation modifies the data (True) or just display state (False).
1291
1589
  """
1292
- history = self.create_history(description)
1293
- self.histories.append(history)
1590
+ self.histories_undo.append(self.create_history(description))
1591
+
1592
+ # Clear redo stack when a new action is performed
1593
+ if clear_redo:
1594
+ self.histories_redo.clear()
1294
1595
 
1295
1596
  # Mark table as dirty if this operation modifies data
1296
1597
  if dirty:
@@ -1298,15 +1599,13 @@ class DataFrameTable(DataTable):
1298
1599
 
1299
1600
  def do_undo(self) -> None:
1300
1601
  """Undo the last action."""
1301
- if not self.histories:
1302
- self.notify("No actions to undo", title="Undo", severity="warning")
1602
+ if not self.histories_undo:
1603
+ # self.notify("No actions to undo", title="Undo", severity="warning")
1303
1604
  return
1304
1605
 
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)
1606
+ # Pop the last history state for undo and save to redo stack
1607
+ history = self.histories_undo.pop()
1608
+ self.histories_redo.append(self.create_history(history.description))
1310
1609
 
1311
1610
  # Restore state
1312
1611
  self.apply_history(history)
@@ -1315,42 +1614,35 @@ class DataFrameTable(DataTable):
1315
1614
 
1316
1615
  def do_redo(self) -> None:
1317
1616
  """Redo the last undone action."""
1318
- if self.history is None:
1319
- self.notify("No actions to redo", title="Redo", severity="warning")
1617
+ if not self.histories_redo:
1618
+ # self.notify("No actions to redo", title="Redo", severity="warning")
1320
1619
  return
1321
1620
 
1322
- description = self.history.description
1621
+ # Pop the last undone state from redo stack
1622
+ history = self.histories_redo.pop()
1623
+ description = history.description
1323
1624
 
1324
1625
  # Save current state for undo
1325
- self.add_history(description)
1626
+ self.add_history(description, clear_redo=False)
1326
1627
 
1327
1628
  # Restore state
1328
- self.apply_history(self.history)
1329
-
1330
- # Clear redo state
1331
- self.history = None
1629
+ self.apply_history(history)
1332
1630
 
1333
1631
  self.notify(f"Reapplied: {description}", title="Redo")
1334
1632
 
1335
1633
  def do_reset(self) -> None:
1336
1634
  """Reset the table to the initial state."""
1337
- self.setup_table(reset=True)
1635
+ self.reset_df(self.dataframe, dirty=False)
1636
+ self.setup_table()
1338
1637
  self.notify("Restored initial state", title="Reset")
1339
1638
 
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
1639
  # Display
1348
1640
  def do_cycle_cursor_type(self) -> None:
1349
1641
  """Cycle through cursor types: cell -> row -> column -> cell."""
1350
1642
  next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
1351
1643
  self.cursor_type = next_type
1352
1644
 
1353
- # self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
1645
+ # self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cycle Cursor Type")
1354
1646
 
1355
1647
  def do_view_row_detail(self) -> None:
1356
1648
  """Open a modal screen to view the selected row's details."""
@@ -1380,6 +1672,14 @@ class DataFrameTable(DataTable):
1380
1672
  cidx = self.cursor_col_idx
1381
1673
  self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
1382
1674
 
1675
+ def do_metadata_shape(self) -> None:
1676
+ """Show metadata about the dataframe (row and column counts)."""
1677
+ self.app.push_screen(MetaShape(self))
1678
+
1679
+ def do_metadata_column(self) -> None:
1680
+ """Show metadata for all columns in the dataframe."""
1681
+ self.app.push_screen(MetaColumnScreen(self))
1682
+
1383
1683
  def do_freeze_row_column(self) -> None:
1384
1684
  """Open the freeze screen to set fixed rows and columns."""
1385
1685
  self.app.push_screen(FreezeScreen(), callback=self.freeze_row_column)
@@ -1396,7 +1696,7 @@ class DataFrameTable(DataTable):
1396
1696
  fixed_rows, fixed_columns = result
1397
1697
 
1398
1698
  # Add to history
1399
- self.add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
1699
+ self.add_history(f"Pinned [$success]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns")
1400
1700
 
1401
1701
  # Apply the pin settings to the table
1402
1702
  if fixed_rows >= 0:
@@ -1404,7 +1704,7 @@ class DataFrameTable(DataTable):
1404
1704
  if fixed_columns >= 0:
1405
1705
  self.fixed_columns = fixed_columns
1406
1706
 
1407
- # self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
1707
+ # self.notify(f"Pinned [$success]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns", title="Pin Row/Column")
1408
1708
 
1409
1709
  def do_hide_column(self) -> None:
1410
1710
  """Hide the currently selected column from the table display."""
@@ -1425,7 +1725,9 @@ class DataFrameTable(DataTable):
1425
1725
  if col_idx >= len(self.columns):
1426
1726
  self.move_cursor(column=len(self.columns) - 1)
1427
1727
 
1428
- # self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
1728
+ # self.notify(
1729
+ # f"Hid column [$success]{col_name}[/]. Press [$accent]H[/] to show hidden columns", title="Hide Column"
1730
+ # )
1429
1731
 
1430
1732
  def do_expand_column(self) -> None:
1431
1733
  """Expand the current column to show the widest cell in the loaded data."""
@@ -1438,58 +1740,78 @@ class DataFrameTable(DataTable):
1438
1740
  if dtype != pl.String:
1439
1741
  return
1440
1742
 
1743
+ # The column to expand/shrink
1744
+ col: Column = self.columns[col_key]
1745
+
1441
1746
  # Calculate the maximum width across all loaded rows
1442
- max_width = len(col_name) + 2 # Start with column name width + padding
1747
+ label_width = len(col_name) + 2 # Start with column name width + padding
1443
1748
 
1444
1749
  try:
1750
+ need_expand = False
1751
+ max_width = label_width
1752
+
1445
1753
  # Scan through all loaded rows that are visible to find max width
1446
1754
  for row_idx in range(self.loaded_rows):
1447
- if not self.visible_rows[row_idx]:
1448
- continue # Skip hidden rows
1449
1755
  cell_value = str(self.df.item(row_idx, col_idx))
1450
1756
  cell_width = measure(self.app.console, cell_value, 1)
1757
+
1758
+ if cell_width > max_width:
1759
+ need_expand = True
1451
1760
  max_width = max(max_width, cell_width)
1452
1761
 
1453
- # Update the column width
1454
- col = self.columns[col_key]
1455
- col.width = max_width
1762
+ if not need_expand:
1763
+ return
1764
+
1765
+ if col_name in self.expanded_columns:
1766
+ col.width = max(label_width, STRING_WIDTH_CAP)
1767
+ self.expanded_columns.remove(col_name)
1768
+ else:
1769
+ self.expanded_columns.add(col_name)
1456
1770
 
1457
- # Force a refresh
1458
- self._update_count += 1
1459
- self._require_update_dimensions = True
1460
- self.refresh(layout=True)
1771
+ # Update the column width
1772
+ col.width = max_width
1461
1773
 
1462
- # self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
1463
1774
  except Exception as e:
1464
- self.notify("Error expanding column", title="Expand", severity="error")
1775
+ self.notify(
1776
+ f"Error expanding column [$error]{col_name}[/]", title="Expand Column", severity="error", timeout=10
1777
+ )
1465
1778
  self.log(f"Error expanding column `{col_name}`: {str(e)}")
1466
1779
 
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)
1780
+ # Force a refresh
1781
+ self._update_count += 1
1782
+ self._require_update_dimensions = True
1783
+ self.refresh(layout=True)
1471
1784
 
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)
1785
+ # self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand Column")
1474
1786
 
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")
1787
+ def do_toggle_rid(self) -> None:
1788
+ """Toggle display of the internal RID column."""
1789
+ self.show_rid = not self.show_rid
1790
+
1791
+ # Recreate table for display
1792
+ self.setup_table()
1793
+
1794
+ def do_show_hidden_rows_columns(self) -> None:
1795
+ """Show all hidden rows/columns by recreating the table."""
1796
+ if not self.hidden_columns and self.df_view is None:
1797
+ # self.notify("No hidden rows or columns to show", title="Show Hidden Rows/Columns", severity="warning")
1477
1798
  return
1478
1799
 
1479
1800
  # Add to history
1480
1801
  self.add_history("Showed hidden rows/columns")
1481
1802
 
1803
+ # If in a filtered view, restore the full dataframe
1804
+ if self.df_view is not None:
1805
+ self.df = self.df_view
1806
+ self.df_view = None
1807
+
1482
1808
  # Clear hidden rows/columns tracking
1483
- self.visible_rows = [True] * len(self.df)
1484
1809
  self.hidden_columns.clear()
1485
1810
 
1486
1811
  # Recreate table for display
1487
1812
  self.setup_table()
1488
1813
 
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
- )
1814
+ # self.notify("Showed hidden row(s) and/or hidden column(s)", title="Show Hidden Rows/Columns")
1493
1815
 
1494
1816
  # Sort
1495
1817
  def do_sort_by_column(self, descending: bool = False) -> None:
@@ -1510,32 +1832,40 @@ class DataFrameTable(DataTable):
1510
1832
 
1511
1833
  # Add to history
1512
1834
  self.add_history(f"Sorted on column [$success]{col_name}[/]", dirty=True)
1835
+
1836
+ # New column - add to sort
1513
1837
  if old_desc is None:
1514
- # Add new column to sort
1515
1838
  self.sorted_columns[col_name] = descending
1839
+
1840
+ # Old column, same direction - remove from sort
1516
1841
  elif old_desc == descending:
1517
- # Same direction - remove from sort
1518
1842
  del self.sorted_columns[col_name]
1843
+
1844
+ # Old column, different direction - add to sort at end
1519
1845
  else:
1520
- # Move to end of sort order
1521
1846
  del self.sorted_columns[col_name]
1522
1847
  self.sorted_columns[col_name] = descending
1523
1848
 
1849
+ lf = self.df.lazy()
1850
+ sort_by = {}
1851
+
1524
1852
  # Apply multi-column sort
1525
1853
  if sort_cols := list(self.sorted_columns.keys()):
1526
1854
  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)
1855
+ sort_by = {"by": sort_cols, "descending": descending_flags, "nulls_last": True}
1528
1856
  else:
1529
- # No sort columns - restore original order
1530
- df_sorted = self.df.with_row_index(RIDX)
1857
+ # No sort - restore original order by adding a temporary index column
1858
+ sort_by = {"by": RID}
1531
1859
 
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]
1860
+ # Perform the sort
1861
+ df_sorted = lf.sort(**sort_by).collect()
1862
+
1863
+ # Also update df_view if applicable
1864
+ if self.df_view is not None:
1865
+ self.df_view = self.df_view.lazy().sort(**sort_by).collect()
1536
1866
 
1537
1867
  # Update the dataframe
1538
- self.df = df_sorted.drop(RIDX)
1868
+ self.df = df_sorted
1539
1869
 
1540
1870
  # Recreate table for display
1541
1871
  self.setup_table()
@@ -1582,6 +1912,17 @@ class DataFrameTable(DataTable):
1582
1912
  .alias(col_name)
1583
1913
  )
1584
1914
 
1915
+ # Also update the view if applicable
1916
+ if self.df_view is not None:
1917
+ # Get the RID value for this row in df_view
1918
+ ridx_view = self.df.item(ridx, self.df.columns.index(RID))
1919
+ self.df_view = self.df_view.with_columns(
1920
+ pl.when(pl.col(RID) == ridx_view)
1921
+ .then(pl.lit(new_value))
1922
+ .otherwise(pl.col(col_name))
1923
+ .alias(col_name)
1924
+ )
1925
+
1585
1926
  # Update the display
1586
1927
  cell_value = self.df.item(ridx, cidx)
1587
1928
  if cell_value is None:
@@ -1595,10 +1936,15 @@ class DataFrameTable(DataTable):
1595
1936
  col_key = col_name
1596
1937
  self.update_cell(row_key, col_key, formatted_value, update_width=True)
1597
1938
 
1598
- # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1939
+ # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit Cell")
1599
1940
  except Exception as e:
1600
- self.notify("Error updating cell", title="Edit", severity="error")
1601
- self.log(f"Error updating cell: {str(e)}")
1941
+ self.notify(
1942
+ f"Error updating cell ([$error]{ridx}[/], [$accent]{col_name}[/])",
1943
+ title="Edit Cell",
1944
+ severity="error",
1945
+ timeout=10,
1946
+ )
1947
+ self.log(f"Error updating cell ({ridx}, {col_name}): {str(e)}")
1602
1948
 
1603
1949
  def do_edit_column(self) -> None:
1604
1950
  """Open modal to edit the entire column with an expression."""
@@ -1627,7 +1973,9 @@ class DataFrameTable(DataTable):
1627
1973
  try:
1628
1974
  expr = validate_expr(term, self.df.columns, cidx)
1629
1975
  except Exception as e:
1630
- self.notify(f"Error validating expression [$error]{term}[/]", title="Edit", severity="error")
1976
+ self.notify(
1977
+ f"Error validating expression [$error]{term}[/]", title="Edit Column", severity="error", timeout=10
1978
+ )
1631
1979
  self.log(f"Error validating expression `{term}`: {str(e)}")
1632
1980
  return
1633
1981
 
@@ -1639,23 +1987,47 @@ class DataFrameTable(DataTable):
1639
1987
  expr = pl.lit(value)
1640
1988
  except Exception:
1641
1989
  self.notify(
1642
- f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1643
- title="Edit",
1990
+ f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
1991
+ title="Edit Column",
1644
1992
  severity="error",
1645
1993
  )
1646
1994
  expr = pl.lit(str(term))
1647
1995
 
1648
1996
  # Add to history
1649
- self.add_history(f"Edited column [$accent]{col_name}[/] with expression", dirty=True)
1997
+ self.add_history(f"Edited column [$success]{col_name}[/] with expression", dirty=True)
1650
1998
 
1651
1999
  try:
1652
2000
  # Apply the expression to the column
1653
- self.df = self.df.with_columns(expr.alias(col_name))
2001
+ self.df = self.df.lazy().with_columns(expr.alias(col_name)).collect()
2002
+
2003
+ # Also update the view if applicable
2004
+ # Update the value of col_name in df_view using the value of col_name from df based on RID mapping between them
2005
+ if self.df_view is not None:
2006
+ # Get updated column from df for rows that exist in df_view
2007
+ col_updated = f"^_{col_name}_^"
2008
+ col_exists = "^_exists_^"
2009
+ lf_updated = self.df.lazy().select(
2010
+ RID, pl.col(col_name).alias(col_updated), pl.lit(True).alias(col_exists)
2011
+ )
2012
+ # Join and use when/then/otherwise to handle all updates including NULLs
2013
+ self.df_view = (
2014
+ self.df_view.lazy()
2015
+ .join(lf_updated, on=RID, how="left")
2016
+ .with_columns(
2017
+ pl.when(pl.col(col_exists))
2018
+ .then(pl.col(col_updated))
2019
+ .otherwise(pl.col(col_name))
2020
+ .alias(col_name)
2021
+ )
2022
+ .drop(col_updated, col_exists)
2023
+ .collect()
2024
+ )
1654
2025
  except Exception as e:
1655
2026
  self.notify(
1656
2027
  f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
1657
- title="Edit",
2028
+ title="Edit Column",
1658
2029
  severity="error",
2030
+ timeout=10,
1659
2031
  )
1660
2032
  self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
1661
2033
  return
@@ -1663,12 +2035,12 @@ class DataFrameTable(DataTable):
1663
2035
  # Recreate table for display
1664
2036
  self.setup_table()
1665
2037
 
1666
- # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
2038
+ # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit Column")
1667
2039
 
1668
- def do_rename_column(self) -> None:
2040
+ def do_rename_column(self, col_idx: int | None) -> None:
1669
2041
  """Open modal to rename the selected column."""
1670
- col_name = self.cursor_col_name
1671
- col_idx = self.cursor_column
2042
+ col_idx = self.cursor_column if col_idx is None else col_idx
2043
+ col_name = self.get_col_key(col_idx).value
1672
2044
 
1673
2045
  # Push the rename column modal screen
1674
2046
  self.app.push_screen(
@@ -1690,19 +2062,30 @@ class DataFrameTable(DataTable):
1690
2062
  return
1691
2063
 
1692
2064
  # Add to history
1693
- self.add_history(f"Renamed column [$accent]{col_name}[/] to [$success]{new_name}[/]", dirty=True)
2065
+ self.add_history(f"Renamed column [$success]{col_name}[/] to [$accent]{new_name}[/]", dirty=True)
1694
2066
 
1695
2067
  # Rename the column in the dataframe
1696
2068
  self.df = self.df.rename({col_name: new_name})
1697
2069
 
1698
- # Update sorted_columns if this column was sorted
2070
+ # Also update the view if applicable
2071
+ if self.df_view is not None:
2072
+ self.df_view = self.df_view.rename({col_name: new_name})
2073
+
2074
+ # Update sorted_columns if this column was sorted and maintain order
1699
2075
  if col_name in self.sorted_columns:
1700
- self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
2076
+ sorted_columns = {}
2077
+ for col, order in self.sorted_columns.items():
2078
+ if col == col_name:
2079
+ sorted_columns[new_name] = order
2080
+ else:
2081
+ sorted_columns[col] = order
2082
+ self.sorted_columns = sorted_columns
1701
2083
 
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)
2084
+ # Update matches if this column had cell matches
2085
+ for cols in self.matches.values():
2086
+ if col_name in cols:
2087
+ cols.remove(col_name)
2088
+ cols.add(new_name)
1706
2089
 
1707
2090
  # Recreate table for display
1708
2091
  self.setup_table()
@@ -1710,7 +2093,7 @@ class DataFrameTable(DataTable):
1710
2093
  # Move cursor to the renamed column
1711
2094
  self.move_cursor(column=col_idx)
1712
2095
 
1713
- # self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
2096
+ # self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Rename Column")
1714
2097
 
1715
2098
  def do_clear_cell(self) -> None:
1716
2099
  """Clear the current cell by setting its value to None."""
@@ -1731,6 +2114,13 @@ class DataFrameTable(DataTable):
1731
2114
  .alias(col_name)
1732
2115
  )
1733
2116
 
2117
+ # Also update the view if applicable
2118
+ if self.df_view is not None:
2119
+ ridx_view = self.df.item(ridx, self.df.columns.index(RID))
2120
+ self.df_view = self.df_view.with_columns(
2121
+ pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2122
+ )
2123
+
1734
2124
  # Update the display
1735
2125
  dtype = self.df.dtypes[cidx]
1736
2126
  dc = DtypeConfig(dtype)
@@ -1738,36 +2128,38 @@ class DataFrameTable(DataTable):
1738
2128
 
1739
2129
  self.update_cell(row_key, col_key, formatted_value)
1740
2130
 
1741
- # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
2131
+ # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear Cell")
1742
2132
  except Exception as e:
1743
- self.notify("Error clearing cell", title="Clear", severity="error")
1744
- self.log(f"Error clearing cell: {str(e)}")
2133
+ self.notify(
2134
+ f"Error clearing cell ([$error]{ridx}[/], [$accent]{col_name}[/])",
2135
+ title="Clear Cell",
2136
+ severity="error",
2137
+ timeout=10,
2138
+ )
2139
+ self.log(f"Error clearing cell ({ridx}, {col_name}): {str(e)}")
1745
2140
  raise e
1746
2141
 
1747
- def do_add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
2142
+ def do_add_column(self, col_name: str = None) -> None:
1748
2143
  """Add acolumn after the current column."""
1749
2144
  cidx = self.cursor_col_idx
1750
2145
 
1751
2146
  if not col_name:
1752
2147
  # Generate a unique column name
1753
2148
  base_name = "new_col"
1754
- new_name = base_name
2149
+ new_col_name = base_name
1755
2150
  counter = 1
1756
- while new_name in self.df.columns:
1757
- new_name = f"{base_name}_{counter}"
2151
+ while new_col_name in self.df.columns:
2152
+ new_col_name = f"{base_name}_{counter}"
1758
2153
  counter += 1
1759
2154
  else:
1760
- new_name = col_name
2155
+ new_col_name = col_name
1761
2156
 
1762
2157
  # Add to history
1763
- self.add_history(f"Added column [$success]{new_name}[/] after column {cidx + 1}", dirty=True)
2158
+ self.add_history(f"Added column [$success]{new_col_name}[/] after column [$accent]{cidx + 1}[/]", dirty=True)
1764
2159
 
1765
2160
  try:
1766
2161
  # 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)
2162
+ new_col_name = pl.lit(None).alias(new_col_name)
1771
2163
 
1772
2164
  # Get columns up to current, the new column, then remaining columns
1773
2165
  cols = self.df.columns
@@ -1775,8 +2167,12 @@ class DataFrameTable(DataTable):
1775
2167
  cols_after = cols[cidx + 1 :]
1776
2168
 
1777
2169
  # 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)
2170
+ select_cols = cols_before + [new_col_name] + cols_after
2171
+ self.df = self.df.lazy().with_columns(new_col_name).select(select_cols).collect()
2172
+
2173
+ # Also update the view if applicable
2174
+ if self.df_view is not None:
2175
+ self.df_view = self.df_view.lazy().with_columns(new_col_name).select(select_cols).collect()
1780
2176
 
1781
2177
  # Recreate table for display
1782
2178
  self.setup_table()
@@ -1786,8 +2182,10 @@ class DataFrameTable(DataTable):
1786
2182
 
1787
2183
  # self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1788
2184
  except Exception as e:
1789
- self.notify("Error adding column", title="Add Column", severity="error")
1790
- self.log(f"Error adding column: {str(e)}")
2185
+ self.notify(
2186
+ f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
2187
+ )
2188
+ self.log(f"Error adding column `{new_col_name}`: {str(e)}")
1791
2189
  raise e
1792
2190
 
1793
2191
  def do_add_column_expr(self) -> None:
@@ -1806,7 +2204,7 @@ class DataFrameTable(DataTable):
1806
2204
  cidx, new_col_name, expr = result
1807
2205
 
1808
2206
  # Add to history
1809
- self.add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.", dirty=True)
2207
+ self.add_history(f"Added column [$success]{new_col_name}[/] with expression [$accent]{expr}[/].", dirty=True)
1810
2208
 
1811
2209
  try:
1812
2210
  # Create the column
@@ -1819,7 +2217,14 @@ class DataFrameTable(DataTable):
1819
2217
 
1820
2218
  # Build the new dataframe with columns reordered
1821
2219
  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)
2220
+ self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
2221
+
2222
+ # Also update the view if applicable
2223
+ if self.df_view is not None:
2224
+ # Get updated column from df for rows that exist in df_view
2225
+ lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
2226
+ # Join and use coalesce to prefer updated value or keep original
2227
+ self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
1823
2228
 
1824
2229
  # Recreate table for display
1825
2230
  self.setup_table()
@@ -1829,7 +2234,9 @@ class DataFrameTable(DataTable):
1829
2234
 
1830
2235
  # self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
1831
2236
  except Exception as e:
1832
- self.notify("Error adding column", title="Add Column", severity="error")
2237
+ self.notify(
2238
+ f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
2239
+ )
1833
2240
  self.log(f"Error adding column `{new_col_name}`: {str(e)}")
1834
2241
 
1835
2242
  def do_add_link_column(self) -> None:
@@ -1841,10 +2248,10 @@ class DataFrameTable(DataTable):
1841
2248
  def add_link_column(self, result: tuple[str, str] | None) -> None:
1842
2249
  """Handle result from AddLinkScreen.
1843
2250
 
1844
- Creates a new link column in the dataframe with clickable links based on a
1845
- user-provided template. Supports multiple placeholder types:
2251
+ Creates a new link column in the dataframe based on a user-provided template.
2252
+ Supports multiple placeholder types:
1846
2253
  - `$_` - Current column (based on cursor position)
1847
- - `$1`, `$2`, etc. - Column by 1-based position index
2254
+ - `$1`, `$2`, etc. - Column by index (1-based)
1848
2255
  - `$name` - Column by name (e.g., `$id`, `$product_name`)
1849
2256
 
1850
2257
  The template is evaluated for each row using Polars expressions with vectorized
@@ -1858,7 +2265,7 @@ class DataFrameTable(DataTable):
1858
2265
  cidx, new_col_name, link_template = result
1859
2266
 
1860
2267
  self.add_history(
1861
- f"Added link column [$accent]{new_col_name}[/] with template [$success]{link_template}[/].", dirty=True
2268
+ f"Added link column [$success]{new_col_name}[/] with template [$accent]{link_template}[/].", dirty=True
1862
2269
  )
1863
2270
 
1864
2271
  try:
@@ -1883,7 +2290,14 @@ class DataFrameTable(DataTable):
1883
2290
 
1884
2291
  # Build the new dataframe with columns reordered
1885
2292
  select_cols = cols_before + [new_col_name] + cols_after
1886
- self.df = self.df.with_columns(new_col).select(select_cols)
2293
+ self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
2294
+
2295
+ # Also update the view if applicable
2296
+ if self.df_view is not None:
2297
+ # Get updated column from df for rows that exist in df_view
2298
+ lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
2299
+ # Join and use coalesce to prefer updated value or keep original
2300
+ self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
1887
2301
 
1888
2302
  # Recreate table for display
1889
2303
  self.setup_table()
@@ -1891,17 +2305,24 @@ class DataFrameTable(DataTable):
1891
2305
  # Move cursor to the new column
1892
2306
  self.move_cursor(column=cidx + 1)
1893
2307
 
1894
- self.notify(f"Added link column [$success]{new_col_name}[/]. Use Ctrl/Cmd click to open.", title="Add Link")
2308
+ # self.notify(f"Added link column [$success]{new_col_name}[/]. Use Ctrl/Cmd click to open.", title="Add Link")
1895
2309
 
1896
2310
  except Exception as e:
1897
- self.notify(f"Error adding link column [$error]{new_col_name}[/]", title="Add Link", severity="error")
2311
+ self.notify(
2312
+ f"Error adding link column [$error]{new_col_name}[/]", title="Add Link", severity="error", timeout=10
2313
+ )
1898
2314
  self.log(f"Error adding link column: {str(e)}")
1899
2315
 
1900
2316
  def do_delete_column(self, more: str = None) -> None:
1901
2317
  """Remove the currently selected column from the table."""
1902
2318
  # Get the column to remove
1903
2319
  col_idx = self.cursor_column
1904
- col_name = self.cursor_col_name
2320
+ try:
2321
+ col_name = self.cursor_col_name
2322
+ except CellDoesNotExist:
2323
+ # self.notify("No column to delete at the current cursor position", title="Delete Column", severity="warning")
2324
+ return
2325
+
1905
2326
  col_key = self.cursor_col_key
1906
2327
 
1907
2328
  col_names_to_remove = []
@@ -1910,7 +2331,7 @@ class DataFrameTable(DataTable):
1910
2331
  # Remove all columns before the current column
1911
2332
  if more == "before":
1912
2333
  for i in range(col_idx + 1):
1913
- col_key = self.get_column_key(i)
2334
+ col_key = self.get_col_key(i)
1914
2335
  col_names_to_remove.append(col_key.value)
1915
2336
  col_keys_to_remove.append(col_key)
1916
2337
 
@@ -1919,7 +2340,7 @@ class DataFrameTable(DataTable):
1919
2340
  # Remove all columns after the current column
1920
2341
  elif more == "after":
1921
2342
  for i in range(col_idx, len(self.columns)):
1922
- col_key = self.get_column_key(i)
2343
+ col_key = self.get_col_key(i)
1923
2344
  col_names_to_remove.append(col_key.value)
1924
2345
  col_keys_to_remove.append(col_key)
1925
2346
 
@@ -1948,18 +2369,25 @@ class DataFrameTable(DataTable):
1948
2369
  if col_name in self.sorted_columns:
1949
2370
  del self.sorted_columns[col_name]
1950
2371
 
2372
+ # Remove from hidden columns if present
2373
+ for col_name in col_names_to_remove:
2374
+ self.hidden_columns.discard(col_name)
2375
+
1951
2376
  # 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)
2377
+ for rid in list(self.matches.keys()):
2378
+ self.matches[rid].difference_update(col_names_to_remove)
1955
2379
  # Remove empty entries
1956
- if not self.matches[row_idx]:
1957
- del self.matches[row_idx]
2380
+ if not self.matches[rid]:
2381
+ del self.matches[rid]
1958
2382
 
1959
2383
  # Remove from dataframe
1960
2384
  self.df = self.df.drop(col_names_to_remove)
1961
2385
 
1962
- self.notify(message, title="Delete")
2386
+ # Also update the view if applicable
2387
+ if self.df_view is not None:
2388
+ self.df_view = self.df_view.drop(col_names_to_remove)
2389
+
2390
+ # self.notify(message, title="Delete Column")
1963
2391
 
1964
2392
  def do_duplicate_column(self) -> None:
1965
2393
  """Duplicate the currently selected column, inserting it right after the current column."""
@@ -1969,29 +2397,28 @@ class DataFrameTable(DataTable):
1969
2397
  col_idx = self.cursor_column
1970
2398
  new_col_name = f"{col_name}_copy"
1971
2399
 
2400
+ # Ensure new column name is unique
2401
+ counter = 1
2402
+ while new_col_name in self.df.columns:
2403
+ new_col_name = f"{new_col_name}{counter}"
2404
+ counter += 1
2405
+
1972
2406
  # Add to history
1973
2407
  self.add_history(f"Duplicated column [$success]{col_name}[/]", dirty=True)
1974
2408
 
1975
2409
  # Create new column and reorder columns to insert after current column
1976
2410
  cols_before = self.df.columns[: cidx + 1]
1977
2411
  cols_after = self.df.columns[cidx + 1 :]
2412
+ cols_new = cols_before + [new_col_name] + cols_after
1978
2413
 
1979
2414
  # 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
- )
2415
+ self.df = self.df.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
1983
2416
 
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
2417
+ # Also update the view if applicable
2418
+ if self.df_view is not None:
2419
+ self.df_view = (
2420
+ self.df_view.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
2421
+ )
1995
2422
 
1996
2423
  # Recreate table for display
1997
2424
  self.setup_table()
@@ -1999,7 +2426,7 @@ class DataFrameTable(DataTable):
1999
2426
  # Move cursor to the new duplicated column
2000
2427
  self.move_cursor(column=col_idx + 1)
2001
2428
 
2002
- # self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
2429
+ # self.notify(f"Duplicated column [$success]{col_name}[/] as [$accent]{new_col_name}[/]", title="Duplicate Column")
2003
2430
 
2004
2431
  def do_delete_row(self, more: str = None) -> None:
2005
2432
  """Delete rows from the table and dataframe.
@@ -2007,97 +2434,95 @@ class DataFrameTable(DataTable):
2007
2434
  Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
2008
2435
  """
2009
2436
  old_count = len(self.df)
2010
- predicates = [True] * len(self.df)
2437
+ rids_to_delete = set()
2011
2438
 
2012
2439
  # Delete all selected rows
2013
- if selected_count := self.selected_rows.count(True):
2440
+ if selected_count := len(self.selected_rows):
2014
2441
  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
2442
+ rids_to_delete.update(self.selected_rows)
2019
2443
 
2020
2444
  # Delete current row and those above
2021
2445
  elif more == "above":
2022
2446
  ridx = self.cursor_row_idx
2023
2447
  history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
2024
- for i in range(ridx + 1):
2025
- predicates[i] = False
2448
+ for rid in self.df[RID][: ridx + 1]:
2449
+ rids_to_delete.add(rid)
2026
2450
 
2027
2451
  # Delete current row and those below
2028
2452
  elif more == "below":
2029
2453
  ridx = self.cursor_row_idx
2030
2454
  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
2455
+ for rid in self.df[RID][ridx:]:
2456
+ rids_to_delete.add(rid)
2034
2457
 
2035
2458
  # Delete the row at the cursor
2036
2459
  else:
2037
2460
  ridx = self.cursor_row_idx
2038
2461
  history_desc = f"Deleted row [$success]{ridx + 1}[/]"
2039
- if self.visible_rows[ridx]:
2040
- predicates[ridx] = False
2462
+ rids_to_delete.add(self.df[RID][ridx])
2041
2463
 
2042
2464
  # Add to history
2043
2465
  self.add_history(history_desc, dirty=True)
2044
2466
 
2045
2467
  # Apply the filter to remove rows
2046
2468
  try:
2047
- df = self.df.with_row_index(RIDX).filter(predicates)
2469
+ df_filtered = self.df.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
2048
2470
  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
2471
+ self.notify(f"Error deleting row(s): {e}", title="Delete Row(s)", severity="error", timeout=10)
2472
+ self.histories_undo.pop() # Remove last history entry
2051
2473
  return
2052
2474
 
2053
- self.df = df.drop(RIDX)
2475
+ # RIDs of remaining rows
2476
+ ok_rids = set(df_filtered[RID])
2054
2477
 
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]
2478
+ # Update selected rows tracking
2479
+ if self.selected_rows:
2480
+ self.selected_rows.intersection_update(ok_rids)
2059
2481
 
2060
- # Clear all matches since row indices have changed
2061
- self.matches = defaultdict(set)
2482
+ # Update the dataframe
2483
+ self.df = df_filtered
2484
+
2485
+ # Update matches since row indices have changed
2486
+ if self.matches:
2487
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
2488
+
2489
+ # Also update the view if applicable
2490
+ if self.df_view is not None:
2491
+ self.df_view = self.df_view.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
2062
2492
 
2063
2493
  # Recreate table for display
2064
2494
  self.setup_table()
2065
2495
 
2066
2496
  deleted_count = old_count - len(self.df)
2067
2497
  if deleted_count > 0:
2068
- self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
2498
+ self.notify(f"Deleted [$success]{deleted_count}[/] row(s)", title="Delete Row(s)")
2069
2499
 
2070
2500
  def do_duplicate_row(self) -> None:
2071
2501
  """Duplicate the currently selected row, inserting it right after the current row."""
2072
2502
  ridx = self.cursor_row_idx
2503
+ rid = self.df[RID][ridx]
2504
+
2505
+ lf = self.df.lazy()
2073
2506
 
2074
2507
  # Get the row to duplicate
2075
- row_to_duplicate = self.df.slice(ridx, 1)
2508
+ row_to_duplicate = lf.slice(ridx, 1).with_columns(pl.col(RID) + 1)
2076
2509
 
2077
2510
  # Add to history
2078
2511
  self.add_history(f"Duplicated row [$success]{ridx + 1}[/]", dirty=True)
2079
2512
 
2080
2513
  # Concatenate: rows before + duplicated row + rows after
2081
- df_before = self.df.slice(0, ridx + 1)
2082
- df_after = self.df.slice(ridx + 1)
2514
+ lf_before = lf.slice(0, ridx + 1)
2515
+ lf_after = lf.slice(ridx + 1).with_columns(pl.col(RID) + 1)
2083
2516
 
2084
2517
  # 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
2518
+ self.df = pl.concat([lf_before, row_to_duplicate, lf_after]).collect()
2519
+
2520
+ # Also update the view if applicable
2521
+ if self.df_view is not None:
2522
+ lf_view = self.df_view.lazy()
2523
+ lf_view_before = lf_view.slice(0, rid + 1)
2524
+ lf_view_after = lf_view.slice(rid + 1).with_columns(pl.col(RID) + 1)
2525
+ self.df_view = pl.concat([lf_view_before, row_to_duplicate, lf_view_after]).collect()
2101
2526
 
2102
2527
  # Recreate table for display
2103
2528
  self.setup_table()
@@ -2105,7 +2530,7 @@ class DataFrameTable(DataTable):
2105
2530
  # Move cursor to the new duplicated row
2106
2531
  self.move_cursor(row=ridx + 1)
2107
2532
 
2108
- # self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
2533
+ # self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Duplicate Row")
2109
2534
 
2110
2535
  def do_move_column(self, direction: str) -> None:
2111
2536
  """Move the current column left or right.
@@ -2121,12 +2546,12 @@ class DataFrameTable(DataTable):
2121
2546
  # Validate move is possible
2122
2547
  if direction == "left":
2123
2548
  if col_idx <= 0:
2124
- self.notify("Cannot move column left", title="Move", severity="warning")
2549
+ # self.notify("Cannot move column left", title="Move Column", severity="warning")
2125
2550
  return
2126
2551
  swap_idx = col_idx - 1
2127
2552
  elif direction == "right":
2128
2553
  if col_idx >= len(self.columns) - 1:
2129
- self.notify("Cannot move column right", title="Move", severity="warning")
2554
+ # self.notify("Cannot move column right", title="Move Column", severity="warning")
2130
2555
  return
2131
2556
  swap_idx = col_idx + 1
2132
2557
 
@@ -2137,7 +2562,8 @@ class DataFrameTable(DataTable):
2137
2562
 
2138
2563
  # Add to history
2139
2564
  self.add_history(
2140
- f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])", dirty=True
2565
+ f"Moved column [$success]{col_name}[/] [$accent]{direction}[/] (swapped with [$success]{swap_name}[/])",
2566
+ dirty=True,
2141
2567
  )
2142
2568
 
2143
2569
  # Swap columns in the table's internal column locations
@@ -2162,7 +2588,11 @@ class DataFrameTable(DataTable):
2162
2588
  cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
2163
2589
  self.df = self.df.select(cols)
2164
2590
 
2165
- # self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
2591
+ # Also update the view if applicable
2592
+ if self.df_view is not None:
2593
+ self.df_view = self.df_view.select(cols)
2594
+
2595
+ # self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move Column")
2166
2596
 
2167
2597
  def do_move_row(self, direction: str) -> None:
2168
2598
  """Move the current row up or down.
@@ -2170,65 +2600,88 @@ class DataFrameTable(DataTable):
2170
2600
  Args:
2171
2601
  direction: "up" to move up, "down" to move down.
2172
2602
  """
2173
- row_idx, col_idx = self.cursor_coordinate
2603
+ curr_row_idx, col_idx = self.cursor_coordinate
2174
2604
 
2175
2605
  # Validate move is possible
2176
2606
  if direction == "up":
2177
- if row_idx <= 0:
2178
- self.notify("Cannot move row up", title="Move", severity="warning")
2607
+ if curr_row_idx <= 0:
2608
+ # self.notify("Cannot move row up", title="Move Row", severity="warning")
2179
2609
  return
2180
- swap_idx = row_idx - 1
2610
+ swap_row_idx = curr_row_idx - 1
2181
2611
  elif direction == "down":
2182
- if row_idx >= len(self.rows) - 1:
2183
- self.notify("Cannot move row down", title="Move", severity="warning")
2612
+ if curr_row_idx >= len(self.rows) - 1:
2613
+ # self.notify("Cannot move row down", title="Move Row", severity="warning")
2184
2614
  return
2185
- swap_idx = row_idx + 1
2615
+ swap_row_idx = curr_row_idx + 1
2186
2616
  else:
2187
2617
  # Invalid direction
2188
2618
  return
2189
2619
 
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
2620
  # Add to history
2194
2621
  self.add_history(
2195
- f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])",
2622
+ f"Moved row [$success]{curr_row_idx}[/] [$accent]{direction}[/] (swapped with row [$success]{swap_row_idx}[/])",
2196
2623
  dirty=True,
2197
2624
  )
2198
2625
 
2199
2626
  # Swap rows in the table's internal row locations
2627
+ curr_key = self.coordinate_to_cell_key((curr_row_idx, 0)).row_key
2628
+ swap_key = self.coordinate_to_cell_key((swap_row_idx, 0)).row_key
2629
+
2200
2630
  self.check_idle()
2201
2631
 
2202
2632
  (
2203
- self._row_locations[row_key],
2633
+ self._row_locations[curr_key],
2204
2634
  self._row_locations[swap_key],
2205
2635
  ) = (
2206
- self._row_locations.get(swap_key),
2207
- self._row_locations.get(row_key),
2636
+ self.get_row_idx(swap_key),
2637
+ self.get_row_idx(curr_key),
2208
2638
  )
2209
2639
 
2210
2640
  self._update_count += 1
2211
2641
  self.refresh()
2212
2642
 
2213
2643
  # Restore cursor position on the moved row
2214
- self.move_cursor(row=swap_idx, column=col_idx)
2644
+ self.move_cursor(row=swap_row_idx, column=col_idx)
2215
2645
 
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])
2646
+ # Locate the rows to swap
2647
+ curr_ridx = curr_row_idx
2648
+ swap_ridx = swap_row_idx
2649
+ first, second = sorted([curr_ridx, swap_ridx])
2220
2650
 
2651
+ # Swap the rows in the dataframe
2221
2652
  self.df = pl.concat(
2222
2653
  [
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),
2654
+ self.df.slice(0, first).lazy(),
2655
+ self.df.slice(second, 1).lazy(),
2656
+ self.df.slice(first + 1, second - first - 1).lazy(),
2657
+ self.df.slice(first, 1).lazy(),
2658
+ self.df.slice(second + 1).lazy(),
2228
2659
  ]
2229
- )
2660
+ ).collect()
2230
2661
 
2231
- # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
2662
+ # Also update the view if applicable
2663
+ if self.df_view is not None:
2664
+ # Find RID values
2665
+ curr_rid = self.df[RID][curr_row_idx]
2666
+ swap_rid = self.df[RID][swap_row_idx]
2667
+
2668
+ # Locate the rows by RID in the view
2669
+ curr_ridx = self.df_view[RID].index_of(curr_rid)
2670
+ swap_ridx = self.df_view[RID].index_of(swap_rid)
2671
+ first, second = sorted([curr_ridx, swap_ridx])
2672
+
2673
+ # Swap the rows in the view
2674
+ self.df_view = pl.concat(
2675
+ [
2676
+ self.df_view.slice(0, first).lazy(),
2677
+ self.df_view.slice(second, 1).lazy(),
2678
+ self.df_view.slice(first + 1, second - first - 1).lazy(),
2679
+ self.df_view.slice(first, 1).lazy(),
2680
+ self.df_view.slice(second + 1).lazy(),
2681
+ ]
2682
+ ).collect()
2683
+
2684
+ # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move Row")
2232
2685
 
2233
2686
  # Type casting
2234
2687
  def do_cast_column_dtype(self, dtype: str) -> None:
@@ -2244,20 +2697,22 @@ class DataFrameTable(DataTable):
2244
2697
  try:
2245
2698
  target_dtype = eval(dtype)
2246
2699
  except Exception:
2247
- self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
2700
+ self.notify(
2701
+ f"Invalid target data type: [$error]{dtype}[/]", title="Cast Column", severity="error", timeout=10
2702
+ )
2248
2703
  return
2249
2704
 
2250
2705
  if current_dtype == target_dtype:
2251
- self.notify(
2252
- f"Column [$accent]{col_name}[/] is already of type [$success]{target_dtype}[/]",
2253
- title="Cast",
2254
- severity="warning",
2255
- )
2706
+ # self.notify(
2707
+ # f"Column [$warning]{col_name}[/] is already of type [$accent]{target_dtype}[/]",
2708
+ # title="Cast Column",
2709
+ # severity="warning",
2710
+ # )
2256
2711
  return # No change needed
2257
2712
 
2258
2713
  # Add to history
2259
2714
  self.add_history(
2260
- f"Cast column [$accent]{col_name}[/] from [$success]{current_dtype}[/] to [$success]{target_dtype}[/]",
2715
+ f"Cast column [$success]{col_name}[/] from [$accent]{current_dtype}[/] to [$success]{target_dtype}[/]",
2261
2716
  dirty=True,
2262
2717
  )
2263
2718
 
@@ -2265,30 +2720,49 @@ class DataFrameTable(DataTable):
2265
2720
  # Cast the column using Polars
2266
2721
  self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
2267
2722
 
2723
+ # Also update the view if applicable
2724
+ if self.df_view is not None:
2725
+ self.df_view = self.df_view.with_columns(pl.col(col_name).cast(target_dtype))
2726
+
2268
2727
  # Recreate table for display
2269
2728
  self.setup_table()
2270
2729
 
2271
- self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
2730
+ # self.notify(f"Cast column [$success]{col_name}[/] to [$accent]{target_dtype}[/]", title="Cast")
2272
2731
  except Exception as e:
2273
2732
  self.notify(
2274
- f"Error casting column [$accent]{col_name}[/] to [$error]{target_dtype}[/]",
2275
- title="Cast",
2733
+ f"Error casting column [$error]{col_name}[/] to [$accent]{target_dtype}[/]",
2734
+ title="Cast Column",
2276
2735
  severity="error",
2736
+ timeout=10,
2277
2737
  )
2278
2738
  self.log(f"Error casting column `{col_name}`: {str(e)}")
2279
2739
 
2280
- # Search
2281
- def do_search_cursor_value(self) -> None:
2282
- """Search with cursor value in current column."""
2740
+ # Row selection
2741
+ def do_select_row(self) -> None:
2742
+ """Select rows.
2743
+
2744
+ If there are existing cell matches, use those to select rows.
2745
+ Otherwise, use the current cell value as the search term and select rows matching that value.
2746
+ """
2283
2747
  cidx = self.cursor_col_idx
2284
2748
 
2285
- # Get the value of the currently selected cell
2286
- term = NULL if self.cursor_value is None else str(self.cursor_value)
2749
+ # Use existing cell matches if present
2750
+ if self.matches:
2751
+ term = pl.col(RID).is_in(self.matches)
2752
+ else:
2753
+ col_name = self.cursor_col_name
2754
+
2755
+ # Get the value of the currently selected cell
2756
+ term = NULL if self.cursor_value is None else str(self.cursor_value)
2757
+ if self.cursor_value is None:
2758
+ term = pl.col(col_name).is_null()
2759
+ else:
2760
+ term = pl.col(col_name) == self.cursor_value
2287
2761
 
2288
- self.search((term, cidx, False, True))
2762
+ self.select_row((term, cidx, False, True))
2289
2763
 
2290
- def do_search_expr(self) -> None:
2291
- """Search by expression."""
2764
+ def do_select_row_expr(self) -> None:
2765
+ """Select rows by expression."""
2292
2766
  cidx = self.cursor_col_idx
2293
2767
 
2294
2768
  # Use current cell value as default search term
@@ -2296,27 +2770,38 @@ class DataFrameTable(DataTable):
2296
2770
 
2297
2771
  # Push the search modal screen
2298
2772
  self.app.push_screen(
2299
- SearchScreen("Search", term, self.df, cidx),
2300
- callback=self.search,
2773
+ SearchScreen("Select", term, self.df, cidx),
2774
+ callback=self.select_row,
2301
2775
  )
2302
2776
 
2303
- def search(self, result) -> None:
2304
- """Search for a term."""
2777
+ def select_row(self, result) -> None:
2778
+ """Select rows by value or expression."""
2305
2779
  if result is None:
2306
2780
  return
2307
2781
 
2308
2782
  term, cidx, match_nocase, match_whole = result
2309
- col_name = self.df.columns[cidx]
2783
+ col_name = "all columns" if cidx is None else self.df.columns[cidx]
2784
+
2785
+ # Already a Polars expression
2786
+ if isinstance(term, pl.Expr):
2787
+ expr = term
2310
2788
 
2311
- if term == NULL:
2789
+ # bool list or Series
2790
+ elif isinstance(term, (list, pl.Series)):
2791
+ expr = term
2792
+
2793
+ # Null case
2794
+ elif term == NULL:
2312
2795
  expr = pl.col(col_name).is_null()
2313
2796
 
2314
- # Support for polars expressions
2797
+ # Expression in string form
2315
2798
  elif tentative_expr(term):
2316
2799
  try:
2317
2800
  expr = validate_expr(term, self.df.columns, cidx)
2318
2801
  except Exception as e:
2319
- self.notify(f"Error validating expression [$error]{term}[/]", title="Search", severity="error")
2802
+ self.notify(
2803
+ f"Error validating expression [$error]{term}[/]", title="Search", severity="error", timeout=10
2804
+ )
2320
2805
  self.log(f"Error validating expression `{term}`: {str(e)}")
2321
2806
  return
2322
2807
 
@@ -2340,55 +2825,127 @@ class DataFrameTable(DataTable):
2340
2825
  term = f"(?i){term}"
2341
2826
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2342
2827
  self.notify(
2343
- f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2828
+ f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
2344
2829
  title="Search",
2345
2830
  severity="warning",
2346
2831
  )
2347
2832
 
2348
2833
  # 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)
2834
+ lf = self.df.lazy()
2352
2835
 
2353
2836
  # Apply filter to get matched row indices
2354
2837
  try:
2355
- matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
2838
+ ok_rids = set(lf.filter(expr).collect()[RID])
2356
2839
  except Exception as e:
2357
- self.notify(f"Error applying search filter [$error]{term}[/]", title="Search", severity="error")
2840
+ self.notify(
2841
+ f"Error applying search filter `[$error]{term}[/]`", title="Search", severity="error", timeout=10
2842
+ )
2358
2843
  self.log(f"Error applying search filter `{term}`: {str(e)}")
2359
2844
  return
2360
2845
 
2361
- match_count = len(matches)
2846
+ match_count = len(ok_rids)
2362
2847
  if match_count == 0:
2363
2848
  self.notify(
2364
- f"No matches found for [$accent]{term}[/]. Try [$warning](?i)abc[/] for case-insensitive search.",
2849
+ f"No matches found for `[$warning]{term}[/]`. Try [$accent](?i)abc[/] for case-insensitive search.",
2365
2850
  title="Search",
2366
2851
  severity="warning",
2367
2852
  )
2368
2853
  return
2369
2854
 
2855
+ message = f"Found [$success]{match_count}[/] matching row(s)"
2856
+
2370
2857
  # Add to history
2371
- self.add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
2858
+ self.add_history(message)
2372
2859
 
2373
- # Update selected rows to include new matches
2374
- for m in matches:
2375
- self.selected_rows[m] = True
2860
+ # Update selected rows to include new selections
2861
+ self.selected_rows.update(ok_rids)
2376
2862
 
2377
2863
  # Show notification immediately, then start highlighting
2378
- self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
2864
+ self.notify(message, title="Select Row")
2865
+
2866
+ # Recreate table for display
2867
+ self.setup_table()
2868
+
2869
+ def do_toggle_selections(self) -> None:
2870
+ """Toggle selected rows highlighting on/off."""
2871
+ # Add to history
2872
+ self.add_history("Toggled row selection")
2873
+
2874
+ # Invert all selected rows
2875
+ self.selected_rows = {rid for rid in self.df[RID] if rid not in self.selected_rows}
2876
+
2877
+ # Check if we're highlighting or un-highlighting
2878
+ if selected_count := len(self.selected_rows):
2879
+ self.notify(f"Toggled selection for [$success]{selected_count}[/] rows", title="Toggle Selection(s)")
2379
2880
 
2380
2881
  # Recreate table for display
2381
2882
  self.setup_table()
2382
2883
 
2383
- # Find
2884
+ def do_toggle_row_selection(self) -> None:
2885
+ """Select/deselect current row."""
2886
+ # Add to history
2887
+ self.add_history("Toggled row selection")
2888
+
2889
+ # Get current row RID
2890
+ ridx = self.cursor_row_idx
2891
+ rid = self.df[RID][ridx]
2892
+
2893
+ if rid in self.selected_rows:
2894
+ self.selected_rows.discard(rid)
2895
+ else:
2896
+ self.selected_rows.add(rid)
2897
+
2898
+ row_key = self.cursor_row_key
2899
+ is_selected = rid in self.selected_rows
2900
+ match_cols = self.matches.get(rid, set())
2901
+
2902
+ for col_idx, col in enumerate(self.ordered_columns):
2903
+ col_key = col.key
2904
+ col_name = col_key.value
2905
+ cell_text: Text = self.get_cell(row_key, col_key)
2906
+
2907
+ if is_selected or (col_name in match_cols):
2908
+ cell_text.style = HIGHLIGHT_COLOR
2909
+ else:
2910
+ # Reset to default style based on dtype
2911
+ dtype = self.df.dtypes[col_idx]
2912
+ dc = DtypeConfig(dtype)
2913
+ cell_text.style = dc.style
2914
+
2915
+ self.update_cell(row_key, col_key, cell_text)
2916
+
2917
+ def do_clear_selections_and_matches(self) -> None:
2918
+ """Clear all selected rows and matches without removing them from the dataframe."""
2919
+ # Check if any selected rows or matches
2920
+ if not self.selected_rows and not self.matches:
2921
+ # self.notify("No selections to clear", title="Clear Selections and Matches", severity="warning")
2922
+ return
2923
+
2924
+ row_count = len(self.selected_rows | set(self.matches.keys()))
2925
+
2926
+ # Add to history
2927
+ self.add_history("Cleared all selected rows")
2928
+
2929
+ # Clear all selections
2930
+ self.selected_rows = set()
2931
+ self.matches = defaultdict(set)
2932
+
2933
+ # Recreate table for display
2934
+ self.setup_table()
2935
+
2936
+ self.notify(f"Cleared selections for [$success]{row_count}[/] rows", title="Clear Selections and Matches")
2937
+
2938
+ # Find & Replace
2384
2939
  def find_matches(
2385
2940
  self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
2386
- ) -> dict[int, set[int]]:
2941
+ ) -> dict[int, set[str]]:
2387
2942
  """Find matches for a term in the dataframe.
2388
2943
 
2389
2944
  Args:
2390
2945
  term: The search term (can be NULL, expression, or plain text)
2391
2946
  cidx: Column index for column-specific search. If None, searches all columns.
2947
+ match_nocase: Whether to perform case-insensitive matching (for string terms)
2948
+ match_whole: Whether to match the whole cell content (for string terms)
2392
2949
 
2393
2950
  Returns:
2394
2951
  Dictionary mapping row indices to sets of column indices containing matches.
@@ -2398,12 +2955,10 @@ class DataFrameTable(DataTable):
2398
2955
  Raises:
2399
2956
  Exception: If expression validation or filtering fails.
2400
2957
  """
2401
- matches: dict[int, set[int]] = defaultdict(set)
2958
+ matches: dict[int, set[str]] = defaultdict(set)
2402
2959
 
2403
2960
  # 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)
2961
+ lf = self.df.lazy()
2407
2962
 
2408
2963
  # Determine which columns to search: single column or all columns
2409
2964
  if cidx is not None:
@@ -2420,7 +2975,9 @@ class DataFrameTable(DataTable):
2420
2975
  try:
2421
2976
  expr = validate_expr(term, self.df.columns, col_idx)
2422
2977
  except Exception as e:
2423
- self.notify(f"Error validating expression [$error]{term}[/]", title="Find", severity="error")
2978
+ self.notify(
2979
+ f"Error validating expression [$error]{term}[/]", title="Find", severity="error", timeout=10
2980
+ )
2424
2981
  self.log(f"Error validating expression `{term}`: {str(e)}")
2425
2982
  return matches
2426
2983
  else:
@@ -2432,14 +2989,14 @@ class DataFrameTable(DataTable):
2432
2989
 
2433
2990
  # Get matched row indices
2434
2991
  try:
2435
- matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
2992
+ matched_ridxs = lf.filter(expr).collect()[RID]
2436
2993
  except Exception as e:
2437
- self.notify(f"Error applying filter: {expr}", title="Find", severity="error")
2994
+ self.notify(f"Error applying filter: [$error]{expr}[/]", title="Find", severity="error", timeout=10)
2438
2995
  self.log(f"Error applying filter: {str(e)}")
2439
2996
  return matches
2440
2997
 
2441
2998
  for ridx in matched_ridxs:
2442
- matches[ridx].add(col_idx)
2999
+ matches[ridx].add(col_name)
2443
3000
 
2444
3001
  return matches
2445
3002
 
@@ -2485,27 +3042,27 @@ class DataFrameTable(DataTable):
2485
3042
  try:
2486
3043
  matches = self.find_matches(term, cidx, match_nocase, match_whole)
2487
3044
  except Exception as e:
2488
- self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
3045
+ self.notify(f"Error finding matches for `[$error]{term}[/]`", title="Find", severity="error", timeout=10)
2489
3046
  self.log(f"Error finding matches for `{term}`: {str(e)}")
2490
3047
  return
2491
3048
 
2492
3049
  if not matches:
2493
3050
  self.notify(
2494
- f"No matches found for [$accent]{term}[/] in current column. Try [$warning](?i)abc[/] for case-insensitive search.",
3051
+ f"No matches found for `[$warning]{term}[/]` in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
2495
3052
  title="Find",
2496
3053
  severity="warning",
2497
3054
  )
2498
3055
  return
2499
3056
 
2500
3057
  # Add to history
2501
- self.add_history(f"Found [$accent]{term}[/] in column [$success]{col_name}[/]")
3058
+ self.add_history(f"Found `[$success]{term}[/]` in column [$accent]{col_name}[/]")
2502
3059
 
2503
3060
  # 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)
3061
+ match_count = sum(len(cols) for cols in matches.values())
3062
+ for rid, cols in matches.items():
3063
+ self.matches[rid].update(cols)
2507
3064
 
2508
- self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
3065
+ self.notify(f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]`", title="Find")
2509
3066
 
2510
3067
  # Recreate table for display
2511
3068
  self.setup_table()
@@ -2519,28 +3076,29 @@ class DataFrameTable(DataTable):
2519
3076
  try:
2520
3077
  matches = self.find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
2521
3078
  except Exception as e:
2522
- self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
3079
+ self.notify(f"Error finding matches for `[$error]{term}[/]`", title="Find", severity="error", timeout=10)
2523
3080
  self.log(f"Error finding matches for `{term}`: {str(e)}")
2524
3081
  return
2525
3082
 
2526
3083
  if not matches:
2527
3084
  self.notify(
2528
- f"No matches found for [$accent]{term}[/] in any column. Try [$warning](?i)abc[/] for case-insensitive search.",
3085
+ f"No matches found for `[$warning]{term}[/]` in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
2529
3086
  title="Global Find",
2530
3087
  severity="warning",
2531
3088
  )
2532
3089
  return
2533
3090
 
2534
3091
  # Add to history
2535
- self.add_history(f"Found [$success]{term}[/] across all columns")
3092
+ self.add_history(f"Found `[$success]{term}[/]` across all columns")
2536
3093
 
2537
3094
  # 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)
3095
+ match_count = sum(len(cols) for cols in matches.values())
3096
+ for rid, cols in matches.items():
3097
+ self.matches[rid].update(cols)
2541
3098
 
2542
3099
  self.notify(
2543
- f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
3100
+ f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]` across all columns",
3101
+ title="Global Find",
2544
3102
  )
2545
3103
 
2546
3104
  # Recreate table for display
@@ -2549,7 +3107,7 @@ class DataFrameTable(DataTable):
2549
3107
  def do_next_match(self) -> None:
2550
3108
  """Move cursor to the next match."""
2551
3109
  if not self.matches:
2552
- self.notify("No matches to navigate", title="Next Match", severity="warning")
3110
+ # self.notify("No matches to navigate", title="Next Match", severity="warning")
2553
3111
  return
2554
3112
 
2555
3113
  # Get sorted list of matched coordinates
@@ -2571,7 +3129,7 @@ class DataFrameTable(DataTable):
2571
3129
  def do_previous_match(self) -> None:
2572
3130
  """Move cursor to the previous match."""
2573
3131
  if not self.matches:
2574
- self.notify("No matches to navigate", title="Previous Match", severity="warning")
3132
+ # self.notify("No matches to navigate", title="Previous Match", severity="warning")
2575
3133
  return
2576
3134
 
2577
3135
  # Get sorted list of matched coordinates
@@ -2598,8 +3156,8 @@ class DataFrameTable(DataTable):
2598
3156
 
2599
3157
  def do_next_selected_row(self) -> None:
2600
3158
  """Move cursor to the next selected row."""
2601
- if not any(self.selected_rows):
2602
- self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
3159
+ if not self.selected_rows:
3160
+ # self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
2603
3161
  return
2604
3162
 
2605
3163
  # Get list of selected row indices in order
@@ -2620,8 +3178,8 @@ class DataFrameTable(DataTable):
2620
3178
 
2621
3179
  def do_previous_selected_row(self) -> None:
2622
3180
  """Move cursor to the previous selected row."""
2623
- if not any(self.selected_rows):
2624
- self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
3181
+ if not self.selected_rows:
3182
+ # self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
2625
3183
  return
2626
3184
 
2627
3185
  # Get list of selected row indices in order
@@ -2640,7 +3198,6 @@ class DataFrameTable(DataTable):
2640
3198
  last_ridx = selected_row_indices[-1]
2641
3199
  self.move_cursor_to(last_ridx, self.cursor_col_idx)
2642
3200
 
2643
- # Replace
2644
3201
  def do_replace(self) -> None:
2645
3202
  """Open replace screen for current column."""
2646
3203
  # Push the replace modal screen
@@ -2690,29 +3247,38 @@ class DataFrameTable(DataTable):
2690
3247
 
2691
3248
  # Add to history
2692
3249
  self.add_history(
2693
- f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
3250
+ f"Replaced [$success]{term_find}[/] with [$accent]{term_replace}[/] in column [$success]{col_name}[/]"
2694
3251
  )
2695
3252
 
2696
3253
  # Update matches
2697
- self.matches = {ridx: col_idxs.copy() for ridx, col_idxs in matches.items()}
3254
+ self.matches = matches
2698
3255
 
2699
3256
  # Recreate table for display
2700
3257
  self.setup_table()
2701
3258
 
2702
3259
  # Store state for interactive replacement using dataclass
2703
- sorted_rows = sorted(self.matches.keys())
3260
+ rid2ridx = {rid: ridx for ridx, rid in enumerate(self.df[RID]) if rid in self.matches}
3261
+
3262
+ # Unique columns to replace
3263
+ cols_to_replace = set()
3264
+ for cols in self.matches.values():
3265
+ cols_to_replace.update(cols)
3266
+
3267
+ # Sorted column indices to replace
3268
+ cidx2col = {cidx: col for cidx, col in enumerate(self.df.columns) if col in cols_to_replace}
3269
+
2704
3270
  self.replace_state = ReplaceState(
2705
3271
  term_find=term_find,
2706
3272
  term_replace=term_replace,
2707
3273
  match_nocase=match_nocase,
2708
3274
  match_whole=match_whole,
2709
3275
  cidx=cidx,
2710
- rows=sorted_rows,
2711
- cols_per_row=[sorted(self.matches[ridx]) for ridx in sorted_rows],
3276
+ rows=list(rid2ridx.values()),
3277
+ cols_per_row=[[cidx for cidx, col in cidx2col.items() if col in self.matches[rid]] for rid in rid2ridx],
2712
3278
  current_rpos=0,
2713
3279
  current_cpos=0,
2714
3280
  current_occurrence=0,
2715
- total_occurrence=sum(len(col_idxs) for col_idxs in self.matches.values()),
3281
+ total_occurrence=sum(len(cols) for cols in self.matches.values()),
2716
3282
  replaced_occurrence=0,
2717
3283
  skipped_occurrence=0,
2718
3284
  done=False,
@@ -2728,9 +3294,10 @@ class DataFrameTable(DataTable):
2728
3294
 
2729
3295
  except Exception as e:
2730
3296
  self.notify(
2731
- f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
3297
+ f"Error replacing [$error]{term_find}[/] with [$accent]{term_replace}[/]",
2732
3298
  title="Replace",
2733
3299
  severity="error",
3300
+ timeout=10,
2734
3301
  )
2735
3302
  self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
2736
3303
 
@@ -2740,7 +3307,7 @@ class DataFrameTable(DataTable):
2740
3307
  self.app.push_screen(
2741
3308
  ConfirmScreen(
2742
3309
  "Replace All",
2743
- label=f"Replace [$success]{term_find}[/] with [$success]{term_replace or repr('')}[/] for all [$accent]{state.total_occurrence}[/] occurrences?",
3310
+ label=f"Replace `[$success]{term_find}[/]` with `[$success]{term_replace}[/]` for all [$accent]{state.total_occurrence}[/] occurrences?",
2744
3311
  ),
2745
3312
  callback=self.handle_replace_all_confirmation,
2746
3313
  )
@@ -2795,6 +3362,18 @@ class DataFrameTable(DataTable):
2795
3362
  pl.when(mask).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
2796
3363
  )
2797
3364
 
3365
+ # Also update the view if applicable
3366
+ if self.df_view is not None:
3367
+ col_updated = f"^_{col_name}_^"
3368
+ lf_updated = self.df.lazy().filter(mask).select(pl.col(col_name).alias(col_updated), pl.col(RID))
3369
+ self.df_view = (
3370
+ self.df_view.lazy()
3371
+ .join(lf_updated, on=RID, how="left")
3372
+ .with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
3373
+ .drop(col_updated)
3374
+ .collect()
3375
+ )
3376
+
2798
3377
  state.replaced_occurrence += len(ridxs)
2799
3378
 
2800
3379
  # Recreate table for display
@@ -2806,7 +3385,7 @@ class DataFrameTable(DataTable):
2806
3385
 
2807
3386
  col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
2808
3387
  self.notify(
2809
- f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]",
3388
+ f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]",
2810
3389
  title="Replace",
2811
3390
  )
2812
3391
 
@@ -2817,9 +3396,10 @@ class DataFrameTable(DataTable):
2817
3396
  self.show_next_replace_confirmation()
2818
3397
  except Exception as e:
2819
3398
  self.notify(
2820
- f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
3399
+ f"Error replacing [$error]{term_find}[/] with [$accent]{term_replace}[/]",
2821
3400
  title="Replace",
2822
3401
  severity="error",
3402
+ timeout=10,
2823
3403
  )
2824
3404
  self.log(f"Error in interactive replace: {str(e)}")
2825
3405
 
@@ -2829,7 +3409,7 @@ class DataFrameTable(DataTable):
2829
3409
  if state.done:
2830
3410
  # All done - show final notification
2831
3411
  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}[/]"
3412
+ msg = f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]"
2833
3413
  if state.skipped_occurrence > 0:
2834
3414
  msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
2835
3415
  self.notify(msg, title="Replace")
@@ -2847,7 +3427,7 @@ class DataFrameTable(DataTable):
2847
3427
  state.current_occurrence += 1
2848
3428
 
2849
3429
  # Show confirmation
2850
- label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] ({state.current_occurrence} of {state.total_occurrence})?"
3430
+ label = f"Replace `[$warning]{state.term_find}[/]` with `[$success]{state.term_replace}[/]` ({state.current_occurrence} of {state.total_occurrence})?"
2851
3431
 
2852
3432
  self.app.push_screen(
2853
3433
  ConfirmScreen("Replace", label=label, maybe="Skip"),
@@ -2864,6 +3444,7 @@ class DataFrameTable(DataTable):
2864
3444
  cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
2865
3445
  col_name = self.df.columns[cidx]
2866
3446
  dtype = self.df.dtypes[cidx]
3447
+ rid = self.df[RID][ridx]
2867
3448
 
2868
3449
  # Replace
2869
3450
  if result is True:
@@ -2876,6 +3457,15 @@ class DataFrameTable(DataTable):
2876
3457
  .otherwise(pl.col(col_name))
2877
3458
  .alias(col_name)
2878
3459
  )
3460
+
3461
+ # Also update the view if applicable
3462
+ if self.df_view is not None:
3463
+ self.df_view = self.df_view.with_columns(
3464
+ pl.when(pl.col(RID) == rid)
3465
+ .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
3466
+ .otherwise(pl.col(col_name))
3467
+ .alias(col_name)
3468
+ )
2879
3469
  else:
2880
3470
  # try to convert replacement value to column dtype
2881
3471
  try:
@@ -2890,6 +3480,12 @@ class DataFrameTable(DataTable):
2890
3480
  .alias(col_name)
2891
3481
  )
2892
3482
 
3483
+ # Also update the view if applicable
3484
+ if self.df_view is not None:
3485
+ self.df_view = self.df_view.with_columns(
3486
+ pl.when(pl.col(RID) == rid).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
3487
+ )
3488
+
2893
3489
  state.replaced_occurrence += 1
2894
3490
 
2895
3491
  # Skip
@@ -2922,128 +3518,25 @@ class DataFrameTable(DataTable):
2922
3518
  # Show next confirmation
2923
3519
  self.show_next_replace_confirmation()
2924
3520
 
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
-
3521
+ # View & Filter
3029
3522
  def do_view_rows(self) -> None:
3030
3523
  """View rows.
3031
3524
 
3032
- If there are selected rows or matches, view those rows.
3033
- Otherwise, view based on the value of the currently selected cell.
3525
+ If there are selected rows, view those.
3526
+ Otherwise, view based on the cursor value.
3034
3527
  """
3035
3528
 
3036
3529
  cidx = self.cursor_col_idx
3530
+ col_name = self.cursor_col_name
3037
3531
 
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
- ]
3532
+ # If there are selected rows, use those
3533
+ if self.selected_rows:
3534
+ term = pl.col(RID).is_in(self.selected_rows)
3043
3535
  # Otherwise, use the current cell value
3044
3536
  else:
3045
3537
  ridx = self.cursor_row_idx
3046
- term = str(self.df.item(ridx, cidx))
3538
+ value = self.df.item(ridx, cidx)
3539
+ term = pl.col(col_name).is_null() if value is None else pl.col(col_name) == value
3047
3540
 
3048
3541
  self.view_rows((term, cidx, False, True))
3049
3542
 
@@ -3051,34 +3544,46 @@ class DataFrameTable(DataTable):
3051
3544
  """Open the filter screen to enter an expression."""
3052
3545
  ridx = self.cursor_row_idx
3053
3546
  cidx = self.cursor_col_idx
3054
- cursor_value = str(self.df.item(ridx, cidx))
3547
+ cursor_value = self.df.item(ridx, cidx)
3548
+ term = NULL if cursor_value is None else str(cursor_value)
3055
3549
 
3056
3550
  self.app.push_screen(
3057
- FilterScreen(self.df, cidx, cursor_value),
3551
+ FilterScreen(self.df, cidx, term),
3058
3552
  callback=self.view_rows,
3059
3553
  )
3060
3554
 
3061
3555
  def view_rows(self, result) -> None:
3062
- """Show only rows with selections or matches, and do hide others. Do not modify the dataframe."""
3556
+ """View selected rows and hide others. Do not modify the dataframe."""
3063
3557
  if result is None:
3064
3558
  return
3065
3559
  term, cidx, match_nocase, match_whole = result
3066
3560
 
3067
3561
  col_name = self.df.columns[cidx]
3068
3562
 
3069
- if term == NULL:
3070
- expr = pl.col(col_name).is_null()
3563
+ # Support for polars expression
3564
+ if isinstance(term, pl.Expr):
3565
+ expr = term
3566
+
3567
+ # Support for list of booleans (selected rows)
3071
3568
  elif isinstance(term, (list, pl.Series)):
3072
- # Support for list of booleans (selected rows)
3073
3569
  expr = term
3570
+
3571
+ # Null case
3572
+ elif term == NULL:
3573
+ expr = pl.col(col_name).is_null()
3574
+
3575
+ # Support for polars expression in string form
3074
3576
  elif tentative_expr(term):
3075
- # Support for polars expressions
3076
3577
  try:
3077
3578
  expr = validate_expr(term, self.df.columns, cidx)
3078
3579
  except Exception as e:
3079
- self.notify(f"Error validating expression [$error]{term}[/]", title="Filter", severity="error")
3580
+ self.notify(
3581
+ f"Error validating expression [$error]{term}[/]", title="Filter Rows", severity="error", timeout=10
3582
+ )
3080
3583
  self.log(f"Error validating expression `{term}`: {str(e)}")
3081
3584
  return
3585
+
3586
+ # Type-aware search based on column dtype
3082
3587
  else:
3083
3588
  dtype = self.df.dtypes[cidx]
3084
3589
  if dtype == pl.String:
@@ -3098,49 +3603,108 @@ class DataFrameTable(DataTable):
3098
3603
  term = f"(?i){term}"
3099
3604
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
3100
3605
  self.notify(
3101
- f"Unknown column type [$warning]{dtype}[/]. Cast to string.", title="Filter", severity="warning"
3606
+ f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
3607
+ title="View Rows",
3608
+ severity="warning",
3102
3609
  )
3103
3610
 
3104
3611
  # Lazyframe with row indices
3105
- lf = self.df.lazy().with_row_index(RIDX)
3106
-
3107
- # Apply existing visibility filter first
3108
- if False in self.visible_rows:
3109
- lf = lf.filter(self.visible_rows)
3612
+ lf = self.df.lazy()
3110
3613
 
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)
3614
+ expr_str = "boolean list or series" if isinstance(expr, (list, pl.Series)) else str(expr)
3115
3615
 
3116
3616
  # Apply the filter expression
3117
3617
  try:
3118
3618
  df_filtered = lf.filter(expr).collect()
3119
3619
  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")
3620
+ self.histories_undo.pop() # Remove last history entry
3621
+ self.notify(f"Error applying filter [$error]{expr_str}[/]", title="View Rows", severity="error", timeout=10)
3122
3622
  self.log(f"Error applying filter `{expr_str}`: {str(e)}")
3123
3623
  return
3124
3624
 
3125
3625
  matched_count = len(df_filtered)
3126
3626
  if not matched_count:
3127
- self.notify(f"No rows match the expression: [$success]{expr}[/]", title="Filter", severity="warning")
3627
+ self.notify(f"No rows match the expression: [$success]{expr}[/]", title="View Rows", severity="warning")
3128
3628
  return
3129
3629
 
3130
3630
  # Add to history
3131
- self.add_history(f"Filtered by expression [$success]{expr_str}[/]", dirty=True)
3631
+ self.add_history(f"Filtered by expression [$success]{expr_str}[/]")
3632
+
3633
+ ok_rids = set(df_filtered[RID])
3132
3634
 
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
3635
+ # Create a view of self.df as a copy
3636
+ if self.df_view is None:
3637
+ self.df_view = self.df
3638
+
3639
+ # Update dataframe
3640
+ self.df = df_filtered
3641
+
3642
+ # Update selected rows
3643
+ if self.selected_rows:
3644
+ self.selected_rows.intersection_update(ok_rids)
3645
+
3646
+ # Update matches
3647
+ if self.matches:
3648
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3139
3649
 
3140
3650
  # Recreate table for display
3141
3651
  self.setup_table()
3142
3652
 
3143
- self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
3653
+ self.notify(f"Filtered to [$success]{matched_count}[/] matching row(s)", title="View Rows")
3654
+
3655
+ def do_filter_rows(self) -> None:
3656
+ """Filter rows.
3657
+
3658
+ If there are selected rows, use those.
3659
+ Otherwise, filter based on the cursor value.
3660
+ """
3661
+ if self.selected_rows:
3662
+ message = "Filtered to selected rows (other rows removed)"
3663
+ filter_expr = pl.col(RID).is_in(self.selected_rows)
3664
+ else: # Search cursor value in current column
3665
+ message = "Filtered to rows matching cursor value (other rows removed)"
3666
+ cidx = self.cursor_col_idx
3667
+ col_name = self.df.columns[cidx]
3668
+ value = self.cursor_value
3669
+
3670
+ if value is None:
3671
+ filter_expr = pl.col(col_name).is_null()
3672
+ else:
3673
+ filter_expr = pl.col(col_name) == value
3674
+
3675
+ # Add to history
3676
+ self.add_history(message, dirty=True)
3677
+
3678
+ # Apply filter to dataframe with row indices
3679
+ df_filtered = self.df.lazy().filter(filter_expr).collect()
3680
+ ok_rids = set(df_filtered[RID])
3681
+
3682
+ # Update selected rows
3683
+ if self.selected_rows:
3684
+ selected_rows = {rid for rid in self.selected_rows if rid in ok_rids}
3685
+ else:
3686
+ selected_rows = set()
3687
+
3688
+ # Update matches
3689
+ if self.matches:
3690
+ matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3691
+ else:
3692
+ matches = defaultdict(set)
3693
+
3694
+ # Update dataframe
3695
+ self.reset_df(df_filtered)
3696
+
3697
+ # Clear view for filter mode
3698
+ self.df_view = None
3699
+
3700
+ # Restore selected rows and matches
3701
+ self.selected_rows = selected_rows
3702
+ self.matches = matches
3703
+
3704
+ # Recreate table for display
3705
+ self.setup_table()
3706
+
3707
+ self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter Rows")
3144
3708
 
3145
3709
  # Copy & Save
3146
3710
  def do_copy_to_clipboard(self, content: str, message: str) -> None:
@@ -3162,24 +3726,28 @@ class DataFrameTable(DataTable):
3162
3726
  input=content,
3163
3727
  text=True,
3164
3728
  )
3165
- self.notify(message, title="Clipboard")
3729
+ self.notify(message, title="Copy to Clipboard")
3166
3730
  except FileNotFoundError:
3167
- self.notify("Error copying to clipboard", title="Clipboard", severity="error")
3731
+ self.notify("Error copying to clipboard", title="Copy to Clipboard", severity="error", timeout=10)
3168
3732
 
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:
3733
+ def do_save_to_file(self, all_tabs: bool | None = None, task_after_save: str | None = None) -> None:
3172
3734
  """Open screen to save file."""
3173
3735
  self._task_after_save = task_after_save
3736
+ tab_count = len(self.app.tabs)
3737
+ save_all = tab_count > 1 and all_tabs is not False
3738
+
3739
+ filepath = Path(self.filename)
3740
+ if save_all:
3741
+ ext = filepath.suffix.lower()
3742
+ if ext in (".xlsx", ".xls"):
3743
+ filename = self.filename
3744
+ else:
3745
+ filename = "all-tabs.xlsx"
3746
+ else:
3747
+ filename = str(filepath.with_stem(self.tabname))
3174
3748
 
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
3749
  self.app.push_screen(
3182
- SaveFileScreen(filename, title=title, all_tabs=all_tabs, multi_tab=multi_tab),
3750
+ SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
3183
3751
  callback=self.save_to_file,
3184
3752
  )
3185
3753
 
@@ -3187,13 +3755,11 @@ class DataFrameTable(DataTable):
3187
3755
  """Handle result from SaveFileScreen."""
3188
3756
  if result is None:
3189
3757
  return
3190
- filename, all_tabs = result
3191
-
3192
- # Whether to save all tabs (for Excel files)
3193
- self._all_tabs = all_tabs
3758
+ filename, save_all, overwrite_prompt = result
3759
+ self._save_all = save_all
3194
3760
 
3195
3761
  # Check if file exists
3196
- if Path(filename).exists():
3762
+ if overwrite_prompt and Path(filename).exists():
3197
3763
  self._pending_filename = filename
3198
3764
  self.app.push_screen(
3199
3765
  ConfirmScreen("File already exists. Overwrite?"),
@@ -3209,7 +3775,7 @@ class DataFrameTable(DataTable):
3209
3775
  else:
3210
3776
  # Go back to SaveFileScreen to allow user to enter a different name
3211
3777
  self.app.push_screen(
3212
- SaveFileScreen(self._pending_filename),
3778
+ SaveFileScreen(self._pending_filename, save_all=self._save_all),
3213
3779
  callback=self.save_to_file,
3214
3780
  )
3215
3781
 
@@ -3217,62 +3783,78 @@ class DataFrameTable(DataTable):
3217
3783
  """Actually save the dataframe to a file."""
3218
3784
  filepath = Path(filename)
3219
3785
  ext = filepath.suffix.lower()
3786
+ if ext == ".gz":
3787
+ ext = Path(filename).with_suffix("").suffix.lower()
3220
3788
 
3221
- # Add to history
3222
- self.add_history(f"Saved dataframe to [$success]{filename}[/]")
3789
+ fmt = ext.removeprefix(".")
3790
+ if fmt not in SUPPORTED_FORMATS:
3791
+ self.notify(
3792
+ f"Unsupported file format [$success]{fmt}[/]. Use [$accent]CSV[/] as fallback. Supported formats: {', '.join(SUPPORTED_FORMATS)}",
3793
+ title="Save to File",
3794
+ severity="warning",
3795
+ )
3796
+ fmt = "csv"
3223
3797
 
3798
+ df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3224
3799
  try:
3225
- if ext in (".xlsx", ".xls"):
3800
+ if fmt == "csv":
3801
+ df.write_csv(filename)
3802
+ elif fmt in ("tsv", "tab"):
3803
+ df.write_csv(filename, separator="\t")
3804
+ elif fmt in ("xlsx", "xls"):
3226
3805
  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
3806
+ elif fmt == "json":
3807
+ df.write_json(filename)
3808
+ elif fmt == "ndjson":
3809
+ df.write_ndjson(filename)
3810
+ elif fmt == "parquet":
3811
+ df.write_parquet(filename)
3812
+ else: # Fallback to CSV
3813
+ df.write_csv(filename)
3814
+
3815
+ # Update current filename
3816
+ self.filename = filename
3238
3817
 
3239
3818
  # Reset dirty flag after save
3240
- if self._all_tabs:
3819
+ if self._save_all:
3241
3820
  tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3242
3821
  for table in tabs.values():
3243
3822
  table.dirty = False
3244
3823
  else:
3245
3824
  self.dirty = False
3246
3825
 
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()
3826
+ if hasattr(self, "_task_after_save"):
3827
+ if self._task_after_save == "close_tab":
3828
+ self.app.do_close_tab()
3829
+ elif self._task_after_save == "quit_app":
3830
+ self.app.exit()
3251
3831
 
3252
3832
  # From ConfirmScreen callback, so notify accordingly
3253
- if self._all_tabs:
3833
+ if self._save_all:
3254
3834
  self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
3255
3835
  else:
3256
3836
  self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
3257
3837
 
3258
3838
  except Exception as e:
3259
- self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error")
3839
+ self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error", timeout=10)
3260
3840
  self.log(f"Error saving file `{filename}`: {str(e)}")
3261
3841
 
3262
3842
  def save_excel(self, filename: str) -> None:
3263
3843
  """Save to an Excel file."""
3264
3844
  import xlsxwriter
3265
3845
 
3266
- if not self._all_tabs or len(self.app.tabs) == 1:
3846
+ if not self._save_all or len(self.app.tabs) == 1:
3267
3847
  # Single tab - save directly
3268
- self.df.write_excel(filename)
3848
+ df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3849
+ df.write_excel(filename, worksheet=self.tabname)
3269
3850
  else:
3270
3851
  # Multiple tabs - use xlsxwriter to create multiple sheets
3271
3852
  with xlsxwriter.Workbook(filename) as wb:
3272
3853
  tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3273
3854
  for table in tabs.values():
3274
3855
  worksheet = wb.add_worksheet(table.tabname)
3275
- table.df.write_excel(workbook=wb, worksheet=worksheet)
3856
+ df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
3857
+ df.write_excel(workbook=wb, worksheet=worksheet)
3276
3858
 
3277
3859
  # SQL Interface
3278
3860
  def do_simple_sql(self) -> None:
@@ -3316,19 +3898,17 @@ class DataFrameTable(DataTable):
3316
3898
  sql: The SQL query string to execute.
3317
3899
  """
3318
3900
 
3319
- import re
3320
-
3321
- RE_FROM_SELF = re.compile(r"\bfrom\s+self\b", re.IGNORECASE)
3901
+ sql = sql.replace("$#", f"(`{RID}` + 1)")
3902
+ if RID not in sql and "*" not in sql:
3903
+ # Ensure RID is selected
3904
+ import re
3322
3905
 
3323
- sql = RE_FROM_SELF.sub(f", `{RIDX}` FROM self", sql)
3906
+ RE_FROM_SELF = re.compile(r"\bFROM\s+self\b", re.IGNORECASE)
3907
+ sql = RE_FROM_SELF.sub(f", `{RID}` FROM self", sql)
3324
3908
 
3325
3909
  # Execute the SQL query
3326
3910
  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()
3911
+ df_filtered = self.df.lazy().sql(sql).collect()
3332
3912
 
3333
3913
  if not len(df_filtered):
3334
3914
  self.notify(
@@ -3336,29 +3916,34 @@ class DataFrameTable(DataTable):
3336
3916
  )
3337
3917
  return
3338
3918
 
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
3919
  except Exception as e:
3358
3920
  self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error", timeout=10)
3359
3921
  self.log(f"Error executing SQL query `{sql}`: {str(e)}")
3360
3922
  return
3361
3923
 
3924
+ # Add to history
3925
+ self.add_history(f"SQL Query:\n[$success]{sql}[/]", dirty=not view)
3926
+
3927
+ # Create a view of self.df as a copy
3928
+ if view and self.df_view is None:
3929
+ self.df_view = self.df
3930
+
3931
+ # Clear view for filter mode
3932
+ if not view:
3933
+ self.df_view = None
3934
+
3935
+ # Update dataframe
3936
+ self.df = df_filtered
3937
+ ok_rids = set(df_filtered[RID])
3938
+
3939
+ # Update selected rows
3940
+ if self.selected_rows:
3941
+ self.selected_rows.intersection_update(ok_rids)
3942
+
3943
+ # Update matches
3944
+ if self.matches:
3945
+ self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
3946
+
3362
3947
  # Recreate table for display
3363
3948
  self.setup_table()
3364
3949